cjm-python-decompose-core 0.0.6__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.
- cjm_python_decompose_core/__init__.py +1 -0
- cjm_python_decompose_core/emit.py +336 -0
- cjm_python_decompose_core/extract.py +202 -0
- cjm_python_decompose_core/ingest.py +103 -0
- cjm_python_decompose_core/parse.py +518 -0
- cjm_python_decompose_core-0.0.6.dist-info/METADATA +147 -0
- cjm_python_decompose_core-0.0.6.dist-info/RECORD +10 -0
- cjm_python_decompose_core-0.0.6.dist-info/WHEEL +5 -0
- cjm_python_decompose_core-0.0.6.dist-info/licenses/LICENSE +201 -0
- cjm_python_decompose_core-0.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.6"
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Project a module BACK out of the graph (graph -> .py) — the canonical emit leg.
|
|
2
|
+
|
|
3
|
+
The inverse of `extract`: reassemble a module's `.py` source from its ordered top-level
|
|
4
|
+
regions (top-level `CodeSymbol` verbatim bodies + `CodeText` verbatim regions), with
|
|
5
|
+
the SEAMS regenerated canonically. This is the graph→`.py` half of authoring-on-graph
|
|
6
|
+
([[graph-as-source-of-truth-inversion]]): the graph OWNS formatting, so a human never
|
|
7
|
+
hand-edits the export. Mirrors the notebook compositor's `project.py` (graph -> .ipynb).
|
|
8
|
+
|
|
9
|
+
Operates on `CodeSymbolNode`/`CodeTextNode` objects OR queried graph wire dicts (so emit
|
|
10
|
+
reads the GRAPH as source — the real "graph is a sufficient source" proof). The fidelity
|
|
11
|
+
bar: symbol/region BODIES are byte-exact, only the inter-region seams are canonical
|
|
12
|
+
(2 blank lines around a def/class, 1 between text regions) — semantically equal, not
|
|
13
|
+
necessarily byte-identical to a non-PEP-8 original.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .parse import emit_regions, SourceRegion
|
|
22
|
+
|
|
23
|
+
_SYMBOL_LABELS = {"CodeSymbol"}
|
|
24
|
+
_TEXT_LABELS = {"CodeText"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _binding_key(b: Dict[str, Any]) -> Tuple:
|
|
28
|
+
"""A binding's dedup/identity key (one import across symbols that share it)."""
|
|
29
|
+
return (b.get("kind"), b.get("level", 0), b.get("module", ""),
|
|
30
|
+
b.get("imported", ""), b.get("alias", ""))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_binding(b: Dict[str, Any]) -> str: # One `import ...` / `from ... import ...` line
|
|
34
|
+
"""Render one import-binding descriptor back to its canonical import statement.
|
|
35
|
+
|
|
36
|
+
`{kind:import, module:os}` -> `import os`; `{kind:import, module:a.b, alias:c}` ->
|
|
37
|
+
`import a.b as c`; `{kind:from, level:1, module:util, imported:helper, alias:h}` ->
|
|
38
|
+
`from .util import helper as h`; `{kind:from, level:1, module:'', imported:sib}` ->
|
|
39
|
+
`from . import sib`."""
|
|
40
|
+
if b.get("kind") == "import":
|
|
41
|
+
line = f"import {b['module']}"
|
|
42
|
+
if b.get("alias"):
|
|
43
|
+
line += f" as {b['alias']}"
|
|
44
|
+
return line
|
|
45
|
+
src = "." * b.get("level", 0) + (b.get("module") or "")
|
|
46
|
+
name = b.get("imported", "")
|
|
47
|
+
line = f"from {src} import {name}"
|
|
48
|
+
if b.get("alias") and b["alias"] != name:
|
|
49
|
+
line += f" as {b['alias']}"
|
|
50
|
+
return line
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _from_name(b: Dict[str, Any]) -> str:
|
|
54
|
+
"""The `name` / `name as alias` clause of one `from ... import` binding."""
|
|
55
|
+
name = b.get("imported", "")
|
|
56
|
+
return f"{name} as {b['alias']}" if b.get("alias") and b["alias"] != name else name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_IMPORT_WRAP_WIDTH = 100 # A from-import line longer than this wraps in aligned parentheses
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _import_section(level: int, module: str) -> int:
|
|
63
|
+
"""The isort-style section a binding sorts into: 0 stdlib · 1 third-party · 2 relative."""
|
|
64
|
+
if level:
|
|
65
|
+
return 2
|
|
66
|
+
top = (module or "").split(".")[0]
|
|
67
|
+
return 0 if top in sys.stdlib_module_names else 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_from_line(level: int, module: str, names: List[str]) -> str:
|
|
71
|
+
"""One merged `from ... import a, b, c` line, parenthesized+aligned when over-width."""
|
|
72
|
+
head = f"from {'.' * level}{module} import "
|
|
73
|
+
line = head + ", ".join(names)
|
|
74
|
+
if len(line) <= _IMPORT_WRAP_WIDTH:
|
|
75
|
+
return line
|
|
76
|
+
indent = " " * (len(head) + 1)
|
|
77
|
+
lines, cur = [], head + "("
|
|
78
|
+
for i, name in enumerate(names):
|
|
79
|
+
piece = name + ("," if i < len(names) - 1 else ")")
|
|
80
|
+
if cur.endswith("("):
|
|
81
|
+
cur += piece
|
|
82
|
+
elif len(cur) + 1 + len(piece) <= _IMPORT_WRAP_WIDTH:
|
|
83
|
+
cur += " " + piece
|
|
84
|
+
else:
|
|
85
|
+
lines.append(cur)
|
|
86
|
+
cur = indent + piece
|
|
87
|
+
lines.append(cur)
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_import_block(
|
|
92
|
+
bindings: List[Dict[str, Any]], # Import-binding descriptors (per-symbol + module-level, unioned)
|
|
93
|
+
) -> str: # The canonical import block text ("" when none)
|
|
94
|
+
"""Derive a module's canonical import block from its used import bindings.
|
|
95
|
+
|
|
96
|
+
The imports-as-projection emit: the block is GENERATED from the bindings the module's
|
|
97
|
+
symbols actually use (so an unused import is auto-pruned), deterministically ordered —
|
|
98
|
+
`from __future__` first, then isort-style SECTIONS separated by a blank line (stdlib,
|
|
99
|
+
third-party, relative — classified via `sys.stdlib_module_names`), each section's
|
|
100
|
+
`import` statements before its `from` imports, all sorted by module; same-source `from`
|
|
101
|
+
imports MERGE onto one line, parenthesized+aligned when over-width. Faithful at the SET
|
|
102
|
+
level (the round-trip bar is semantic equality of the import set, not byte-identity to
|
|
103
|
+
a hand-ordered original)."""
|
|
104
|
+
seen: set = set()
|
|
105
|
+
uniq: List[Dict[str, Any]] = []
|
|
106
|
+
for b in bindings:
|
|
107
|
+
k = _binding_key(b)
|
|
108
|
+
if k not in seen:
|
|
109
|
+
seen.add(k)
|
|
110
|
+
uniq.append(b)
|
|
111
|
+
|
|
112
|
+
future: List[str] = []
|
|
113
|
+
plain_by_section: Dict[int, List[Dict[str, Any]]] = {}
|
|
114
|
+
from_groups: Dict[Tuple[int, int, str], List[str]] = {}
|
|
115
|
+
for b in uniq:
|
|
116
|
+
level, module = b.get("level", 0), b.get("module") or ""
|
|
117
|
+
if b.get("kind") == "import":
|
|
118
|
+
plain_by_section.setdefault(_import_section(level, module), []).append(b)
|
|
119
|
+
elif module == "__future__" and not level:
|
|
120
|
+
future.append(_from_name(b))
|
|
121
|
+
else:
|
|
122
|
+
from_groups.setdefault((_import_section(level, module), level, module),
|
|
123
|
+
[]).append(_from_name(b))
|
|
124
|
+
|
|
125
|
+
sections: List[List[str]] = []
|
|
126
|
+
if future:
|
|
127
|
+
sections.append([f"from __future__ import {', '.join(sorted(set(future)))}"])
|
|
128
|
+
for sec in (0, 1, 2):
|
|
129
|
+
lines: List[str] = []
|
|
130
|
+
for b in sorted(plain_by_section.get(sec, []), key=lambda x: (x.get("module") or "").lower()):
|
|
131
|
+
lines.append(render_binding(b))
|
|
132
|
+
for (s, level, module) in sorted((k for k in from_groups if k[0] == sec),
|
|
133
|
+
key=lambda k: (k[1], k[2].lower())):
|
|
134
|
+
names = sorted(set(from_groups[(s, level, module)]), key=str.lower)
|
|
135
|
+
lines.append(_render_from_line(level, module, names))
|
|
136
|
+
if lines:
|
|
137
|
+
sections.append(lines)
|
|
138
|
+
return "\n\n".join("\n".join(lines) for lines in sections)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def synth_import(local_name: str, import_name: str) -> Dict[str, Any]:
|
|
142
|
+
"""A synthetic `from <import_name> import <local_name>` binding (USES-derived).
|
|
143
|
+
|
|
144
|
+
For an INTRA-corpus reference that has no import statement of its own — a symbol that
|
|
145
|
+
referenced a same-module sibling and then MOVED, so the sibling is now cross-module.
|
|
146
|
+
Absolute form (the target module's dotted import name); the round-trip bar is semantic."""
|
|
147
|
+
return {"name": local_name, "kind": "from", "module": import_name,
|
|
148
|
+
"imported": local_name, "alias": "", "level": 0}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def module_used_bindings(
|
|
152
|
+
symbol_nodes: Any, # The module's CodeSymbol nodes (objects or wire dicts) — carry per-symbol import_bindings
|
|
153
|
+
module_node: Any = None, # The CodeModule node (carries module-level import_bindings) — optional
|
|
154
|
+
uses_derived: Any = None, # USES-derived synthetic bindings (intra-corpus imports the frozen bindings can't carry)
|
|
155
|
+
) -> List[Dict[str, Any]]: # The unioned import bindings this module needs
|
|
156
|
+
"""Union of every contained symbol's import bindings + the module-level ones (+ any
|
|
157
|
+
USES-derived synthetics for names no binding covers).
|
|
158
|
+
|
|
159
|
+
This is the input to `render_import_block`: which imports the module's CURRENT members
|
|
160
|
+
actually use. The frozen per-symbol bindings carry external + already-cross-module deps;
|
|
161
|
+
`uses_derived` adds the imports that only become cross-module AFTER a move (a former
|
|
162
|
+
same-module reference) — together they make the relocate ZERO-RESIDUAL."""
|
|
163
|
+
def get(node: Any, key: str) -> List[Dict[str, Any]]:
|
|
164
|
+
if node is None:
|
|
165
|
+
return []
|
|
166
|
+
if isinstance(node, dict):
|
|
167
|
+
return (node.get("properties") or node).get(key, []) or []
|
|
168
|
+
props = getattr(node, "properties", None) # graph node object carrying a props dict
|
|
169
|
+
if isinstance(props, dict):
|
|
170
|
+
return props.get(key, []) or []
|
|
171
|
+
return getattr(node, key, []) or [] # plain object with the attr directly
|
|
172
|
+
|
|
173
|
+
out: List[Dict[str, Any]] = []
|
|
174
|
+
if module_node is not None:
|
|
175
|
+
out.extend(get(module_node, "import_bindings"))
|
|
176
|
+
for s in symbol_nodes:
|
|
177
|
+
out.extend(get(s, "import_bindings"))
|
|
178
|
+
if uses_derived: # add only names no explicit binding covers
|
|
179
|
+
covered = {b.get("name") for b in out}
|
|
180
|
+
for b in uses_derived:
|
|
181
|
+
if b.get("name") not in covered:
|
|
182
|
+
out.append(b)
|
|
183
|
+
covered.add(b.get("name"))
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _region_from_node(
|
|
188
|
+
node: Any, # A CodeSymbolNode / CodeTextNode object, or a queried wire dict
|
|
189
|
+
) -> Optional[Tuple[int, SourceRegion]]: # (order_index, region) or None when the node is not a top-level region
|
|
190
|
+
"""Pull (order_index, verbatim region) from a node — None for non-region nodes.
|
|
191
|
+
|
|
192
|
+
A region is a node carrying an `order_index` (top-level symbols + code-text regions);
|
|
193
|
+
nested symbols (no body, no order_index) and any other node kind return None."""
|
|
194
|
+
if isinstance(node, dict):
|
|
195
|
+
label = node.get("label", "")
|
|
196
|
+
p = node.get("properties", {})
|
|
197
|
+
order = p.get("order_index")
|
|
198
|
+
if order is None:
|
|
199
|
+
return None
|
|
200
|
+
if label in _SYMBOL_LABELS:
|
|
201
|
+
return order, SourceRegion(kind="symbol", text=p.get("body", ""),
|
|
202
|
+
start_line=0, end_line=0,
|
|
203
|
+
qualname=p.get("qualname", ""),
|
|
204
|
+
symbol_kind=p.get("symbol_kind", ""))
|
|
205
|
+
if label in _TEXT_LABELS:
|
|
206
|
+
return order, SourceRegion(kind="text", text=p.get("text", ""),
|
|
207
|
+
start_line=0, end_line=0,
|
|
208
|
+
region_key=p.get("region_key", ""))
|
|
209
|
+
return None
|
|
210
|
+
# Node object: distinguish by the presence of a `body` (symbol) vs `text` (region) attr.
|
|
211
|
+
order = getattr(node, "order_index", None)
|
|
212
|
+
if order is None:
|
|
213
|
+
return None
|
|
214
|
+
if hasattr(node, "body"):
|
|
215
|
+
return order, SourceRegion(kind="symbol", text=node.body, start_line=0, end_line=0,
|
|
216
|
+
qualname=getattr(node, "qualname", ""),
|
|
217
|
+
symbol_kind=getattr(node, "symbol_kind", ""))
|
|
218
|
+
if hasattr(node, "text"):
|
|
219
|
+
return order, SourceRegion(kind="text", text=node.text, start_line=0, end_line=0,
|
|
220
|
+
region_key=getattr(node, "region_key", ""))
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def regions_from_nodes(
|
|
225
|
+
nodes: Any, # CodeSymbol/CodeText nodes (objects or wire dicts) belonging to ONE module
|
|
226
|
+
) -> List[SourceRegion]: # The module's ordered top-level regions
|
|
227
|
+
"""Collect a module's top-level regions from its nodes, ordered by `order_index`.
|
|
228
|
+
|
|
229
|
+
Non-region nodes (nested symbols, the module node itself, anything else) are
|
|
230
|
+
skipped — only the ordered verbatim regions reassemble the source."""
|
|
231
|
+
found = [r for r in (_region_from_node(n) for n in nodes) if r is not None]
|
|
232
|
+
found.sort(key=lambda t: t[0])
|
|
233
|
+
return [region for _order, region in found]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _label_of(node: Any) -> str:
|
|
237
|
+
"""A node's label ('CodeSymbol' / 'CodeText' / …) from a wire dict or object."""
|
|
238
|
+
if isinstance(node, dict):
|
|
239
|
+
return node.get("label", "")
|
|
240
|
+
return getattr(node, "label", node.__class__.__name__)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _looks_like_docstring(text: str) -> bool:
|
|
244
|
+
"""Whether a text region opens with a string literal (a module docstring)."""
|
|
245
|
+
return text.lstrip().startswith(('"""', "'''", '"', "'"))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_IMPORT_LINE = re.compile(r"\s*(from|import)\s")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _strip_import_lines(text: str) -> str: # The region text with top-level import statements removed
|
|
252
|
+
"""Drop top-level `import`/`from ... import` statements (paren/backslash continuations
|
|
253
|
+
included) from a text region, keeping any non-import lines — so a region that mixes
|
|
254
|
+
imports with a constant keeps the constant while the imports are regenerated.
|
|
255
|
+
|
|
256
|
+
AST-located: only lines spanned by a real top-level Import/ImportFrom statement drop —
|
|
257
|
+
a DOCSTRING line that merely starts with `from ...` survives (the line-regex fallback,
|
|
258
|
+
for a region that doesn't parse standalone, would eat it), and an import nested in an
|
|
259
|
+
`if`/`try` is conditional logic, not a derivable binding, so it stays too."""
|
|
260
|
+
try:
|
|
261
|
+
tree = ast.parse(text)
|
|
262
|
+
except SyntaxError:
|
|
263
|
+
return _strip_import_lines_regex(text)
|
|
264
|
+
drop: set = set()
|
|
265
|
+
for node in tree.body:
|
|
266
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
267
|
+
drop.update(range(node.lineno, (node.end_lineno or node.lineno) + 1))
|
|
268
|
+
return "\n".join(ln for i, ln in enumerate(text.splitlines(), 1) if i not in drop)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _strip_import_lines_regex(text: str) -> str: # The pre-AST fallback (unparseable region)
|
|
272
|
+
"""Line-regex import stripping — only for a region `ast.parse` rejects."""
|
|
273
|
+
lines = text.splitlines()
|
|
274
|
+
out: List[str] = []
|
|
275
|
+
i = 0
|
|
276
|
+
while i < len(lines):
|
|
277
|
+
ln = lines[i]
|
|
278
|
+
if _IMPORT_LINE.match(ln):
|
|
279
|
+
depth = ln.count("(") - ln.count(")")
|
|
280
|
+
cont = ln.rstrip().endswith("\\")
|
|
281
|
+
i += 1
|
|
282
|
+
while (depth > 0 or cont) and i < len(lines):
|
|
283
|
+
depth += lines[i].count("(") - lines[i].count(")")
|
|
284
|
+
cont = lines[i].rstrip().endswith("\\")
|
|
285
|
+
i += 1
|
|
286
|
+
continue
|
|
287
|
+
out.append(ln)
|
|
288
|
+
i += 1
|
|
289
|
+
return "\n".join(out)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def emit_module_from_nodes(
|
|
293
|
+
nodes: Any, # The module's CodeSymbol (incl. methods, for bindings) + CodeText nodes
|
|
294
|
+
module_node: Any = None, # The CodeModule node (module-level import bindings) — for derive_imports
|
|
295
|
+
derive_imports: bool = False, # Regenerate the import block from bindings instead of the verbatim region
|
|
296
|
+
uses_derived: Any = None, # USES-derived synthetic imports (zero-residual move) — for derive_imports
|
|
297
|
+
) -> str: # The reconstructed canonical `.py` source
|
|
298
|
+
"""Reassemble a module's canonical `.py` source from its graph nodes (the round-trip).
|
|
299
|
+
|
|
300
|
+
With `derive_imports`, the verbatim import region is replaced by the block DERIVED from
|
|
301
|
+
the module's used import bindings (imports-as-projection): unused imports drop, ordering
|
|
302
|
+
is canonical, and a moved symbol's imports recompute — at the SEMANTIC fidelity bar
|
|
303
|
+
(bodies byte-exact, the import set equal/canonical), not byte-identity."""
|
|
304
|
+
regions = regions_from_nodes(nodes)
|
|
305
|
+
if not derive_imports:
|
|
306
|
+
return emit_regions(regions)
|
|
307
|
+
symbols = [n for n in nodes if _label_of(n) == "CodeSymbol"]
|
|
308
|
+
block = render_import_block(module_used_bindings(symbols, module_node, uses_derived))
|
|
309
|
+
rebuilt: List[SourceRegion] = []
|
|
310
|
+
for r in regions:
|
|
311
|
+
if r.kind == "text":
|
|
312
|
+
kept = _strip_import_lines(r.text).strip("\n")
|
|
313
|
+
if kept.strip():
|
|
314
|
+
rebuilt.append(SourceRegion(kind="text", text=kept, start_line=0, end_line=0,
|
|
315
|
+
region_key=getattr(r, "region_key", "")))
|
|
316
|
+
else:
|
|
317
|
+
rebuilt.append(r)
|
|
318
|
+
if block:
|
|
319
|
+
imp = SourceRegion(kind="text", text=block, start_line=0, end_line=0, region_key="imports")
|
|
320
|
+
pos = 1 if (rebuilt and rebuilt[0].kind == "text"
|
|
321
|
+
and _looks_like_docstring(rebuilt[0].text)) else 0
|
|
322
|
+
rebuilt.insert(pos, imp)
|
|
323
|
+
return emit_regions(rebuilt)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def nodes_for_module(
|
|
327
|
+
nodes: Any, # Queried CodeSymbol/CodeText wire dicts (e.g. find_nodes_by_label)
|
|
328
|
+
module_id: str, # The CodeModule id to filter to
|
|
329
|
+
) -> List[Any]: # The region nodes belonging to that module
|
|
330
|
+
"""Filter queried region nodes down to one module (by `module_id` property)."""
|
|
331
|
+
out = []
|
|
332
|
+
for n in nodes:
|
|
333
|
+
p = n.get("properties", {}) if isinstance(n, dict) else {}
|
|
334
|
+
if p.get("module_id") == module_id:
|
|
335
|
+
out.append(n)
|
|
336
|
+
return out
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Bind parsed Python onto dev-graph-schema code nodes (the dev-domain binding).
|
|
2
|
+
|
|
3
|
+
Turns a `ParsedModule` into a `CodeModuleNode` + its `CodeSymbolNode`s, carrying
|
|
4
|
+
file-hash provenance and the LOCAL edges (ABOUT -> the repo Entity; DEFINES
|
|
5
|
+
module->top-level symbol and class->method). The CORPUS-level edges (IMPORTS/CALLS,
|
|
6
|
+
which need the whole decomposed corpus to resolve) are added in `ingest`. This is
|
|
7
|
+
the analogue of `cjm_markdown_decompose_core.extract` for the code source type.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from cjm_context_graph_primitives.provenance import SourceRef
|
|
15
|
+
from cjm_dev_graph_schema.nodes import CodeModuleNode, CodeSymbolNode, CodeTextNode
|
|
16
|
+
|
|
17
|
+
from .parse import parse_module, parse_regions, ParsedModule, SourceRegion
|
|
18
|
+
|
|
19
|
+
# Directories never decomposed as source (generated / virtualenv / packaging cruft).
|
|
20
|
+
_SKIP_DIRS = {"__pycache__", ".git", ".ipynb_checkpoints", "build", "dist", ".eggs"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DecomposedModule:
|
|
25
|
+
"""One module bound to schema nodes: the module + its symbols + local edges.
|
|
26
|
+
|
|
27
|
+
`parsed` is retained so the ingest layer can resolve corpus-level IMPORTS/CALLS
|
|
28
|
+
(which need every module's import-name / every symbol's name to be known).
|
|
29
|
+
`texts` are the non-def verbatim regions (the round-trip substrate between symbols);
|
|
30
|
+
the module's CONTAINS edges (in `local_edges`) order all top-level regions for emit."""
|
|
31
|
+
module: CodeModuleNode # The CodeModule node
|
|
32
|
+
symbols: List[CodeSymbolNode] # All symbols, flattened (every nesting level)
|
|
33
|
+
local_edges: List[Dict[str, Any]] = field(default_factory=list) # ABOUT + structural DEFINES + CONTAINS edges
|
|
34
|
+
parsed: Optional[ParsedModule] = None # The raw parse (for corpus IMPORTS/CALLS resolution)
|
|
35
|
+
texts: List[CodeTextNode] = field(default_factory=list) # Non-def verbatim regions (imports/docstring/constants/__main__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text_region_kind(
|
|
39
|
+
region: SourceRegion, # A "text" region
|
|
40
|
+
) -> str: # Coarse flavor for relevance/render ("imports" | "docstring" | "code")
|
|
41
|
+
"""Classify a non-def text region for display (imports / module docstring / code)."""
|
|
42
|
+
stripped = region.text.lstrip()
|
|
43
|
+
if stripped.startswith(("import ", "from ")):
|
|
44
|
+
return "imports"
|
|
45
|
+
if stripped.startswith(('"""', "'''", '"', "'")):
|
|
46
|
+
return "docstring"
|
|
47
|
+
return "code"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def module_path_for(
|
|
51
|
+
path: str, # The file path
|
|
52
|
+
repo_root: str, # The repo root the module path is relative to
|
|
53
|
+
) -> str: # Repo-relative POSIX module path (e.g. "cjm_dev_graph_schema/nodes.py")
|
|
54
|
+
"""The repo-relative POSIX path used as the module's identity input.
|
|
55
|
+
|
|
56
|
+
Falls back to the bare filename when the path is not under `repo_root`."""
|
|
57
|
+
p = Path(path)
|
|
58
|
+
try:
|
|
59
|
+
rel = p.relative_to(repo_root)
|
|
60
|
+
except ValueError:
|
|
61
|
+
rel = Path(p.name)
|
|
62
|
+
return rel.as_posix()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def import_name_for(
|
|
66
|
+
module_path: str, # Repo-relative module path
|
|
67
|
+
) -> str: # Dotted import name ("pkg/sub/mod.py" -> "pkg.sub.mod"; "pkg/__init__.py" -> "pkg")
|
|
68
|
+
"""The dotted import name a module is reachable by (drops a trailing `__init__`)."""
|
|
69
|
+
parts = list(Path(module_path).with_suffix("").parts)
|
|
70
|
+
if parts and parts[-1] == "__init__":
|
|
71
|
+
parts = parts[:-1]
|
|
72
|
+
return ".".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_symbols(
|
|
76
|
+
module: CodeModuleNode, # The enclosing module node
|
|
77
|
+
parsed: ParsedModule, # Its parse
|
|
78
|
+
content_hash: str, # The module file's content hash (symbols share their file's source)
|
|
79
|
+
path: str, # The file path (provenance locator)
|
|
80
|
+
) -> Tuple[List[CodeSymbolNode], List[Dict[str, Any]]]: # (flat symbol nodes, DEFINES edges)
|
|
81
|
+
"""Flatten the parse tree into symbol nodes + structural DEFINES edges.
|
|
82
|
+
|
|
83
|
+
DEFINES runs module->top-level symbol and parent-symbol->nested-symbol, so the
|
|
84
|
+
nesting (class -> its methods) is preserved as edges, not just qualname text."""
|
|
85
|
+
symbols: List[CodeSymbolNode] = []
|
|
86
|
+
edges: List[Dict[str, Any]] = []
|
|
87
|
+
|
|
88
|
+
def make(ps) -> CodeSymbolNode:
|
|
89
|
+
node = CodeSymbolNode(
|
|
90
|
+
module_id=module.id, qualname=ps.qualname, symbol_kind=ps.kind,
|
|
91
|
+
path=path, content_hash=content_hash, lineno=ps.lineno,
|
|
92
|
+
docstring=ps.docstring, calls=list(ps.calls), refs=list(ps.refs),
|
|
93
|
+
import_bindings=list(ps.import_bindings),
|
|
94
|
+
properties={"decorators": list(ps.decorators)} if ps.decorators else {},
|
|
95
|
+
)
|
|
96
|
+
symbols.append(node)
|
|
97
|
+
children = [make(c) for c in ps.children]
|
|
98
|
+
if children:
|
|
99
|
+
edges.extend(node.defines_edges([c.id for c in children]))
|
|
100
|
+
return node
|
|
101
|
+
|
|
102
|
+
top = [make(ps) for ps in parsed.symbols]
|
|
103
|
+
edges[:0] = module.defines_edges([t.id for t in top]) # module->top DEFINES first
|
|
104
|
+
return symbols, edges
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def decompose_text(
|
|
108
|
+
repo_key: str, # The repo's durable conceptual slug (caller-supplied; the federation anchor)
|
|
109
|
+
module_path: str, # Repo-relative module path (identity input)
|
|
110
|
+
path: str, # File path (provenance locator)
|
|
111
|
+
text: str, # The module source text
|
|
112
|
+
content_hash: Optional[str] = None, # Precomputed file hash (else hashed over the UTF-8 text)
|
|
113
|
+
import_name: Optional[str] = None, # Override the derived dotted import name
|
|
114
|
+
) -> DecomposedModule: # The decomposed module
|
|
115
|
+
"""Parse + bind in one step from in-memory source text."""
|
|
116
|
+
ch = content_hash if content_hash is not None else SourceRef.compute_hash(text.encode("utf-8"))
|
|
117
|
+
parsed = parse_module(text)
|
|
118
|
+
module = CodeModuleNode(
|
|
119
|
+
repo_key=repo_key, module_path=module_path, path=path, content_hash=ch,
|
|
120
|
+
import_name=import_name if import_name is not None else import_name_for(module_path),
|
|
121
|
+
docstring=parsed.docstring, imports=list(parsed.imports),
|
|
122
|
+
import_bindings=list(parsed.module_used_bindings),
|
|
123
|
+
)
|
|
124
|
+
symbols, defines = _build_symbols(module, parsed, ch, path)
|
|
125
|
+
|
|
126
|
+
# Verbatim-region overlay (the authoring / round-trip substrate): attach each
|
|
127
|
+
# top-level symbol's VERBATIM body + order, mint CodeText nodes for the non-def
|
|
128
|
+
# regions, and order ALL top-level regions under the module via CONTAINS.
|
|
129
|
+
top_by_qual = {s.qualname: s for s in symbols if "." not in s.qualname}
|
|
130
|
+
texts: List[CodeTextNode] = []
|
|
131
|
+
region_ids: List[str] = []
|
|
132
|
+
for i, region in enumerate(parse_regions(text)):
|
|
133
|
+
if region.kind == "symbol":
|
|
134
|
+
sym = top_by_qual.get(region.qualname)
|
|
135
|
+
if sym is None: # defensive: a top-level def with no matching ParsedSymbol (shouldn't happen)
|
|
136
|
+
continue
|
|
137
|
+
sym.body = region.text
|
|
138
|
+
sym.body_hash = SourceRef.compute_hash(region.text.encode("utf-8"))
|
|
139
|
+
sym.order_index = i
|
|
140
|
+
region_ids.append(sym.id)
|
|
141
|
+
else:
|
|
142
|
+
ct = CodeTextNode(
|
|
143
|
+
module_id=module.id, region_key=region.region_key, text=region.text,
|
|
144
|
+
content_hash=SourceRef.compute_hash(region.text.encode("utf-8")),
|
|
145
|
+
order_index=i, path=path, kind=_text_region_kind(region))
|
|
146
|
+
texts.append(ct)
|
|
147
|
+
region_ids.append(ct.id)
|
|
148
|
+
|
|
149
|
+
local_edges = [module.about_edge(), *defines, *module.contains_edges(region_ids)]
|
|
150
|
+
return DecomposedModule(module=module, symbols=symbols, local_edges=local_edges,
|
|
151
|
+
parsed=parsed, texts=texts)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def decompose_file(
|
|
155
|
+
repo_key: str, # The repo's durable conceptual slug
|
|
156
|
+
path: str, # Path to the .py file
|
|
157
|
+
repo_root: str, # Repo root (for the repo-relative module path)
|
|
158
|
+
) -> DecomposedModule: # The decomposed module
|
|
159
|
+
"""Read a `.py` file and decompose it (hash over the raw file bytes)."""
|
|
160
|
+
raw = Path(path).read_bytes()
|
|
161
|
+
mp = module_path_for(path, repo_root)
|
|
162
|
+
return decompose_text(repo_key, mp, str(path), raw.decode("utf-8"),
|
|
163
|
+
content_hash=SourceRef.compute_hash(raw))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def iter_py_files(
|
|
167
|
+
root: str, # Directory to walk
|
|
168
|
+
) -> Iterator[str]: # Paths to .py files (skipping generated/packaging dirs)
|
|
169
|
+
"""Yield `.py` file paths under `root`, skipping `__pycache__`/build/etc."""
|
|
170
|
+
base = Path(root)
|
|
171
|
+
for p in sorted(base.rglob("*.py")):
|
|
172
|
+
if any(part in _SKIP_DIRS for part in p.relative_to(base).parts):
|
|
173
|
+
continue
|
|
174
|
+
yield str(p)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def decompose_paths(
|
|
178
|
+
repo_key: str, # The repo's durable conceptual slug
|
|
179
|
+
paths: Iterable[str], # The .py files to decompose
|
|
180
|
+
repo_root: str, # Repo root (for repo-relative module paths)
|
|
181
|
+
) -> List[DecomposedModule]: # One DecomposedModule per parseable file
|
|
182
|
+
"""Decompose an explicit set of files; unparseable files are skipped (recorded by the caller)."""
|
|
183
|
+
out: List[DecomposedModule] = []
|
|
184
|
+
for path in paths:
|
|
185
|
+
try:
|
|
186
|
+
out.append(decompose_file(repo_key, path, repo_root))
|
|
187
|
+
except SyntaxError:
|
|
188
|
+
continue
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def decompose_package(
|
|
193
|
+
repo_key: str, # The repo's durable conceptual slug
|
|
194
|
+
package_dir: str, # The importable package directory (e.g. ".../cjm_dev_graph_schema")
|
|
195
|
+
repo_root: Optional[str] = None, # Repo root for relative module paths (default = package_dir's parent)
|
|
196
|
+
) -> List[DecomposedModule]: # The decomposed package modules
|
|
197
|
+
"""Decompose every `.py` under a package dir (the lib's own importable source).
|
|
198
|
+
|
|
199
|
+
`repo_root` defaults to the package dir's parent, so module paths read like
|
|
200
|
+
`cjm_dev_graph_schema/nodes.py` (the importable form)."""
|
|
201
|
+
root = repo_root if repo_root is not None else str(Path(package_dir).parent)
|
|
202
|
+
return decompose_paths(repo_key, iter_py_files(package_dir), root)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Flatten a decomposed Python corpus into graph elements (the queue-free half).
|
|
2
|
+
|
|
3
|
+
Turns `DecomposedModule`s into the `(nodes, edges)` wire-dict lists that
|
|
4
|
+
`cjm_context_graph_layer.ops.extend_graph` commits — adding the CORPUS-level edges
|
|
5
|
+
that need the whole corpus to resolve:
|
|
6
|
+
|
|
7
|
+
- `IMPORTS` (module -> module): each module's raw imports are resolved (relative
|
|
8
|
+
imports against the importer's own package) and matched to a module in the corpus.
|
|
9
|
+
- `CALLS` (symbol -> symbol): best-effort, by UNAMBIGUOUS bare name — a call name
|
|
10
|
+
that maps to exactly one symbol in the corpus links; ambiguous/external names are
|
|
11
|
+
skipped (precision over recall — never mint a wrong edge).
|
|
12
|
+
|
|
13
|
+
External/stdlib targets are simply absent from the maps and skipped (never
|
|
14
|
+
phantom-minted), mirroring the markdown core's dangling-reference discipline. The
|
|
15
|
+
queue/capability wiring is the projection-CLI driver's concern; the flattening here
|
|
16
|
+
is reusable and unit-testable without a running graph.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .extract import DecomposedModule
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_import(
|
|
25
|
+
raw: str, # Raw import as captured by parse ("os", "pkg.mod", ".identity", "..pkg")
|
|
26
|
+
importer_import_name: str, # The importing module's dotted import name (e.g. "pkg.sub.mod")
|
|
27
|
+
is_package: bool, # Whether the importer is a package (__init__) — its package is itself
|
|
28
|
+
) -> Optional[str]: # Absolute dotted module name, or None when it escapes the corpus root
|
|
29
|
+
"""Resolve a (possibly relative) import to an absolute dotted module name.
|
|
30
|
+
|
|
31
|
+
Absolute imports pass through unchanged. A relative import's leading dots are a
|
|
32
|
+
level (1 = the importer's own package, each extra dot goes one package up); the
|
|
33
|
+
remainder is appended. Returns None when the dots climb above the top package."""
|
|
34
|
+
if not raw.startswith("."):
|
|
35
|
+
return raw
|
|
36
|
+
n_dots = len(raw) - len(raw.lstrip("."))
|
|
37
|
+
suffix = raw[n_dots:]
|
|
38
|
+
parts = importer_import_name.split(".") if importer_import_name else []
|
|
39
|
+
base = parts if is_package else parts[:-1] # the importer's package
|
|
40
|
+
up = n_dots - 1
|
|
41
|
+
if up > len(base):
|
|
42
|
+
return None
|
|
43
|
+
pkg = list(base[:len(base) - up]) if up else list(base)
|
|
44
|
+
if suffix:
|
|
45
|
+
pkg += suffix.split(".")
|
|
46
|
+
return ".".join(pkg) if pkg else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_import_map(
|
|
50
|
+
decomposed: Iterable[DecomposedModule], # The decomposed corpus
|
|
51
|
+
) -> Dict[str, str]: # {dotted import name: CodeModule id}
|
|
52
|
+
"""Map every module's dotted import name to its node id (the IMPORTS target table)."""
|
|
53
|
+
return {d.module.import_name: d.module.id for d in decomposed if d.module.import_name}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_call_map(
|
|
57
|
+
decomposed: Iterable[DecomposedModule], # The decomposed corpus
|
|
58
|
+
) -> Dict[str, str]: # {bare symbol name: CodeSymbol id}, UNAMBIGUOUS names only
|
|
59
|
+
"""Map UNAMBIGUOUS bare symbol names to their node ids (the CALLS target table).
|
|
60
|
+
|
|
61
|
+
A name defined by more than one symbol is omitted — resolving it would guess.
|
|
62
|
+
This favors precision: distinctively-named free functions link, shared method
|
|
63
|
+
names (`id`, `to_graph_node`, ...) do not."""
|
|
64
|
+
name_to_ids: Dict[str, set] = {}
|
|
65
|
+
for d in decomposed:
|
|
66
|
+
for s in d.symbols:
|
|
67
|
+
name_to_ids.setdefault(s.qualname.split(".")[-1], set()).add(s.id)
|
|
68
|
+
return {name: next(iter(ids)) for name, ids in name_to_ids.items() if len(ids) == 1}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def corpus_graph_elements(
|
|
72
|
+
decomposed: Iterable[DecomposedModule], # The decomposed corpus (one entry per module)
|
|
73
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: # (node wire dicts, edge wire dicts)
|
|
74
|
+
"""Collect a decomposed corpus into the node + edge wire-dict lists `extend_graph` expects.
|
|
75
|
+
|
|
76
|
+
Nodes: one `CodeModule` per file + one `CodeSymbol` per definition. Edges: the
|
|
77
|
+
per-module local edges (ABOUT to the repo Entity + structural DEFINES) plus the
|
|
78
|
+
resolved corpus-level IMPORTS/CALLS. Deterministic ids make this idempotent
|
|
79
|
+
under `extend_graph` (re-ingesting collides into verified no-ops)."""
|
|
80
|
+
decs = list(decomposed)
|
|
81
|
+
import_map = build_import_map(decs)
|
|
82
|
+
call_map = build_call_map(decs)
|
|
83
|
+
|
|
84
|
+
nodes: List[Dict[str, Any]] = []
|
|
85
|
+
edges: List[Dict[str, Any]] = []
|
|
86
|
+
for d in decs:
|
|
87
|
+
nodes.append(d.module.to_graph_node())
|
|
88
|
+
nodes.extend(s.to_graph_node() for s in d.symbols)
|
|
89
|
+
nodes.extend(t.to_graph_node() for t in d.texts) # non-def verbatim regions (round-trip substrate)
|
|
90
|
+
edges.extend(d.local_edges)
|
|
91
|
+
|
|
92
|
+
is_pkg = d.module.module_path.endswith("__init__.py")
|
|
93
|
+
local_imports: Dict[str, str] = {}
|
|
94
|
+
for raw in d.module.imports:
|
|
95
|
+
target = resolve_import(raw, d.module.import_name, is_pkg)
|
|
96
|
+
if target and target in import_map and import_map[target] != d.module.id:
|
|
97
|
+
local_imports[raw] = import_map[target]
|
|
98
|
+
edges.extend(d.module.import_edges(local_imports))
|
|
99
|
+
|
|
100
|
+
for s in d.symbols:
|
|
101
|
+
edges.extend(s.calls_edges(call_map))
|
|
102
|
+
edges.extend(s.uses_edges(call_map))
|
|
103
|
+
return nodes, edges
|