codmap 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Python extractor backed by griffe (DESIGN §10.8).
|
|
2
|
+
|
|
3
|
+
Static analysis only — griffe parses source without importing the target, and
|
|
4
|
+
resolves the hard parts for us (signatures, docstrings, __all__ visibility,
|
|
5
|
+
base-class / relative-import / re-export resolution — DESIGN §3.1); we consume
|
|
6
|
+
that, we don't reinvent it.
|
|
7
|
+
|
|
8
|
+
Emits (M0 + M1 + M1.5):
|
|
9
|
+
- definition nodes (module/class/function/attribute) + `contains` structure;
|
|
10
|
+
- `export` edges for re-exports/aliases (module re-exposes a symbol — §2.1);
|
|
11
|
+
- `imports` edges between modules (dependency graph — §1, §3.1);
|
|
12
|
+
- `inherits` edges (class → base class; external bases flagged — §2);
|
|
13
|
+
- `decorated_by` edges (symbol → decorator callable — §2);
|
|
14
|
+
- node `extras`: attribute `annotation`, class `is_dataclass`, and dynamic
|
|
15
|
+
`registry` binding (decorator + literal key) for factory/registry wiring (§7).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import griffe
|
|
26
|
+
|
|
27
|
+
from codemap.extract.attrflow import add_attrflow
|
|
28
|
+
from codemap.extract.behavior import add_behavior
|
|
29
|
+
from codemap.extract.dataflow import add_dataflow
|
|
30
|
+
from codemap.extract.dispatch import add_dispatch, add_family_links
|
|
31
|
+
from codemap.extract.gsource import module_file, module_identity
|
|
32
|
+
from codemap.provenance import build_provenance
|
|
33
|
+
from codemap.model import Edge, Graph, Node
|
|
34
|
+
|
|
35
|
+
# griffe object kinds we turn into definition nodes (aliases handled separately).
|
|
36
|
+
_NODE_KINDS = {"module", "class", "function", "attribute"}
|
|
37
|
+
|
|
38
|
+
#: Why an input file produced no module (R1-C23 / design D2).
|
|
39
|
+
SKIP_ENCODING, SKIP_SYNTAX, SKIP_IO, SKIP_UNREAD = "encoding", "syntax", "io", "unread"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class _Walk:
|
|
44
|
+
"""What one structural walk accumulates besides nodes and edges."""
|
|
45
|
+
|
|
46
|
+
aliases: list = field(default_factory=list) # (parent_module, name, target, public)
|
|
47
|
+
imports: list = field(default_factory=list) # (module_id, target_symbol_path)
|
|
48
|
+
#: canonical real path → the module id that claimed it (R1-C23/D1, symlink cycles)
|
|
49
|
+
claimed: dict = field(default_factory=dict)
|
|
50
|
+
#: module ids skipped because their file was already read under another name
|
|
51
|
+
aliased: list = field(default_factory=list) # (skipped_id, owner_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_structural(package_path: str | Path):
|
|
55
|
+
"""The cheap, deterministic base: griffe load + definition nodes + structural
|
|
56
|
+
edges (contains / imports / inherits / decorated_by / export). No behavioral
|
|
57
|
+
layer. Shared by :func:`extract` and the incremental path (R1-C9), which both
|
|
58
|
+
add the (expensive, tier-sensitive) behavioral passes on top.
|
|
59
|
+
|
|
60
|
+
Returns ``(graph, root, module_name, search_path)``.
|
|
61
|
+
"""
|
|
62
|
+
pkg_dir = Path(package_path).resolve()
|
|
63
|
+
if not pkg_dir.is_dir():
|
|
64
|
+
raise NotADirectoryError(f"Not a package directory: {pkg_dir}")
|
|
65
|
+
|
|
66
|
+
module_name = pkg_dir.name
|
|
67
|
+
search_path = pkg_dir.parent
|
|
68
|
+
root = griffe.load(module_name, search_paths=[str(search_path)])
|
|
69
|
+
|
|
70
|
+
graph = Graph(target=module_name)
|
|
71
|
+
walk = _Walk()
|
|
72
|
+
|
|
73
|
+
_collect(graph, root, search_path, module_name, walk)
|
|
74
|
+
_resolve_edges(graph, module_name, walk.aliases, walk.imports)
|
|
75
|
+
# R1-C23/D2: an input the extractor could not read used to vanish without a word,
|
|
76
|
+
# and the graph then reported on a tree it had not fully seen. Record what was
|
|
77
|
+
# missed *in the artifact*, so a consumer holding only the graph is told.
|
|
78
|
+
graph.provenance = {"inputs": _input_report(graph, pkg_dir, search_path, walk)}
|
|
79
|
+
return graph, root, module_name, search_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_behavioral_layer(graph, root, module_name, search_path, *, deep: bool,
|
|
83
|
+
behavior_only=None, attr_only=None) -> None:
|
|
84
|
+
"""Add every behavioral pass on top of a structural base (in the fixed order).
|
|
85
|
+
|
|
86
|
+
``behavior_only`` / ``attr_only`` (module-path sets) restrict the two expensive
|
|
87
|
+
jedi-sensitive passes to those modules — the incremental hook (R1-C9). The cheap,
|
|
88
|
+
tier-independent passes (dispatch / family / dataflow) always run whole.
|
|
89
|
+
"""
|
|
90
|
+
# M4/M5: call-graph + control skeleton (deep=jedi type inference).
|
|
91
|
+
add_behavior(graph, root, module_name, deep=deep, search_path=search_path,
|
|
92
|
+
only=behavior_only)
|
|
93
|
+
# M7: bridge factory/registry dispatch seams using the M1.5 registry table.
|
|
94
|
+
add_dispatch(graph, root, module_name)
|
|
95
|
+
# M9 (F4): link registry-family members to the Protocol they satisfy.
|
|
96
|
+
add_family_links(graph)
|
|
97
|
+
# M12 (F6): column/string-key dataflow (reads/writes on `df['col']`).
|
|
98
|
+
add_dataflow(graph, root, module_name)
|
|
99
|
+
# R1-C20 (issue #1): attribute-access edges (accesses: function → attribute).
|
|
100
|
+
add_attrflow(graph, root, module_name, deep=deep, search_path=search_path,
|
|
101
|
+
only=attr_only)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract(package_path: str | Path, *, deep: bool = False) -> Graph:
|
|
105
|
+
"""Build a code graph from a Python package directory.
|
|
106
|
+
|
|
107
|
+
``deep=True`` runs the jedi-backed call resolver (M5) — richer call-graph
|
|
108
|
+
(local-variable type inference) at ~1 min build cost; default is the fast
|
|
109
|
+
ast tier (sub-second). See ``extract/behavior.py``.
|
|
110
|
+
"""
|
|
111
|
+
graph, root, module_name, search_path = build_structural(package_path)
|
|
112
|
+
add_behavioral_layer(graph, root, module_name, search_path, deep=deep)
|
|
113
|
+
# R1-C25: even a library-built graph says which tool and which tier made it. The
|
|
114
|
+
# input identity (scope_id, source commit) is added by whoever resolved the scope —
|
|
115
|
+
# `extract` deliberately does not hash the tree a second time.
|
|
116
|
+
graph.provenance = build_provenance(tier="deep" if deep else "fast",
|
|
117
|
+
inputs=graph.provenance.get("inputs"))
|
|
118
|
+
return graph
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# -- pass 1: definition nodes + contains/inherits/decorated_by, collect aliases/imports --
|
|
122
|
+
|
|
123
|
+
def _enumerate_sources(pkg_dir: Path) -> list[Path]:
|
|
124
|
+
"""Every ``.py`` under the package, each real file once (R1-C23 / design D1+D2).
|
|
125
|
+
|
|
126
|
+
Symlinks *are* followed — a symlinked source directory is a legitimate layout — but a
|
|
127
|
+
directory whose real path was already walked is not re-entered, which is what keeps a
|
|
128
|
+
``loop -> .`` link from generating an unbounded tree. Deterministic order.
|
|
129
|
+
"""
|
|
130
|
+
out: list[Path] = []
|
|
131
|
+
seen: set[str] = set()
|
|
132
|
+
for dirpath, dirnames, filenames in os.walk(pkg_dir, followlinks=True):
|
|
133
|
+
real = os.path.realpath(dirpath)
|
|
134
|
+
if real in seen:
|
|
135
|
+
dirnames[:] = []
|
|
136
|
+
continue
|
|
137
|
+
seen.add(real)
|
|
138
|
+
dirnames[:] = sorted(d for d in dirnames if d != "__pycache__")
|
|
139
|
+
out.extend(Path(dirpath) / n for n in sorted(filenames) if n.endswith(".py"))
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _skip_reason(path: Path) -> str:
|
|
144
|
+
"""Why this file produced no module — asked only of files that produced none."""
|
|
145
|
+
try:
|
|
146
|
+
src = path.read_text(encoding="utf-8")
|
|
147
|
+
except UnicodeDecodeError:
|
|
148
|
+
return SKIP_ENCODING
|
|
149
|
+
except OSError:
|
|
150
|
+
return SKIP_IO
|
|
151
|
+
try:
|
|
152
|
+
ast.parse(src)
|
|
153
|
+
except (SyntaxError, ValueError):
|
|
154
|
+
return SKIP_SYNTAX
|
|
155
|
+
return SKIP_UNREAD # parses here but griffe produced nothing — say so plainly
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
|
|
159
|
+
"""What the walk read, and what it could not (R1-C23 / design D2).
|
|
160
|
+
|
|
161
|
+
Lives in the graph's ``provenance`` rather than in a sidecar because a consumer
|
|
162
|
+
holding only ``graph.json`` is exactly the one who must be told that the tree was
|
|
163
|
+
read incompletely. Paths are relative to the search root — the artifact travels.
|
|
164
|
+
"""
|
|
165
|
+
files = _enumerate_sources(pkg_dir)
|
|
166
|
+
have = {n.file for n in graph.nodes.values() if n.kind == "module" and n.file}
|
|
167
|
+
py = sorted(_rel(f, root) for f in files)
|
|
168
|
+
skipped = [{"path": rel, "reason": _skip_reason(root / rel)}
|
|
169
|
+
for rel in py if rel not in have]
|
|
170
|
+
report: dict = {"python_files": len(py)}
|
|
171
|
+
if skipped:
|
|
172
|
+
report["skipped"] = skipped
|
|
173
|
+
if walk.aliased:
|
|
174
|
+
report["aliased_modules"] = [
|
|
175
|
+
{"id": mid, "same_as": owner} for mid, owner in sorted(walk.aliased)
|
|
176
|
+
]
|
|
177
|
+
return report
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _star_import_targets(module) -> list[str]:
|
|
181
|
+
"""Modules pulled in by ``from X import *`` (R1-C23 / design D3).
|
|
182
|
+
|
|
183
|
+
Fed into the same ``imports`` list griffe fills, so the target resolves — and gets
|
|
184
|
+
the flat-layout retry — through exactly one code path.
|
|
185
|
+
|
|
186
|
+
Cost is why this is a substring gate before a parse: ``module.source`` is already in
|
|
187
|
+
griffe's cache, and scanning every module of the dogfood target for ``import *`` takes
|
|
188
|
+
0.065s and yields zero candidates. Only a file that contains the text is parsed, so
|
|
189
|
+
the answer is exact rather than a regex guess about what is inside a string literal.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
src = module.source
|
|
193
|
+
except Exception: # no source (namespace dir, synthetic)
|
|
194
|
+
return []
|
|
195
|
+
if "import *" not in src:
|
|
196
|
+
return []
|
|
197
|
+
try:
|
|
198
|
+
tree = ast.parse(src)
|
|
199
|
+
except (SyntaxError, ValueError):
|
|
200
|
+
return [] # unreadable: D2's report owns this file
|
|
201
|
+
modpath = module.canonical_path
|
|
202
|
+
f = module_file(module)
|
|
203
|
+
is_pkg = f is not None and f.name == "__init__.py"
|
|
204
|
+
base = modpath.split(".") if is_pkg else modpath.split(".")[:-1]
|
|
205
|
+
targets = []
|
|
206
|
+
for node in ast.walk(tree):
|
|
207
|
+
if not isinstance(node, ast.ImportFrom):
|
|
208
|
+
continue
|
|
209
|
+
if not any(a.name == "*" for a in node.names):
|
|
210
|
+
continue
|
|
211
|
+
if node.level:
|
|
212
|
+
anchor = base[:len(base) - (node.level - 1)]
|
|
213
|
+
target = ".".join(anchor + ([node.module] if node.module else []))
|
|
214
|
+
else:
|
|
215
|
+
target = node.module or ""
|
|
216
|
+
if target:
|
|
217
|
+
targets.append(target)
|
|
218
|
+
return targets
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _collect(graph, obj, root, target_pkg, walk) -> None:
|
|
222
|
+
if obj.kind.value == "module":
|
|
223
|
+
_claim(obj, walk) # the root claims its own path before any member is walked
|
|
224
|
+
_add_node(graph, obj, root)
|
|
225
|
+
for name, tgt in (obj.imports or {}).items():
|
|
226
|
+
walk.imports.append((obj.canonical_path, tgt))
|
|
227
|
+
# R1-C23/D3: griffe expands `from .m import *` into member aliases but records
|
|
228
|
+
# no import, so the dependency itself was invisible — a star-import is the least
|
|
229
|
+
# explicit dependency in the language and the one most worth surfacing.
|
|
230
|
+
for tgt in _star_import_targets(obj):
|
|
231
|
+
walk.imports.append((obj.canonical_path, tgt))
|
|
232
|
+
for name, member in obj.members.items():
|
|
233
|
+
if member.is_alias:
|
|
234
|
+
# capture ALL re-exports (public flag kept) — a symbol can be importable
|
|
235
|
+
# via a module without being in its __all__ (e.g. bquant.analysis.zones
|
|
236
|
+
# re-exports analyze_zones but its __all__ lists only the legacy API).
|
|
237
|
+
walk.aliases.append(
|
|
238
|
+
(obj.canonical_path, name, member.target_path, member.is_public)
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
if member.kind.value not in _NODE_KINDS:
|
|
242
|
+
continue
|
|
243
|
+
# R1-C23/D1: a directory symlink into its own ancestry makes the same file
|
|
244
|
+
# reachable under unboundedly many names. Refuse the second name — before the
|
|
245
|
+
# `contains` edge, or the graph keeps an edge to a node that is never added.
|
|
246
|
+
if member.kind.value == "module" and not _claim(member, walk):
|
|
247
|
+
continue
|
|
248
|
+
if member.kind.value != "module": # modules add themselves in the branch above
|
|
249
|
+
_add_node(graph, member, root)
|
|
250
|
+
graph.add_edge(Edge("contains", obj.canonical_path, member.canonical_path))
|
|
251
|
+
_emit_decorated_by(graph, member)
|
|
252
|
+
if member.kind.value == "class":
|
|
253
|
+
_emit_inherits(graph, member, target_pkg)
|
|
254
|
+
if member.kind.value in {"module", "class"}:
|
|
255
|
+
_collect(graph, member, root, target_pkg, walk)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _claim(module, walk) -> bool:
|
|
259
|
+
"""Claim a module's real path for it; False when another module already holds it.
|
|
260
|
+
|
|
261
|
+
The survivor is whichever module the walk reached first — the walk descends from the
|
|
262
|
+
package root, so that is always the shallower, real name (``hardpkg.api`` over
|
|
263
|
+
``hardpkg.loop.api``). A module with no resolvable path cannot be proven a duplicate
|
|
264
|
+
and is always kept: omitting real code is the worse error.
|
|
265
|
+
"""
|
|
266
|
+
key = module_identity(module)
|
|
267
|
+
if key is None:
|
|
268
|
+
return True
|
|
269
|
+
owner = walk.claimed.get(key)
|
|
270
|
+
if owner is None:
|
|
271
|
+
walk.claimed[key] = module.canonical_path
|
|
272
|
+
return True
|
|
273
|
+
if owner == module.canonical_path:
|
|
274
|
+
return True
|
|
275
|
+
walk.aliased.append((module.canonical_path, owner))
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# -- semantic edges resolvable inline (griffe gives absolute targets) --------
|
|
280
|
+
|
|
281
|
+
def _emit_inherits(graph, cls, target_pkg) -> None:
|
|
282
|
+
"""One `inherits` edge per base; griffe resolves the base to a canonical path."""
|
|
283
|
+
for base in getattr(cls, "bases", None) or []:
|
|
284
|
+
target = getattr(base, "canonical_path", None) or str(base)
|
|
285
|
+
internal = target == target_pkg or target.startswith(target_pkg + ".")
|
|
286
|
+
graph.add_edge(
|
|
287
|
+
Edge(
|
|
288
|
+
"inherits",
|
|
289
|
+
cls.canonical_path,
|
|
290
|
+
target,
|
|
291
|
+
extras={} if internal else {"external": True},
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _emit_decorated_by(graph, obj) -> None:
|
|
297
|
+
"""One `decorated_by` edge per decorator (target = its callable path)."""
|
|
298
|
+
for name in _decorator_names(obj):
|
|
299
|
+
graph.add_edge(Edge("decorated_by", obj.canonical_path, name))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# -- pass 2: resolve export + import edges against known nodes ----------------
|
|
303
|
+
|
|
304
|
+
def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
|
|
305
|
+
module_ids = sorted(
|
|
306
|
+
(n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
for parent_module, name, target_path, is_public in aliases:
|
|
310
|
+
if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
|
|
311
|
+
continue # external re-export (e.g. `import numpy as np`) — out of scope
|
|
312
|
+
graph.add_edge(
|
|
313
|
+
Edge(
|
|
314
|
+
"export",
|
|
315
|
+
parent_module,
|
|
316
|
+
target_path,
|
|
317
|
+
extras={"as": name, "public": is_public},
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
seen: set[tuple[str, str]] = set()
|
|
322
|
+
unresolved: list[tuple[str, str]] = []
|
|
323
|
+
|
|
324
|
+
# pass A — package-qualified targets. Exact, and run first so that a pair reachable
|
|
325
|
+
# both ways is recorded as exact rather than inferred.
|
|
326
|
+
for src_module, target_path in imports:
|
|
327
|
+
if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
|
|
328
|
+
unresolved.append((src_module, target_path)) # external, or flat (pass B)
|
|
329
|
+
continue
|
|
330
|
+
tgt_module = _containing_module(target_path, module_ids)
|
|
331
|
+
if tgt_module is None or tgt_module == src_module:
|
|
332
|
+
continue
|
|
333
|
+
key = (src_module, tgt_module)
|
|
334
|
+
if key in seen:
|
|
335
|
+
continue
|
|
336
|
+
seen.add(key)
|
|
337
|
+
graph.add_edge(Edge("imports", src_module, tgt_module))
|
|
338
|
+
|
|
339
|
+
# pass B — flat layout (R1-C21): sibling modules importing each other by bare name
|
|
340
|
+
# (`from alpha import X`), which works at runtime because the directory itself is on
|
|
341
|
+
# sys.path. griffe records the source-literal target, so pass A cannot tell it from
|
|
342
|
+
# `pandas.DataFrame`. Retry it against the importer's own package — and label it, since
|
|
343
|
+
# this is an inference about sys.path, not something the source states.
|
|
344
|
+
known_modules = set(module_ids)
|
|
345
|
+
for src_module, target_path in unresolved:
|
|
346
|
+
tgt_module = _flat_sibling(src_module, target_path, known_modules)
|
|
347
|
+
if tgt_module is None or tgt_module == src_module:
|
|
348
|
+
continue
|
|
349
|
+
key = (src_module, tgt_module)
|
|
350
|
+
if key in seen:
|
|
351
|
+
continue
|
|
352
|
+
seen.add(key)
|
|
353
|
+
graph.add_edge(
|
|
354
|
+
Edge("imports", src_module, tgt_module, extras={"resolution": "flat"})
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _flat_sibling(src_module: str, target_path: str, known_modules: set[str]) -> str | None:
|
|
359
|
+
"""The flat-layout sibling of ``src_module`` named by ``target_path``, or None.
|
|
360
|
+
|
|
361
|
+
Deliberately narrow (design D2): it only ever sees targets that resolved to nothing
|
|
362
|
+
as package-qualified, and it only fires when the target's head names a module sitting
|
|
363
|
+
**beside the importer**. Measured on two real packages (codemap, bquant): fires zero
|
|
364
|
+
times, so a correctly-laid-out package cannot be disturbed by it.
|
|
365
|
+
"""
|
|
366
|
+
if "." not in src_module:
|
|
367
|
+
return None # a top-level module has no package for siblings to live in
|
|
368
|
+
parent = src_module.rsplit(".", 1)[0]
|
|
369
|
+
candidate = f"{parent}.{target_path.split('.', 1)[0]}"
|
|
370
|
+
return candidate if candidate in known_modules else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _containing_module(symbol_path: str, module_ids: list[str]) -> str | None:
|
|
374
|
+
"""Longest module-id that is a prefix of (or equals) the symbol path."""
|
|
375
|
+
for mid in module_ids: # already sorted longest-first
|
|
376
|
+
if symbol_path == mid or symbol_path.startswith(mid + "."):
|
|
377
|
+
return mid
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# -- node building (M0 + M1.5 extras) ----------------------------------------
|
|
382
|
+
|
|
383
|
+
def _add_node(graph, obj, root) -> None:
|
|
384
|
+
decorators = _decorator_names(obj)
|
|
385
|
+
graph.add_node(
|
|
386
|
+
Node(
|
|
387
|
+
id=obj.canonical_path,
|
|
388
|
+
kind=obj.kind.value,
|
|
389
|
+
file=_rel(module_file(obj), root), # None for a namespace dir (R1-C21)
|
|
390
|
+
lineno=getattr(obj, "lineno", None),
|
|
391
|
+
endlineno=getattr(obj, "endlineno", None),
|
|
392
|
+
signature=_signature(obj),
|
|
393
|
+
docstring=obj.docstring.value if obj.docstring else None,
|
|
394
|
+
visibility="public" if obj.is_public else "private",
|
|
395
|
+
decorators=decorators,
|
|
396
|
+
is_deprecated=any(d.split(".")[-1] == "deprecated" for d in decorators),
|
|
397
|
+
extras=_stub_marked(_extras(obj, decorators), obj),
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _stub_marked(extras: dict, obj) -> dict:
|
|
403
|
+
"""Label a symbol that exists only in a ``.pyi`` stub (R1-C23 / design D5).
|
|
404
|
+
|
|
405
|
+
A stub-only module has no runtime counterpart, so its symbols are declarations, not
|
|
406
|
+
code. Labelling beats both alternatives: dropping them loses the declared surface of
|
|
407
|
+
a stubs distribution, and leaving them unmarked presents a function that does not
|
|
408
|
+
exist as if it did. Consumers that reason about execution (dead-code) exclude them.
|
|
409
|
+
"""
|
|
410
|
+
f = module_file(obj)
|
|
411
|
+
if f is not None and f.suffix == ".pyi":
|
|
412
|
+
extras = {**extras, "stub": True}
|
|
413
|
+
return extras
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _extras(obj, decorators) -> dict:
|
|
417
|
+
"""Language-specific facts kept off the neutral core (DESIGN §2)."""
|
|
418
|
+
extras: dict = {}
|
|
419
|
+
kind = obj.kind.value
|
|
420
|
+
if kind == "attribute" and getattr(obj, "annotation", None) is not None:
|
|
421
|
+
extras["annotation"] = str(obj.annotation) # e.g. "List[ZoneInfo]" (CM-01)
|
|
422
|
+
if kind == "function":
|
|
423
|
+
# structured param/return types — basis for type-flow (M4) and CM-03.
|
|
424
|
+
params = [
|
|
425
|
+
{"name": p.name, "type": str(p.annotation)}
|
|
426
|
+
for p in obj.parameters
|
|
427
|
+
if p.annotation is not None
|
|
428
|
+
]
|
|
429
|
+
if params:
|
|
430
|
+
extras["params"] = params
|
|
431
|
+
if obj.returns is not None:
|
|
432
|
+
extras["returns"] = str(obj.returns)
|
|
433
|
+
if kind == "class":
|
|
434
|
+
if any(d.split(".")[-1] == "dataclass" for d in decorators):
|
|
435
|
+
extras["is_dataclass"] = True # CM-02
|
|
436
|
+
binding = _registry_binding(obj) # CM-07: @Registry.register('key')
|
|
437
|
+
if binding is not None:
|
|
438
|
+
extras["registry"] = binding
|
|
439
|
+
return extras
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _registry_binding(obj) -> dict | None:
|
|
443
|
+
"""Dynamic registration via a decorator call with a literal string key.
|
|
444
|
+
|
|
445
|
+
Turns ``@ZoneDetectionRegistry.register('zero_crossing', ...)`` into
|
|
446
|
+
``{"decorator": "...register", "key": "zero_crossing"}`` so factory/registry
|
|
447
|
+
wiring is queryable instead of hidden in a decorator string (DESIGN §7).
|
|
448
|
+
"""
|
|
449
|
+
for d in getattr(obj, "decorators", []) or []:
|
|
450
|
+
path = getattr(d, "callable_path", None)
|
|
451
|
+
value = getattr(d, "value", None)
|
|
452
|
+
if path is None or "register" not in str(path).lower():
|
|
453
|
+
continue
|
|
454
|
+
args = getattr(value, "arguments", None)
|
|
455
|
+
if not args:
|
|
456
|
+
continue
|
|
457
|
+
first = args[0] # griffe gives a string-literal arg as quoted source text
|
|
458
|
+
if isinstance(first, str) and len(first) >= 2 and first[0] in "'\"":
|
|
459
|
+
key = first.strip("'\"")
|
|
460
|
+
if key:
|
|
461
|
+
return {"decorator": str(path), "key": key}
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _signature(obj) -> str | None:
|
|
466
|
+
if obj.kind.value != "function":
|
|
467
|
+
return None
|
|
468
|
+
parts = []
|
|
469
|
+
for p in obj.parameters:
|
|
470
|
+
s = p.name
|
|
471
|
+
if p.annotation is not None:
|
|
472
|
+
s += f": {p.annotation}"
|
|
473
|
+
if p.default is not None:
|
|
474
|
+
s += f" = {p.default}"
|
|
475
|
+
parts.append(s)
|
|
476
|
+
sig = f"{obj.name}({', '.join(parts)})"
|
|
477
|
+
if obj.returns is not None:
|
|
478
|
+
sig += f" -> {obj.returns}"
|
|
479
|
+
return sig
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _decorator_names(obj) -> list[str]:
|
|
483
|
+
names = []
|
|
484
|
+
for d in getattr(obj, "decorators", []) or []:
|
|
485
|
+
path = getattr(d, "callable_path", None)
|
|
486
|
+
names.append(str(path) if path else str(getattr(d, "value", d)))
|
|
487
|
+
return names
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _rel(filepath, root: Path) -> str | None:
|
|
491
|
+
if filepath is None:
|
|
492
|
+
return None
|
|
493
|
+
try:
|
|
494
|
+
return str(Path(filepath).resolve().relative_to(root.resolve()))
|
|
495
|
+
except ValueError:
|
|
496
|
+
return str(filepath)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Griffe object → its source file, normalized (R1-C21 / design D4).
|
|
2
|
+
|
|
3
|
+
``filepath`` on a griffe object has **three** shapes, not two:
|
|
4
|
+
|
|
5
|
+
- a ``Path`` — the ordinary module/class/function case;
|
|
6
|
+
- ``None`` — no file (synthetic objects);
|
|
7
|
+
- a ``list[Path]`` — a **namespace package** (a directory with no ``__init__.py``),
|
|
8
|
+
which has search locations rather than one source file.
|
|
9
|
+
|
|
10
|
+
Every consumer used to take the first two into account and crash on the third
|
|
11
|
+
(``TypeError: ... not 'list'``, issue #4). The list shape is normalized here, once,
|
|
12
|
+
so no pass has to know about it: a namespace package has no single file, and ``None``
|
|
13
|
+
is the value every one of those call sites already handles.
|
|
14
|
+
|
|
15
|
+
Note the shape that made the old guards fail: ``if not fp`` *passes* a non-empty
|
|
16
|
+
list, so each site sailed past its own check straight into ``Path(list)``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def module_file(obj) -> Path | None:
|
|
25
|
+
"""The single source file behind a griffe object, or ``None`` if it has none."""
|
|
26
|
+
fp = getattr(obj, "filepath", None)
|
|
27
|
+
if fp is None or isinstance(fp, list):
|
|
28
|
+
return None
|
|
29
|
+
return Path(fp)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_namespace_dir(obj) -> bool:
|
|
33
|
+
"""True for a namespace package (a directory without ``__init__.py``)."""
|
|
34
|
+
return isinstance(getattr(obj, "filepath", None), list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def module_identity(obj) -> str | None:
|
|
38
|
+
"""The **real** path behind a module — symlinks resolved (R1-C23 / design D1).
|
|
39
|
+
|
|
40
|
+
A directory symlink that points into its own ancestry makes the same file reachable
|
|
41
|
+
under unboundedly many module names: a single ``loop -> .`` link turned a 17-file
|
|
42
|
+
package into **615 modules** nested 40 deep, silently. Resolving to a canonical path
|
|
43
|
+
lets the walk notice it has read this file already.
|
|
44
|
+
|
|
45
|
+
A namespace package has search locations rather than a file; its first location
|
|
46
|
+
identifies it well enough for that purpose.
|
|
47
|
+
"""
|
|
48
|
+
fp = getattr(obj, "filepath", None)
|
|
49
|
+
if isinstance(fp, list):
|
|
50
|
+
fp = fp[0] if fp else None
|
|
51
|
+
if fp is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return str(Path(fp).resolve())
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def module_imports(mod, modpath: str, known_modules) -> dict[str, str]:
|
|
60
|
+
"""``mod.imports``, with flat-layout sibling targets package-qualified (R1-C21).
|
|
61
|
+
|
|
62
|
+
griffe records an import target exactly as the source writes it, so in a flat layout
|
|
63
|
+
(`from alpha import X`, the directory itself on ``sys.path``) the target is
|
|
64
|
+
``alpha.X`` — unprefixed, and therefore indistinguishable from ``pandas.X`` to every
|
|
65
|
+
consumer that tests ``target.startswith(pkg + ".")``. Qualifying the map here means
|
|
66
|
+
the inference lives in **one** place and each pass (calls, attribute access) simply
|
|
67
|
+
stops being blind to the layout, rather than each re-deriving it.
|
|
68
|
+
|
|
69
|
+
Same guard as the ``imports``-edge resolution (design D2): only a target that is not
|
|
70
|
+
already package-internal, and only when its head names a module sitting **beside**
|
|
71
|
+
the importer. The module-level inference stays visible on the corresponding
|
|
72
|
+
``imports`` edge, which carries ``extras.resolution="flat"``.
|
|
73
|
+
"""
|
|
74
|
+
imports = dict(getattr(mod, "imports", None) or {})
|
|
75
|
+
if "." not in modpath:
|
|
76
|
+
return imports
|
|
77
|
+
parent, pkg = modpath.rsplit(".", 1)[0], modpath.split(".", 1)[0] + "."
|
|
78
|
+
out = {}
|
|
79
|
+
for name, target in imports.items():
|
|
80
|
+
if not target.startswith(pkg) and f"{parent}.{target.split('.', 1)[0]}" in known_modules:
|
|
81
|
+
target = f"{parent}.{target}"
|
|
82
|
+
out[name] = target
|
|
83
|
+
return out
|