Skip to content

Commit a50b943

Browse files
Technologicatclaude
andcommitted
Fix #125: attribute decorator-argument uses to the decorated function
Names appearing inside a decorator's call arguments are visited at module scope (Python evaluates decorator expressions at definition time), so the resulting uses edges landed on the enclosing module only. Functions like `@app.get("/x", dependencies=[Depends(Guard())]) def fn(): ...` showed no edge to Depends or Guard — those hung off the module instead. Mirror the existing treatment of default values: visit decorators in the enclosing scope (unchanged), but also record which targets are touched and re-emit the same uses edges from the decorated function's node. This preserves the module-level edges (a decorator expression really does run at import time) while adding the edges the call-graph reader expects. Implementation: a small `_decorator_use_recorders` stack. `add_uses_edge` appends the to_node to the top recorder regardless of whether the edge itself is new — otherwise, if two functions in the same module share a decorator, the second one would see an empty delta because the edge is deduplicated. `visit_FunctionDef` pushes/pops the recorder around `analyze_functiondef` and replays the captured targets once inside the function scope. Reported by @doctorgu (#125). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9180ca2 commit a50b943

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
## 2.4.3 (in progress)
44

5-
*No user-visible changes yet.*
5+
### Bug fixes
6+
7+
- **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)
68

79

810
---

pyan/analyzer.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ def _init_common(self, logger):
134134
"""Shared initialization for both constructors."""
135135
self.logger = logger or logging.getLogger(__name__)
136136

137+
# Stack of sets; while non-empty, ``add_uses_edge`` records each edge's
138+
# to_node into the top set. Used by visit_FunctionDef to capture uses
139+
# coming from decorator arguments (#125) even when those edges already
140+
# exist on the enclosing module's adjacency set.
141+
self._decorator_use_recorders = []
142+
137143
# data gathered from analysis
138144
self.module_to_filename = {} # module name → filename (or module name itself in source mode)
139145
self.defines_edges = {}
@@ -638,6 +644,8 @@ def visit_FunctionDef(self, node):
638644
#
639645
# - Analyze decorators. They belong to the surrounding scope,
640646
# so we must analyze them before entering the function scope.
647+
# Record every use target touched during that analysis (#125);
648+
# see the re-attribution below.
641649
#
642650
# - Determine whether this definition is for a function, an (instance)
643651
# method, a static method or a class method.
@@ -646,7 +654,12 @@ def visit_FunctionDef(self, node):
646654
# method or a class method. (For a class method, it represents cls,
647655
# but Pyan only cares about types, not instances.)
648656
#
649-
self_name, flavor = self.analyze_functiondef(node)
657+
decorator_uses = set()
658+
self._decorator_use_recorders.append(decorator_uses)
659+
try:
660+
self_name, flavor = self.analyze_functiondef(node)
661+
finally:
662+
self._decorator_use_recorders.pop()
650663

651664
# Now we can create the Node.
652665
#
@@ -703,6 +716,15 @@ def visit_FunctionDef(self, node):
703716
# show `f → some_func`.
704717
self._record_default_uses_in_function(node.args)
705718

719+
# Same treatment for decorator arguments (#125). Decorators are
720+
# visited in the enclosing scope (Python evaluates them there at
721+
# definition time), but the decorated function is meaningfully tied
722+
# to whatever names the decorator references — e.g.
723+
# `@app.get("/x", dependencies=[Depends(Guard())])` should show
724+
# the function using Depends and Guard, not just the module.
725+
for tgt in decorator_uses:
726+
self.add_uses_edge(to_node, tgt)
727+
706728
# Visit type annotations to create uses edges for referenced types.
707729
#
708730
# NOTE: Strictly, Python evaluates annotations in the *enclosing*
@@ -2220,6 +2242,13 @@ def add_defines_edge(self, from_node, to_node):
22202242
def add_uses_edge(self, from_node, to_node):
22212243
"""Add a uses edge in the graph between two nodes."""
22222244

2245+
# Record decorator-argument targets (#125) regardless of whether the
2246+
# underlying edge is new: if another function in the same module has
2247+
# already added ``module → foo``, the edge is deduplicated but we
2248+
# still need to see ``foo`` here to attribute it to the decorated fn.
2249+
for rec in self._decorator_use_recorders:
2250+
rec.add(to_node)
2251+
22232252
if from_node not in self.uses_edges:
22242253
self.uses_edges[from_node] = set()
22252254
if to_node in self.uses_edges[from_node]:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Issue #125: names referenced inside a decorator's call arguments
2+
# should also be attributed to the decorated function, not only to the
3+
# enclosing module scope. Mirrors FastAPI-style patterns like
4+
# ``@router.get(path, dependencies=[Depends(Guard())])``.
5+
6+
7+
def depends(callable_):
8+
return callable_
9+
10+
11+
class Guard:
12+
def __init__(self):
13+
pass
14+
15+
16+
def route(path, dependencies=None):
17+
def decorator(fn):
18+
return fn
19+
return decorator
20+
21+
22+
@route("/open")
23+
def open_route():
24+
return "ok"
25+
26+
27+
@route("/secure", dependencies=[depends(Guard())])
28+
def secure_route():
29+
return "ok"
30+
31+
32+
@route("/mixed", dependencies=[depends(Guard())])
33+
def mixed_route(token=depends(Guard())):
34+
return token

tests/test_regressions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,50 @@ def test_init_imports_toplevel_init_dot_import():
216216
mypkg_init = f"{INIT_IMPORTS_PREFIX}.mypkg"
217217
mypkg_uses = get_in_dict(v.uses_edges, mypkg_init)
218218
get_node(mypkg_uses, f"{INIT_IMPORTS_PREFIX}.mypkg.sub")
219+
220+
221+
# --- Issue #125: decorator arguments should be attributed to the decorated function ---
222+
#
223+
# Python evaluates decorator expressions at definition time in the enclosing scope,
224+
# so a decorator's argument uses naturally attach to the enclosing module. But for
225+
# call-graph purposes, the *decorated function* also "uses" those names — e.g. in
226+
# ``@app.get("/x", dependencies=[Depends(Guard())]) def fn(): ...`` the function
227+
# fn is meaningfully tied to Depends and Guard, not just the module. Mirrors the
228+
# treatment of default values.
229+
230+
ISSUE125_FILE = os.path.join(TESTS_DIR, "test_code/issue125/fastapi_style.py")
231+
232+
233+
def test_issue125_decorator_args_attributed_to_function():
234+
"""Names inside a decorator's call arguments should appear as uses of the
235+
decorated function, not only of the enclosing module."""
236+
v = CallGraphVisitor([ISSUE125_FILE], logger=logging.getLogger())
237+
238+
secure_uses = get_in_dict(v.uses_edges, "fastapi_style.secure_route")
239+
# From the decorator call ``@route("/secure", dependencies=[depends(Guard())])``.
240+
get_node(secure_uses, "fastapi_style.depends")
241+
get_node(secure_uses, "fastapi_style.Guard")
242+
# The decorator function itself is also a use.
243+
get_node(secure_uses, "fastapi_style.route")
244+
245+
246+
def test_issue125_bare_decorator_without_callable_args():
247+
"""A decorator with no callable arguments should still attribute the
248+
decorator name to the function, but nothing spurious."""
249+
v = CallGraphVisitor([ISSUE125_FILE], logger=logging.getLogger())
250+
251+
open_uses = get_in_dict(v.uses_edges, "fastapi_style.open_route")
252+
get_node(open_uses, "fastapi_style.route")
253+
target_names = {n.get_name() for n in open_uses}
254+
assert "fastapi_style.depends" not in target_names
255+
assert "fastapi_style.Guard" not in target_names
256+
257+
258+
def test_issue125_mixed_decorator_and_default():
259+
"""When a name appears in both a decorator argument and a default value,
260+
the function should still have exactly one edge to it (edges deduplicate)."""
261+
v = CallGraphVisitor([ISSUE125_FILE], logger=logging.getLogger())
262+
263+
mixed_uses = get_in_dict(v.uses_edges, "fastapi_style.mixed_route")
264+
get_node(mixed_uses, "fastapi_style.depends")
265+
get_node(mixed_uses, "fastapi_style.Guard")

0 commit comments

Comments
 (0)