Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3581,6 +3581,41 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
# node orphaned (#1050). Treat decorated_definition as a transparent
# wrapper so parent_class_nid propagates to the real function node.
if t == "decorated_definition":
# Applying a decorator emitted no edge to the decorator symbol, so
# `affected <decorator>` reported nothing for the functions it wraps
# (#2154). Emit the same shape TS/JS already emits in
# `_ts_emit_decorator_edges`: a `references` edge (context=
# "decorator") from the decorated function/class to each decorator,
# resolved via ensure_named_node so an imported decorator becomes a
# sourceless stub the corpus rewire collapses onto its definition.
# The owner ids mirror the definition branches below/above verbatim,
# so the edge lands on the node the walk is about to create.
if config.ts_module == "tree_sitter_python":
inner = node.child_by_field_name("definition")
inner_name = None
if inner is not None:
name_node = inner.child_by_field_name("name")
inner_name = _read_text(name_node, source) if name_node else None
# A name that normalizes to nothing is skipped by the definition
# branches (#1899), so an edge to it would dangle.
if inner_name and normalize_id(inner_name):
if inner.type in config.class_types:
owner_nid = _make_id(stem, ".".join(namespace_stack), inner_name)
elif parent_class_nid:
owner_nid = _make_id(parent_class_nid, inner_name)
else:
owner_nid = _make_id(stem, inner_name)
for child in node.children:
if child.type != "decorator":
continue
deco_name = _python_decorator_name(child, source)
if not deco_name:
continue
deco_line = child.start_point[0] + 1
target = ensure_named_node(deco_name, deco_line)
if target != owner_nid:
add_edge(owner_nid, target, "references", deco_line,
context="decorator")
for child in node.children:
walk(child, parent_class_nid=parent_class_nid)
return
Expand Down Expand Up @@ -4480,6 +4515,28 @@ def _scan_js_module_dispatch(n) -> None:
result["csharp_type_table"] = {"path": str_path, "table": cs_table}
return result

def _python_decorator_name(deco_node, source: bytes) -> str | None:
"""Return the head symbol of a Python `decorator` node.

The Python twin of `_ts_decorator_name`, differing only in grammar node
names: `@traced` -> the identifier; `@retry(times=3)` -> the `function` of
the `call`; `@app.route("/")` / `@mod.deco` -> the `attribute` (the symbol
itself, not the module alias it is reached through).
"""
for child in deco_node.children:
if not child.is_named:
continue
target = child
if target.type == "call":
target = target.child_by_field_name("function") or target
if target.type == "attribute":
attr = target.child_by_field_name("attribute")
return _read_text(attr, source) if attr else None
if target.type == "identifier":
return _read_text(target, source)
return None
return None

def _ts_decorator_name(deco_node, source: bytes) -> str | None:
"""Return the head symbol of a TS `decorator` node.

Expand Down
168 changes: 168 additions & 0 deletions tests/test_python_decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Regression tests: Python decorator references (#2154).

Applying a Python decorator emitted no edge to the decorator symbol, so
`affected <decorator>` answered "No affected nodes found" for every function it
wraps — a silent false negative on reverse-impact queries.

TS/JS already emitted these edges (`_ts_emit_decorator_edges`); the Python
`decorated_definition` branch walked its children only to propagate the parent
class id (#1050) and never looked at the `decorator` children. Python now emits
the same shape: `references` edges with context="decorator" from the decorated
function/class to the decorator symbol, resolved through the same
sourceless-stub path as type references so an imported decorator collapses onto
its real definition.
"""
from pathlib import Path

from graphify.extract import _file_stem, _make_id, extract


def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path


def _stem(file: str) -> str:
return _file_stem(Path(file))


def _func_nid(file: str, func: str) -> str:
return _make_id(_stem(file), func)


def _class_nid(file: str, cls: str) -> str:
return _make_id(_stem(file), cls)


def _method_nid(file: str, cls: str, method: str) -> str:
return _make_id(_class_nid(file, cls), method)


def _deco_edges(result: dict, owner_nid: str) -> set[str]:
"""Decorator-reference edge targets emitted from owner_nid."""
return {
e["target"]
for e in result["edges"]
if e["source"] == owner_nid
and e["relation"] == "references"
and e.get("context") == "decorator"
}


def test_module_level_function_decorator(tmp_path):
# The issue repro: decorator imported from another module, applied to a
# module-level function. Target is the sourceless stub the rewire collapses.
f = _write(tmp_path / "pkg" / "consumer.py",
"from deco import my_decorator\n"
"\n"
"@my_decorator\n"
"def business_logic():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
assert _make_id("my_decorator") in _deco_edges(
r, _func_nid("pkg/consumer.py", "business_logic"))


def test_same_file_decorator_resolves_to_local_definition(tmp_path):
# Decorator defined above its use in the same file: the edge must point at
# the real local node, not a stub.
f = _write(tmp_path / "pkg" / "local.py",
"def my_decorator(fn):\n"
" return fn\n"
"\n"
"@my_decorator\n"
"def business_logic():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
assert _func_nid("pkg/local.py", "my_decorator") in _deco_edges(
r, _func_nid("pkg/local.py", "business_logic"))


def test_decorator_with_arguments(tmp_path):
# `@deco(arg)` is a `call` node; the head symbol is its `function` field.
f = _write(tmp_path / "pkg" / "args.py",
"from deco import retry\n"
"\n"
"@retry(times=3)\n"
"def flaky():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
assert _make_id("retry") in _deco_edges(r, _func_nid("pkg/args.py", "flaky"))


def test_attribute_decorator_targets_the_symbol_not_the_module(tmp_path):
# `@app.route("/")` is an `attribute` under a `call`; the target is `route`,
# matching _ts_decorator_name's member_expression handling.
f = _write(tmp_path / "pkg" / "web.py",
"import app\n"
"\n"
"@app.route(\"/\")\n"
"def index():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
targets = _deco_edges(r, _func_nid("pkg/web.py", "index"))
assert _make_id("route") in targets
assert _make_id("app") not in targets


def test_stacked_decorators_all_emit(tmp_path):
f = _write(tmp_path / "pkg" / "stack.py",
"from deco import a, b, c\n"
"\n"
"@a\n"
"@b\n"
"@c\n"
"def target():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
targets = _deco_edges(r, _func_nid("pkg/stack.py", "target"))
assert {_make_id("a"), _make_id("b"), _make_id("c")} <= targets


def test_decorated_method_owner_is_class_qualified(tmp_path):
# Guards the #1050 interaction: the owner must be the class-qualified method
# id, not a bare module-level id.
f = _write(tmp_path / "pkg" / "svc.py",
"from deco import traced\n"
"\n"
"class Service:\n"
" @traced\n"
" def handle(self):\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
assert _make_id("traced") in _deco_edges(
r, _method_nid("pkg/svc.py", "Service", "handle"))


def test_property_still_class_qualified(tmp_path):
# #1050 regression guard: @property/@staticmethod must not change the
# method node's class-qualified id.
f = _write(tmp_path / "pkg" / "prop.py",
"class Config:\n"
" @property\n"
" def name(self):\n"
" return 1\n")
r = extract([f], cache_root=tmp_path)
assert any(n["id"] == _method_nid("pkg/prop.py", "Config", "name")
for n in r["nodes"])


def test_decorated_class(tmp_path):
f = _write(tmp_path / "pkg" / "model.py",
"from dataclasses import dataclass\n"
"\n"
"@dataclass\n"
"class Point:\n"
" x: int\n")
r = extract([f], cache_root=tmp_path)
assert _make_id("dataclass") in _deco_edges(
r, _class_nid("pkg/model.py", "Point"))


def test_undecorated_function_emits_no_decorator_edge(tmp_path):
f = _write(tmp_path / "pkg" / "plain.py",
"def plain():\n"
" pass\n")
r = extract([f], cache_root=tmp_path)
assert _deco_edges(r, _func_nid("pkg/plain.py", "plain")) == set()
Loading