Skip to content

Commit bd31dd7

Browse files
Technologicatclaude
andcommitted
Analyze class decorators (#125 follow-up)
visit_ClassDef previously ignored node.decorator_list entirely, so class decorators like `@dataclass` or `@register(kind="x")` produced no uses edges anywhere — neither for the module nor for the decorated class. Spotted while fixing #125. Apply the same treatment as function decorators: visit the decorator expressions in the enclosing scope (Python evaluates them there at definition time), record the uses via the `_decorator_use_recorders` stack, and re-emit them from the class node once we're inside the class scope. Fixture extended with `ApiHandler` / `SecureApiHandler` to cover the bare and callable-argument cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a50b943 commit bd31dd7

4 files changed

Lines changed: 52 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Bug fixes
66

77
- **Names referenced inside a decorator's arguments are now attributed to the decorated function**, not only to the enclosing module. Previously, a function decorated with e.g. `@app.get("/x", dependencies=[Depends(Guard())])` showed no uses of `Depends` or `Guard` — those edges landed on the module instead. The function now also gets a uses edge to each target referenced in its decorator arguments, mirroring the existing treatment of default values. (#125 — thanks @doctorgu)
8+
- **Class decorators are now analyzed** — previously `visit_ClassDef` ignored `decorator_list` entirely, so `@dataclass` or `@register(kind="x")` on a class produced no uses edges anywhere. Class decorators now behave like function decorators: the decorator expression is visited at module scope, and referenced names are also attributed to the decorated class.
89

910

1011
---

pyan/analyzer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,17 @@ def visit_Module(self, node):
602602
def visit_ClassDef(self, node):
603603
self.logger.debug(f"ClassDef {node.name}, {self.filename}:{node.lineno}")
604604

605+
# Visit decorators in the enclosing scope (Python evaluates them there
606+
# at definition time), recording every use target touched. We replay
607+
# those as uses of the decorated class below (#125).
608+
decorator_uses = set()
609+
self._decorator_use_recorders.append(decorator_uses)
610+
try:
611+
for deco in node.decorator_list:
612+
self.visit(deco)
613+
finally:
614+
self._decorator_use_recorders.pop()
615+
605616
from_node = self.get_node_of_current_namespace()
606617
ns = from_node.get_name()
607618
to_node = self.get_node(ns, node.name, node, flavor=Flavor.CLASS)
@@ -634,6 +645,11 @@ def visit_ClassDef(self, node):
634645
# mark uses from a derived class to its bases (via names appearing in a load context).
635646
self.visit(b)
636647

648+
# Re-emit decorator-argument uses from the class node (same
649+
# rationale as in visit_FunctionDef).
650+
for tgt in decorator_uses:
651+
self.add_uses_edge(to_node, tgt)
652+
637653
for stmt in node.body:
638654
self.visit(stmt)
639655

tests/test_code/issue125/fastapi_style.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,18 @@ def secure_route():
3232
@route("/mixed", dependencies=[depends(Guard())])
3333
def mixed_route(token=depends(Guard())):
3434
return token
35+
36+
37+
# Class decorators should receive the same treatment.
38+
39+
40+
@route("/api")
41+
class ApiHandler:
42+
def handle(self):
43+
return "ok"
44+
45+
46+
@route("/api/secure", dependencies=[depends(Guard())])
47+
class SecureApiHandler:
48+
def handle(self):
49+
return "ok"

tests/test_regressions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,23 @@ def test_issue125_mixed_decorator_and_default():
263263
mixed_uses = get_in_dict(v.uses_edges, "fastapi_style.mixed_route")
264264
get_node(mixed_uses, "fastapi_style.depends")
265265
get_node(mixed_uses, "fastapi_style.Guard")
266+
267+
268+
def test_issue125_class_decorator_bare():
269+
"""Class decorators were previously ignored entirely. The decorator name
270+
should now appear as a use of the decorated class."""
271+
v = CallGraphVisitor([ISSUE125_FILE], logger=logging.getLogger())
272+
273+
api_uses = get_in_dict(v.uses_edges, "fastapi_style.ApiHandler")
274+
get_node(api_uses, "fastapi_style.route")
275+
276+
277+
def test_issue125_class_decorator_with_callable_args():
278+
"""Names inside a class decorator's arguments should be attributed to the
279+
decorated class, mirroring the function-decorator behavior."""
280+
v = CallGraphVisitor([ISSUE125_FILE], logger=logging.getLogger())
281+
282+
secure_uses = get_in_dict(v.uses_edges, "fastapi_style.SecureApiHandler")
283+
get_node(secure_uses, "fastapi_style.route")
284+
get_node(secure_uses, "fastapi_style.depends")
285+
get_node(secure_uses, "fastapi_style.Guard")

0 commit comments

Comments
 (0)