You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
analyzer: module-level name bindings produce graph Nodes
Generalize the analyzer's notion of "defined Node" so that every named
entity reachable from outside its definition site has a Node — not just
classes, functions, modules, and imports.
Until now, module-level plain assignments (`CONSTANT = 42`, `LOGGER = ...`,
`store = _NS()`) were tracked only as scope-local set_value bindings, with
no Node in the graph. Cross-module imports contracted to wildcards, and
the #127 attribute-fallback had to climb all the way to the enclosing
module since the binding itself wasn't an addressable Node.
Now `_bind_target` creates a defined Flavor.NAME Node at the LHS dotted
path whenever the current scope is a module or class. Function-locals
stay as scope-only set_value bindings (promoting every loop variable to
a Node would clutter the graph).
Consequences:
- `from mymod import CONSTANT` resolves to the actual NAME Node rather
than contracting to a wildcard.
- The #127 fallback for `store.dataset` lands on `mymod.store` (the
binding) rather than the enclosing module — strictly more specific.
For the simple-attribute case the "fallback" no longer climbs at all,
since obj_node itself is already defined; the same code path still
fires but is now redundant with what the obj_node already provides.
The chained-access case still genuinely climbs through undefined
intermediate ATTRIBUTE Nodes.
- Edgeless NAME Nodes (module constants that nobody imports) are
suppressed from the rendered output by default in visgraph, so the
default visual density is unchanged. The Node still exists in the
analyzer's graph for cross-module resolution.
Tests:
- The three #127 regression tests now assert the more-specific edge
target (`namespace_module.store` instead of `namespace_module`). The
simple-case tests are renamed to `_emits_edge_to_binding`, dropping
the "falls_back" framing since obj_node is now already defined; the
chained-case test keeps "climbs_to_defined_ancestor" because the
climb through undefined intermediates is still genuine.
- Four new tests in test_features.py cover the prequisite directly:
module-level binding creates a defined NAME Node, function-local
does not, cross-module constant import resolves to the NAME Node,
visgraph suppresses edgeless NAME Nodes.
Prequisite to #129.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+9-1Lines changed: 9 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,12 +1,20 @@
1
1
# Changelog
2
2
3
-
## 2.5.1 (in progress)
3
+
## 2.6.0 (in progress)
4
+
5
+
### New features
6
+
7
+
-**Module-level name bindings now produce graph Nodes.** Every module-level assignment (e.g. `CONSTANT = 42`, `LOGGER = logging.getLogger(__name__)`, `store = _NS()`) creates a defined `Flavor.NAME` Node at the bound dotted path. `from mymod import x` now resolves to the actual binding instead of contracting to a wildcard, and the #127 attribute-fallback lands on the specific binding rather than climbing all the way to the enclosing module. Function-locals are unchanged — they stay as scope-only bindings to keep the graph readable. Edgeless NAME Nodes (module constants nobody imports) are suppressed from the rendered output by default; they remain in the analyzer's graph for cross-module resolution.
4
8
5
9
### Bug fixes
6
10
7
11
-**Cross-module attribute reads on namespace-style modules now produce uses edges.** A module whose public surface is a runtime-built object (`SimpleNamespace`, `unpythonic.env.env`, a small `class _NS: pass; store = _NS()` shim) used to appear as an isolated node even when it was central to the subsystem — every `store.dataset` access landed on a synthetic ATTRIBUTE node that visgraph dropped as undefined. The analyzer now also emits an edge to the immediate defined parent of the obj (typically the exporting module) when the attribute itself can't be resolved. Symmetric for attribute writes (`store.flag = value`). One-level only — does not climb through unanalyzed packages, and within-scope self-references are suppressed (a method reading an undefined attribute on its own class, or a function reading module-level state in its own module, is just normal scoping). Generalizes the existing class-fallback (Enum members, class constants) introduced in 2.4.0. (#127)
8
12
-**Advisory when `infer_root` may have misidentified the package root.** Two ambiguous situations now emit a warning suggesting `--root`: (1) inference walked up at least one package level and stopped at a directory that has neither `__init__.py` nor a project-root marker (`pyproject.toml`, `setup.py`, `setup.cfg`) — consistent with a top-level PEP 420 namespace package; (2) inference didn't walk up at all but the input directory's parent has `__init__.py` — consistent with the user feeding pyan the contents of a namespace subpackage (e.g. `pyan3 pkg/sub_ns/*.py` where `sub_ns/` has no `__init__.py`), which would otherwise silently produce bare module names and broken relative imports. Auto-walking further is unsafe — the same filesystem shapes also occur for workspace directories like `tests/` or `examples/` — so the choice is left to the user. The `--root` help text now also mentions the namespace-package case explicitly. (#128)
9
13
14
+
### Internal
15
+
16
+
-**Flavor rename:**`Flavor.NAMESPACE` (synthetic structural marker for module/class/function scope bookkeeping) is now `Flavor.SCOPE`. The previous name is being freed up for an upcoming `Flavor.NAMESPACE_OBJECT` representing a runtime namespace value (env, SimpleNamespace, …). The Node represents the scope; the `Scope` class implements one — same concept at two layers.
0 commit comments