Skip to content

Commit bcc2e13

Browse files
Technologicatclaude
andcommitted
Track del statement protocol methods (D7)
- Add visit_Delete: `del obj.attr` → __delattr__ edge, `del obj[key]` → __delitem__ edge. Bare `del name` is a no-op (flow-insensitive analyzer can't safely clear bindings). - Three new tests: del_attr, del_item, del_name_no_protocol_edge - Unify Store/Del context comments across visit_Attribute, visit_Name - Update CHANGELOG, TODO_DEFERRED, REMAINING-ITEMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e76930a commit bcc2e13

6 files changed

Lines changed: 98 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
- Plain-text output format (`--text` / `format="text"`) for both call-graph
1515
and module-level modes. Sorted adjacency list with `[D]`/`[U]` edge tags.
16+
- **`del` statement protocol tracking**`del obj.attr` now generates a uses
17+
edge to `__delattr__`, and `del obj[key]` to `__delitem__`. Complements the
18+
existing `__enter__`/`__exit__` tracking for `with`.
1619

1720
### Other
1821

REMAINING-ITEMS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ v2.0.0 released 2026-02-19.
1515

1616
| Item | Description |
1717
|------|-------------|
18-
| D7 | `Del` context tracking |
1918
| D8 | Iterator protocol tracking + `is_async` |
2019
| D10 | `visit_Name` local variable noise |
2120

@@ -27,11 +26,12 @@ v2.0.0 released 2026-02-19.
2726
| D13 | Per-comprehension scope isolation |
2827
| D14 | "Node" terminology overload |
2928
| D15 | modvis multi-project coloring |
29+
| D17 | README example graph (synthetic showcase) |
3030

31-
## Completed (shipped in 2.0.0)
31+
## Completed
3232

33-
All M, S, and N1–N7 items. D1–D6, D9, D11, D16. CI, badges, flake8.
34-
See `CHANGELOG.md` for the full list.
33+
All M, S, and N1–N7 items. D1–D7, D9, D11, D16. CI, badges, flake8.
34+
See `CHANGELOG.md` for the 2.0.0 manifest.
3535

3636
## Canonical detail
3737

TODO_DEFERRED.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
## Medium
1313

1414
- **D6: README: document `--module-level` mode**: ✓ Done (`831f31c`). Also added --svg/--html to modvis CLI.
15-
- **D7: `Del` context tracking**: Currently silently ignored (falls through the `ast.Load` guard) in `visit_Attribute`/`visit_Name`. Could track `__delattr__`/`__del__` protocol calls for completeness, similar to how `__enter__`/`__exit__` are tracked for `with`.
15+
- **D7: `Del` context tracking**: ✓ Done. `visit_Delete` tracks `__delattr__` for `del obj.attr` and `__delitem__` for `del obj[key]`. Bare `del name` is a no-op (just unbinds). Three tests added.
1616
- **D8: Iterator protocol tracking + `is_async`**: We already track the context manager protocol (`__enter__`/`__exit__` for `with`). Tracking the iterator protocol (`__iter__`/`__next__`) would be a natural addition — and would make `analyze_comprehension`'s ignored `ast.comprehension.is_async` field relevant too (`__aiter__`/`__anext__` vs `__iter__`/`__next__`).
1717
- **D9: modvis `filename_to_module_name` cwd fragility**: ✓ Done (`c9cc075`+`a310477`). Added `root` parameter to `filename_to_module_name`, `ImportVisitor`, `create_modulegraph`, and CLI `--root`. Root is inferred by default (walk up past `__init__.py` dirs).
1818
- **D10: `visit_Name` local variable noise**: When a local has no known value, a wildcard `UNKNOWN`-flavored node is created (analyzer.py:721–725). The existing TODO suggests skipping node creation for locals in the innermost scope — would reduce graph noise and postprocessor cleanup work.
@@ -24,3 +24,4 @@
2424
- **D13: Per-comprehension scope isolation**: All listcomps (or setcomps, etc.) in the same function share one scope key (e.g. `"module.func.listcomp"`). This is the same on both pre-3.12 (last symtable child wins) and 3.12+ (synthetic scope shared). Would need numbered keys to isolate each comprehension's bindings.
2525
- **D14: "Node" terminology overload**: Three concepts share the name "node": (1) AST node (`ast.AST`), (2) Pyan's analysis graph node (`Node` class), (3) visualization/output node. Check whether all three are still conflated and consider introducing distinct terminology to reduce confusion.
2626
- **D15: modvis multi-project coloring**: When analyzing files from several projects in one run, hue could be decided by the top-level directory name (after `./` if any), and lightness by depth in each tree. This would match how the call-graph analyzer colors functions/classes. Currently all modules are colored by their immediate package directory.
27+
- **D17: README example graph**: The current `graph0.svg` (generated from `pyan.modvis`) is realistic but visually cluttered for a front-page showcase. Replace with a synthetic multi-file example designed to demonstrate the features described in the README's "About" section: uses edges (including recursion and mutual recursion), HSL node coloring (hue by file, lightness by nesting depth), translucent fills, and grouping. Recursion should be visible as a self-loop (A→A), and mutual recursion as a pair of arrows (B→C, C→B). Use meaningful names — no `foo`/`bar`. Omit defines edges — they add noise without aiding first impression. Keep the synthetic source files in the repo (e.g. `examples/`) so the graph is reproducible.

pyan/analyzer.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,9 @@ def visit_Constant(self, node):
639639
tn = t.__name__
640640
return self.get_node(ns, tn, node, flavor=Flavor.ATTRIBUTE)
641641

642-
# attribute access (node.ctx is ast.Load/Store/Del; Store is handled by _bind_target)
642+
# attribute access (node.ctx is ast.Load/Store/Del)
643+
# Store context: handled by _bind_target
644+
# Del context: handled by visit_Delete (protocol method edges)
643645
def visit_Attribute(self, node):
644646
objname = get_ast_node_name(node.value)
645647
self.logger.debug(
@@ -708,9 +710,12 @@ def visit_Attribute(self, node):
708710
# pass on
709711
else:
710712
return self.visit(node.value)
711-
# Store/Del contexts: no action (Store handled by _bind_target)
713+
# Store context: handled by _bind_target
714+
# Del context: handled by visit_Delete (protocol method edges)
712715

713-
# name access (node.ctx is ast.Load/Store/Del; Store is handled by _bind_target)
716+
# name access (node.ctx is ast.Load/Store/Del)
717+
# Store context: handled by _bind_target
718+
# Del context: handled by visit_Delete (protocol method edges)
714719
def visit_Name(self, node):
715720
self.logger.debug("Name %s in context %s, %s:%s" % (node.id, type(node.ctx), self.filename, node.lineno))
716721

@@ -731,7 +736,8 @@ def visit_Name(self, node):
731736
self.logger.info("New edge added for Use from %s to Name %s" % (from_node, to_node))
732737

733738
return to_node
734-
# Store/Del contexts: no action (Store handled by _bind_target)
739+
# Store context: handled by _bind_target
740+
# Del context: handled by visit_Delete (protocol method edges)
735741

736742
def visit_Assign(self, node):
737743
# - chaining assignments like "a = b = c" produces multiple targets
@@ -1018,6 +1024,41 @@ def visit_With(self, node):
10181024
def visit_AsyncWith(self, node):
10191025
self._visit_with(node, "__aenter__", "__aexit__")
10201026

1027+
def visit_Delete(self, node):
1028+
"""Track protocol method calls implied by `del` statements.
1029+
1030+
`del obj.attr` invokes `obj.__delattr__("attr")`.
1031+
`del obj[key]` invokes `obj.__delitem__(key)`.
1032+
`del name` just unbinds a local — no protocol call.
1033+
1034+
NOTE: `del name` also invalidates prior value bindings for `name`,
1035+
so a subsequent `name.attr` would be a NameError at runtime. We do
1036+
not clear the binding here because the analyzer is flow-insensitive —
1037+
the `del` might sit in a branch that doesn't always execute, or come
1038+
after the use in source order but not in control flow. Clearing would
1039+
be wrong as often as right. Revisit if flow sensitivity is ever added.
1040+
"""
1041+
self.logger.debug("Delete, %s:%s" % (self.filename, node.lineno))
1042+
from_node = self.get_node_of_current_namespace()
1043+
for target in node.targets:
1044+
if isinstance(target, ast.Attribute):
1045+
obj_node = self.visit(target.value)
1046+
if isinstance(obj_node, Node):
1047+
to_node = self.get_node(obj_node.get_name(), "__delattr__", None, flavor=Flavor.METHOD)
1048+
self.logger.debug("Use from %s to %s (del attr)" % (from_node, to_node))
1049+
if self.add_uses_edge(from_node, to_node):
1050+
self.logger.info("New edge added for Use from %s to %s (del attr)" % (from_node, to_node))
1051+
elif isinstance(target, ast.Subscript):
1052+
obj_node = self.visit(target.value)
1053+
if isinstance(obj_node, Node):
1054+
to_node = self.get_node(obj_node.get_name(), "__delitem__", None, flavor=Flavor.METHOD)
1055+
self.logger.debug("Use from %s to %s (del item)" % (from_node, to_node))
1056+
if self.add_uses_edge(from_node, to_node):
1057+
self.logger.info("New edge added for Use from %s to %s (del item)" % (from_node, to_node))
1058+
# Also visit the slice — it may contain names/calls.
1059+
self.visit(target.slice)
1060+
# ast.Name in ast.Del context: just unbinds, no protocol call.
1061+
10211062
# --- Match statement (PEP 634, Python 3.10+) ---
10221063

10231064
def visit_Match(self, node):

tests/test_code/features.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,26 @@ def annotated_func(x: MyType) -> ReturnType:
210210

211211
class Holder:
212212
value: MyType
213+
214+
215+
# --- Del statement ---
216+
217+
class Registry:
218+
def __delattr__(self, name):
219+
pass
220+
221+
def __delitem__(self, key):
222+
pass
223+
224+
225+
def clear_entry(registry):
226+
registry = Registry()
227+
del registry.entry
228+
229+
def remove_item(registry):
230+
registry = Registry()
231+
del registry["key"]
232+
233+
def unbind_local():
234+
tmp = 1
235+
del tmp

tests/test_features.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,27 @@ def test_class_body_annotation_uses(v):
197197
get_node(uses, f"{PREFIX}.MyType")
198198

199199

200+
# --- Del statement ---
201+
202+
def test_del_attr(v):
203+
"""del registry.entry creates uses edge to Registry.__delattr__."""
204+
uses = get_in_dict(v.uses_edges, f"{PREFIX}.clear_entry")
205+
get_node(uses, f"{PREFIX}.Registry.__delattr__")
206+
207+
208+
def test_del_item(v):
209+
"""del registry["key"] creates uses edge to Registry.__delitem__."""
210+
uses = get_in_dict(v.uses_edges, f"{PREFIX}.remove_item")
211+
get_node(uses, f"{PREFIX}.Registry.__delitem__")
212+
213+
214+
def test_del_name_no_protocol_edge(v):
215+
"""del tmp (bare name) should not create protocol method edges."""
216+
# unbind_local has no uses edges at all — it shouldn't even appear as a key.
217+
names = [node.get_name() for node in v.uses_edges.keys()]
218+
assert f"{PREFIX}.unbind_local" not in names
219+
220+
200221
# --- Type aliases (PEP 695, Python 3.12+) ---
201222

202223
PREFIX_312 = "test_code_312.type_aliases"

0 commit comments

Comments
 (0)