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.
@@ -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