mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
164 lines
6.8 KiB
Python
164 lines
6.8 KiB
Python
"""Reading a `@property` runs its getter, so the read is a call of it.
|
|
|
|
`response.text` names no call, but runs `Response.text` when that is a property. Reads whose receiver
|
|
is typed the way a member call would be (`self`, a typed local or parameter, a `self.X` field bound to
|
|
one class) get a `references` edge with `context: property_read`, and only when the target is a property
|
|
spelled exactly like the read.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract
|
|
|
|
_RESPONSE = (
|
|
"class Response:\n"
|
|
" def __init__(self):\n"
|
|
" self.count = 0\n"
|
|
" self._items = []\n"
|
|
"\n"
|
|
" @property\n"
|
|
" def text(self):\n"
|
|
" return ''\n"
|
|
"\n"
|
|
" @property\n"
|
|
" def items(self):\n"
|
|
" return self._items\n"
|
|
"\n"
|
|
" def handle(self):\n"
|
|
" return self.text\n"
|
|
)
|
|
|
|
|
|
def _edges(tmp_path: Path, files: dict[str, str]):
|
|
paths = []
|
|
for name, body in files.items():
|
|
p = tmp_path / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(body, encoding="utf-8")
|
|
paths.append(p)
|
|
r = extract(paths, root=tmp_path, cache_root=tmp_path / "graphify-out", parallel=False)
|
|
nodes = {n["id"]: n for n in r["nodes"]}
|
|
owner = {e["target"]: nodes[e["source"]]["label"] for e in r["edges"] if e["relation"] == "method"}
|
|
return {(nodes[e["source"]]["label"], owner.get(e["target"], ""), nodes[e["target"]]["label"], e.get("context"))
|
|
for e in r["edges"] if e["relation"] in ("calls", "references")}
|
|
|
|
|
|
def test_self_property_read_in_its_own_class(tmp_path: Path):
|
|
edges = _edges(tmp_path, {"resp.py": _RESPONSE})
|
|
assert (".handle()", "Response", ".text()", "property_read") in edges
|
|
|
|
|
|
def test_typed_local_and_parameter_reads_across_files(tmp_path: Path):
|
|
edges = _edges(tmp_path, {
|
|
"resp.py": _RESPONSE,
|
|
"use.py": ("from resp import Response\n\n\n"
|
|
"def fetch():\n r = Response()\n return r.text\n\n\n"
|
|
"def show(r: Response):\n return r.items\n"),
|
|
})
|
|
assert ("fetch()", "Response", ".text()", "property_read") in edges
|
|
assert ("show()", "Response", ".items()", "property_read") in edges
|
|
|
|
|
|
def test_self_field_typed_receiver(tmp_path: Path):
|
|
edges = _edges(tmp_path, {
|
|
"resp.py": _RESPONSE,
|
|
"client.py": ("from resp import Response\n\n\nclass Client:\n"
|
|
" def __init__(self):\n self.last = Response()\n\n"
|
|
" def body(self):\n return self.last.text\n"),
|
|
})
|
|
assert (".body()", "Response", ".text()", "property_read") in edges
|
|
|
|
|
|
def test_fields_method_references_and_stores_are_not_reads(tmp_path: Path):
|
|
edges = _edges(tmp_path, {
|
|
"resp.py": _RESPONSE,
|
|
"use.py": ("from resp import Response\n\n\n"
|
|
"def poke():\n r = Response()\n r.count\n cb = r.handle\n r.text = 'x'\n return cb\n"),
|
|
})
|
|
assert not any(e[0] == "poke()" and e[3] == "property_read" for e in edges), edges
|
|
|
|
|
|
def test_augmented_assignment_reads_the_property(tmp_path: Path):
|
|
# `r.text += x` runs the getter before the setter.
|
|
edges = _edges(tmp_path, {
|
|
"resp.py": _RESPONSE,
|
|
"use.py": "from resp import Response\n\n\ndef extend():\n r = Response()\n r.text += 'x'\n",
|
|
})
|
|
assert ("extend()", "Response", ".text()", "property_read") in edges
|
|
|
|
|
|
def test_underscored_field_does_not_reach_the_property_without_underscore(tmp_path: Path):
|
|
# `_items` and `items` fold to one key in the resolver; the read names `_items`.
|
|
edges = _edges(tmp_path, {
|
|
"resp.py": _RESPONSE,
|
|
"use.py": "from resp import Response\n\n\ndef raw():\n r = Response()\n return r._items\n",
|
|
})
|
|
assert not any(e[0] == "raw()" and e[3] == "property_read" for e in edges), edges
|
|
|
|
|
|
def test_property_inherited_from_a_base_in_another_file(tmp_path: Path):
|
|
edges = _edges(tmp_path, {
|
|
"base.py": "class Base:\n @property\n def name(self):\n return 'b'\n",
|
|
"child.py": "from base import Base\n\n\nclass Child(Base):\n def greet(self):\n return self.name\n",
|
|
})
|
|
assert (".greet()", "Base", ".name()", "property_read") in edges
|
|
|
|
|
|
def test_changed_files_rebuild_keeps_the_read_of_an_unchanged_property(tmp_path: Path):
|
|
from graphify.watch import _rebuild_code
|
|
|
|
files = {
|
|
"resp.py": _RESPONSE,
|
|
"use.py": "from resp import Response\n\n\ndef fetch():\n r = Response()\n return r.text\n",
|
|
}
|
|
roots = []
|
|
for name in ("inc", "full"):
|
|
root = tmp_path / name
|
|
root.mkdir()
|
|
for fname, body in files.items():
|
|
(root / fname).write_text(body, encoding="utf-8")
|
|
roots.append(root)
|
|
inc, full = roots
|
|
assert _rebuild_code(inc, no_cluster=True, acquire_lock=False, force=True)
|
|
for root in roots:
|
|
p = root / "use.py"
|
|
p.write_text(p.read_text(encoding="utf-8") + "# touched\n", encoding="utf-8")
|
|
assert _rebuild_code(inc, changed_paths=[inc / "use.py"], no_cluster=True, acquire_lock=False)
|
|
assert _rebuild_code(full, no_cluster=True, acquire_lock=False, force=True)
|
|
|
|
def calls_of(root):
|
|
g = json.loads((root / "graphify-out" / "graph.json").read_text(encoding="utf-8"))
|
|
return {(e["source"], e["target"]) for e in g.get("links", g.get("edges", []))
|
|
if e["relation"] in ("calls", "references")}
|
|
|
|
assert calls_of(inc) == calls_of(full)
|
|
assert ("use_fetch", "resp_response_text") in calls_of(full)
|
|
|
|
|
|
def test_schema_bump_retires_entries_cached_without_property_reads(tmp_path: Path, monkeypatch):
|
|
import graphify.cache as cache_mod
|
|
from graphify.cache import save_cached
|
|
from graphify.extract import extract_python
|
|
|
|
files = {"resp.py": _RESPONSE}
|
|
paths = []
|
|
for name, body in files.items():
|
|
(tmp_path / name).write_text(body, encoding="utf-8")
|
|
paths.append(tmp_path / name)
|
|
current = cache_mod._AST_CACHE_SCHEMA
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "same-version")
|
|
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", 7) # last schema without them
|
|
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
|
|
for p in paths:
|
|
stale = extract_python(p)
|
|
stale["raw_calls"] = [rc for rc in stale.get("raw_calls", []) if not rc.get("_python_attr_read")]
|
|
for node in stale["nodes"]:
|
|
node.pop("_py_property", None)
|
|
save_cached(p, stale, root=tmp_path, cache_root=tmp_path, kind="ast")
|
|
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current)
|
|
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
|
|
r = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
|
|
assert any(e["relation"] == "references" and e.get("context") == "property_read" for e in r["edges"])
|