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,518 @@
1
+ """Schema-free Python parsing (stdlib `ast`).
2
+
3
+ Decomposes a `.py` module into its module docstring, imports, and the tree of
4
+ top-level + nested symbols (functions, classes, methods) — each with its
5
+ qualified name, kind, line number, docstring, decorators, and the names it
6
+ references/calls. Carries NO graph-schema dependency on purpose: this is the
7
+ genuinely-general layer, reusable for any Python corpus. The dev-domain binding
8
+ (-> `CodeModule`/`CodeSymbol` nodes) lives in `extract`.
9
+
10
+ Mirrors `cjm_markdown_decompose_core.parse`'s split: pure structural
11
+ decomposition here, the schema meeting in `extract`.
12
+ """
13
+
14
+ import ast
15
+ from dataclasses import dataclass, field
16
+ from typing import Iterator, List
17
+
18
+ # Definition node types that become symbols (and that own their own call scope).
19
+ _DEF_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
20
+
21
+
22
+ @dataclass
23
+ class ParsedSymbol:
24
+ """One definition within a module (function/class/method), with its nesting."""
25
+ qualname: str # Dotted name within the module (e.g. "EntityNode.to_graph_node")
26
+ name: str # Bare name (the last qualname segment)
27
+ kind: str # "function" | "class" | "method"
28
+ lineno: int # 1-based start line
29
+ docstring: str = "" # First non-empty line of the symbol's docstring ("" if none)
30
+ decorators: List[str] = field(default_factory=list) # Decorator names (e.g. "dataclass", "property")
31
+ calls: List[str] = field(default_factory=list) # Names this symbol directly CALLS (call-callees only; dedup, order-preserved)
32
+ refs: List[str] = field(default_factory=list) # Names this symbol REFERENCES (superset of calls: + bases/annotations/decorators/name loads)
33
+ import_bindings: List[dict] = field(default_factory=list) # Top-level imports this symbol's refs use (travel with it on a move; imports-as-projection)
34
+ children: List["ParsedSymbol"] = field(default_factory=list) # Nested symbols (a class's methods, a nested function)
35
+ first_param: str = "" # The function's first parameter name ("" for a class / no-arg fn); "self"/"cls" marks method-shaped
36
+ first_param_annotation: str = "" # The first param's type annotation name (e.g. "JobQueue" for `@patch def f(self:JobQueue)`)
37
+
38
+
39
+ @dataclass
40
+ class ParsedModule:
41
+ """The structural decomposition of one Python module."""
42
+ docstring: str = "" # First non-empty line of the module docstring
43
+ imports: List[str] = field(default_factory=list) # Imported module names (dotted; relative kept as ".pkg"), dedup/order-preserved
44
+ symbols: List[ParsedSymbol] = field(default_factory=list) # Top-level symbols (each may carry children)
45
+ import_bindings: dict = field(default_factory=dict) # {local-name: [binding, ...]} for every TOP-LEVEL import (the imports-as-projection table; a list because plain submodule imports sharing a root coexist)
46
+ module_used_bindings: List[dict] = field(default_factory=list) # Bindings used by module-level (non-def) code — constants/__all__/__main__
47
+
48
+
49
+ def _docstring_first_line(
50
+ node: ast.AST, # A module / function / class node
51
+ ) -> str: # First non-empty docstring line ("" when no docstring)
52
+ """The first non-empty line of a node's docstring (the relevance/description hook)."""
53
+ doc = ast.get_docstring(node)
54
+ if not doc:
55
+ return ""
56
+ for line in doc.splitlines():
57
+ s = line.strip()
58
+ if s:
59
+ return s
60
+ return ""
61
+
62
+
63
+ def _callee_name(
64
+ func: ast.AST, # The `.func` of an ast.Call
65
+ ) -> str: # The bare callee name ("" when not a plain Name/Attribute)
66
+ """Bare callee name: `f()` -> "f", `obj.m()` / `mod.f()` -> "m"/"f"."""
67
+ if isinstance(func, ast.Name):
68
+ return func.id
69
+ if isinstance(func, ast.Attribute):
70
+ return func.attr
71
+ return ""
72
+
73
+
74
+ def _decorator_name(
75
+ dec: ast.AST, # A decorator expression
76
+ ) -> str: # The decorator's bare name ("" when not a Name/Attribute/Call of those)
77
+ """Bare decorator name: `@foo` -> "foo", `@a.b` -> "b", `@foo(...)` -> "foo"."""
78
+ if isinstance(dec, ast.Name):
79
+ return dec.id
80
+ if isinstance(dec, ast.Attribute):
81
+ return dec.attr
82
+ if isinstance(dec, ast.Call):
83
+ return _decorator_name(dec.func)
84
+ return ""
85
+
86
+
87
+ def _annotation_name(
88
+ ann: ast.AST, # A type-annotation expression (or None)
89
+ ) -> str: # The annotation's bare name ("" when absent/unsupported)
90
+ """Bare annotation name: `JobQueue` -> "JobQueue", `mod.T` -> "T", `"JobQueue"` (forward ref) -> "JobQueue"."""
91
+ if ann is None:
92
+ return ""
93
+ if isinstance(ann, ast.Name):
94
+ return ann.id
95
+ if isinstance(ann, ast.Attribute):
96
+ return ann.attr
97
+ if isinstance(ann, ast.Constant) and isinstance(ann.value, str):
98
+ return ann.value
99
+ return ""
100
+
101
+
102
+ def _first_param(
103
+ node: ast.AST, # A FunctionDef / AsyncFunctionDef node
104
+ ) -> "tuple[str, str]": # (first param name, its annotation name) — ("","") when none
105
+ """The function's first positional parameter name + annotation (the method-shape signal).
106
+
107
+ A first param named `self`/`cls` marks a method-shaped function; its annotation
108
+ (e.g. `self: JobQueue` under `@patch`) names the class the method belongs to."""
109
+ args = getattr(node, "args", None)
110
+ posonly = list(getattr(args, "posonlyargs", []) or []) if args else []
111
+ pos = list(getattr(args, "args", []) or []) if args else []
112
+ ordered = posonly + pos
113
+ if not ordered:
114
+ return "", ""
115
+ a = ordered[0]
116
+ return a.arg, _annotation_name(a.annotation)
117
+
118
+
119
+ def _collect_calls(
120
+ node: ast.AST, # A statement/expression node to scan
121
+ names: "dict[str, None]", # Accumulator (ordered set via dict keys)
122
+ ) -> None:
123
+ """Collect direct call names under `node`, NOT descending into nested defs.
124
+
125
+ A nested function/class owns its own calls (attributed to that child symbol),
126
+ so its subtree is skipped here — this keeps each symbol's `calls` to the code
127
+ it itself runs."""
128
+ if isinstance(node, _DEF_TYPES):
129
+ return
130
+ if isinstance(node, ast.Call):
131
+ nm = _callee_name(node.func)
132
+ if nm:
133
+ names.setdefault(nm, None)
134
+ for child in ast.iter_child_nodes(node):
135
+ _collect_calls(child, names)
136
+
137
+
138
+ def _direct_calls(
139
+ node: ast.AST, # A FunctionDef / ClassDef node
140
+ ) -> List[str]: # Direct call names in its body (dedup, order-preserved)
141
+ """Call names made directly in a symbol's body (excluding nested def bodies)."""
142
+ names: "dict[str, None]" = {}
143
+ for stmt in getattr(node, "body", []):
144
+ _collect_calls(stmt, names)
145
+ return list(names)
146
+
147
+
148
+ def _collect_refs(
149
+ node: ast.AST, # A statement/expression node to scan
150
+ names: "dict[str, None]", # Accumulator (ordered set via dict keys)
151
+ ) -> None:
152
+ """Collect ALL referenced bare names under `node`, NOT descending into nested defs.
153
+
154
+ The superset of `_collect_calls`: every `Name` load and `Attribute` access (so a
155
+ base class, a type annotation used bare, a decorator, a referenced constant — not
156
+ just call-callees). A nested def owns its own refs, so its subtree is skipped."""
157
+ if isinstance(node, _DEF_TYPES):
158
+ return
159
+ if isinstance(node, ast.Name):
160
+ names.setdefault(node.id, None)
161
+ elif isinstance(node, ast.Attribute):
162
+ names.setdefault(node.attr, None)
163
+ for child in ast.iter_child_nodes(node):
164
+ _collect_refs(child, names)
165
+
166
+
167
+ def _direct_refs(
168
+ node: ast.AST, # A FunctionDef / AsyncFunctionDef / ClassDef node
169
+ ) -> List[str]: # Referenced names in the symbol's own code (dedup, order-preserved)
170
+ """All names a symbol references in its OWN code (excluding nested def bodies).
171
+
172
+ Spans the signature surface a body-only call walk misses — decorators, class bases
173
+ + keywords, parameter/return annotations — plus the body's Name/Attribute loads.
174
+ Resolution against the corpus filters locals/builtins, so over-collection is safe."""
175
+ names: "dict[str, None]" = {}
176
+ for dec in getattr(node, "decorator_list", []):
177
+ _collect_refs(dec, names)
178
+ if isinstance(node, ast.ClassDef):
179
+ for base in node.bases:
180
+ _collect_refs(base, names)
181
+ for kw in node.keywords: # metaclass= and other class kwargs
182
+ _collect_refs(kw.value, names)
183
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
184
+ args = node.args
185
+ all_args = (list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
186
+ + ([args.vararg] if args.vararg else [])
187
+ + ([args.kwarg] if args.kwarg else []))
188
+ for a in all_args:
189
+ if a is not None and a.annotation is not None:
190
+ _collect_refs(a.annotation, names)
191
+ for d in list(args.defaults) + list(args.kw_defaults): # parameter DEFAULTS reference names too
192
+ if d is not None:
193
+ _collect_refs(d, names)
194
+ if node.returns is not None:
195
+ _collect_refs(node.returns, names)
196
+ for stmt in getattr(node, "body", []):
197
+ _collect_refs(stmt, names)
198
+ return list(names)
199
+
200
+
201
+ def _import_bindings(
202
+ tree: ast.Module, # The parsed module
203
+ ) -> "dict[str, list[dict]]": # {local-name bound in the namespace: its binding descriptor(s)}
204
+ """Map each name a TOP-LEVEL import binds to descriptor(s) emit can regenerate.
205
+
206
+ Only module-level imports (a local import inside a function stays in that function's
207
+ verbatim body, so it must NOT be hoisted to the regenerated module import block).
208
+ `import a.b` binds `a`; `import a.b as c` binds `c`; `from m import x [as y]` binds
209
+ `y or x`. The descriptor carries kind/module/imported/alias/level so a faithful
210
+ `import ...` / `from ... import ...` line can be re-emitted and a symbol's used subset
211
+ can travel with it on a move.
212
+
213
+ A name maps to a LIST because plain un-aliased submodule imports COEXIST: `import
214
+ urllib.request` + `import urllib.error` both bind `urllib` and both statements are
215
+ live (each initializes a different submodule) — keying one descriptor per name
216
+ silently dropped all but the last (the flip-time import-dedupe bug). Aliased and
217
+ `from` imports rebinding a name genuinely supersede it (Python's last-binding-wins),
218
+ so those still REPLACE the entry."""
219
+ out: "dict[str, list[dict]]" = {}
220
+ for node in tree.body:
221
+ if isinstance(node, ast.Import):
222
+ for alias in node.names:
223
+ local = alias.asname or alias.name.split(".")[0]
224
+ desc = {"name": local, "kind": "import", "module": alias.name,
225
+ "imported": "", "alias": alias.asname or "", "level": 0}
226
+ existing = out.get(local)
227
+ if (not alias.asname and existing
228
+ and all(d["kind"] == "import" and not d["alias"] for d in existing)):
229
+ if not any(d["module"] == alias.name for d in existing):
230
+ existing.append(desc)
231
+ else:
232
+ out[local] = [desc]
233
+ elif isinstance(node, ast.ImportFrom):
234
+ for alias in node.names:
235
+ if alias.name == "*":
236
+ continue
237
+ local = alias.asname or alias.name
238
+ out[local] = [{"name": local, "kind": "from", "module": node.module or "",
239
+ "imported": alias.name, "alias": alias.asname or "",
240
+ "level": node.level or 0}]
241
+ return out
242
+
243
+
244
+ def _used_bindings(
245
+ ref_names: List[str], # Names a symbol (or region) references
246
+ bindings: "dict[str, list[dict]]", # The module's top-level import-binding table
247
+ ) -> List[dict]: # The binding descriptors those refs use (order-preserved, dedup)
248
+ """The subset of `bindings` a set of referenced names actually uses.
249
+
250
+ A referenced name pulls in EVERY descriptor bound to it (all coexisting
251
+ `import root.sub` statements — the ref walk sees only `root`, so which submodule
252
+ the symbol touches is unknowable; carrying all of them is the faithful answer)."""
253
+ out: List[dict] = []
254
+ seen: set = set()
255
+ for r in ref_names:
256
+ for b in bindings.get(r, ()):
257
+ k = (b.get("kind"), b.get("level", 0), b.get("module", ""),
258
+ b.get("imported", ""), b.get("alias", ""))
259
+ if k not in seen:
260
+ seen.add(k)
261
+ out.append(b)
262
+ return out
263
+
264
+
265
+ def _extract_symbols(
266
+ body: List[ast.stmt], # A module/class/function body
267
+ bindings: "dict[str, dict]", # The module's top-level import-binding table (for per-symbol used imports)
268
+ parent_qual: str = "", # Enclosing qualname prefix ("" at module level)
269
+ in_class: bool = False, # Whether `body` is a class body (so defs are methods)
270
+ ) -> List[ParsedSymbol]: # Symbols declared directly in this body (with children)
271
+ """Recursively decompose the def/class nodes in a body into `ParsedSymbol`s."""
272
+ out: List[ParsedSymbol] = []
273
+ for node in body:
274
+ if not isinstance(node, _DEF_TYPES):
275
+ continue
276
+ qual = f"{parent_qual}.{node.name}" if parent_qual else node.name
277
+ if isinstance(node, ast.ClassDef):
278
+ kind = "class"
279
+ fp, fpa = "", ""
280
+ else:
281
+ kind = "method" if in_class else "function"
282
+ fp, fpa = _first_param(node)
283
+ refs = _direct_refs(node)
284
+ out.append(ParsedSymbol(
285
+ qualname=qual,
286
+ name=node.name,
287
+ kind=kind,
288
+ lineno=node.lineno,
289
+ docstring=_docstring_first_line(node),
290
+ decorators=[d for d in (_decorator_name(x) for x in node.decorator_list) if d],
291
+ calls=_direct_calls(node),
292
+ refs=refs,
293
+ import_bindings=_used_bindings(refs, bindings),
294
+ children=_extract_symbols(node.body, bindings, parent_qual=qual,
295
+ in_class=isinstance(node, ast.ClassDef)),
296
+ first_param=fp,
297
+ first_param_annotation=fpa,
298
+ ))
299
+ return out
300
+
301
+
302
+ def _module_imports(
303
+ tree: ast.Module, # The parsed module
304
+ ) -> List[str]: # Imported module names (dotted; relative as leading-dot), dedup/order-preserved
305
+ """Imported module names, in a form a corpus resolver can match.
306
+
307
+ `import a.b` / `import a.b as c` -> "a.b"; `from a.b import c` -> "a.b" AND
308
+ "a.b.c" (so `from pkg import submodule` resolves to the submodule when it is a
309
+ module); relative imports keep their leading dots ("." / ".identity" / "..pkg")
310
+ for the extract layer to resolve against the module's own package."""
311
+ names: "dict[str, None]" = {}
312
+ for node in ast.walk(tree):
313
+ if isinstance(node, ast.Import):
314
+ for alias in node.names:
315
+ names.setdefault(alias.name, None)
316
+ elif isinstance(node, ast.ImportFrom):
317
+ base = "." * (node.level or 0) + (node.module or "")
318
+ if base:
319
+ names.setdefault(base, None)
320
+ for alias in node.names:
321
+ if alias.name == "*":
322
+ continue
323
+ joiner = "" if base.endswith(".") else "."
324
+ names.setdefault(f"{base}{joiner}{alias.name}" if base else alias.name, None)
325
+ return list(names)
326
+
327
+
328
+ def parse_module(
329
+ text: str, # Full module source text
330
+ ) -> ParsedModule: # The structural decomposition
331
+ """Parse Python source into module docstring + imports + the symbol tree.
332
+
333
+ Raises `SyntaxError` on unparseable source (the caller decides how to handle a
334
+ malformed file — there is no silent partial parse)."""
335
+ tree = ast.parse(text)
336
+ bindings = _import_bindings(tree)
337
+ # Imports used by module-level (non-def) code — constants, __all__, an `if __main__`.
338
+ mod_names: "dict[str, None]" = {}
339
+ for stmt in tree.body:
340
+ if not isinstance(stmt, _DEF_TYPES):
341
+ _collect_refs(stmt, mod_names)
342
+ return ParsedModule(
343
+ docstring=_docstring_first_line(tree),
344
+ imports=_module_imports(tree),
345
+ symbols=_extract_symbols(tree.body, bindings),
346
+ import_bindings=bindings,
347
+ module_used_bindings=_used_bindings(list(mod_names), bindings),
348
+ )
349
+
350
+
351
+ def monkeypatch_assignments(
352
+ text: str, # Module / cell source text
353
+ ) -> List["tuple[str, str, str]"]: # (class_name, attr_name, value_func_name) per top-level `Class.attr = func`
354
+ """Top-level monkey-patch assignments: `Class.method = func` (the incremental-class idiom).
355
+
356
+ The nbdev/notebook pattern (e.g. `JobQueue.submit = submit`) that reattaches a
357
+ free function as a class method in a later cell — opaque to the AST symbol walk, so
358
+ surfaced here for the compositor to rebuild the true class->method structure. Only
359
+ `Attribute(Name) = Name` top-level assigns are matched (precision over recall)."""
360
+ out: List["tuple[str, str, str]"] = []
361
+ try:
362
+ tree = ast.parse(text)
363
+ except SyntaxError:
364
+ return out
365
+ for node in tree.body:
366
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Name):
367
+ continue
368
+ func = node.value.id
369
+ for tgt in node.targets:
370
+ if (isinstance(tgt, ast.Attribute) and isinstance(tgt.value, ast.Name)):
371
+ out.append((tgt.value.id, tgt.attr, func))
372
+ return out
373
+
374
+
375
+ def iter_symbols(
376
+ parsed: ParsedModule, # A decomposed module
377
+ ) -> Iterator[ParsedSymbol]: # Every symbol, parents before children (depth-first)
378
+ """Flatten the symbol tree depth-first (parent before its children).
379
+
380
+ Convenience for building corpus-wide symbol maps and for emitting one node per
381
+ symbol regardless of nesting."""
382
+ def walk(syms: List[ParsedSymbol]) -> Iterator[ParsedSymbol]:
383
+ for s in syms:
384
+ yield s
385
+ yield from walk(s.children)
386
+ yield from walk(parsed.symbols)
387
+
388
+
389
+ # ----------------------------------------------------------------------------- #
390
+ # Verbatim region decomposition (the authoring / round-trip substrate)
391
+ #
392
+ # A module is an ORDERED sequence of top-level regions: each def/class is a
393
+ # "symbol" region carrying its VERBATIM body (decorators + leading comments through
394
+ # the end of the def); each run of contiguous non-def top-level statements (imports,
395
+ # the module docstring, constants, `__all__`, `if __name__`) is a "text" region held
396
+ # verbatim. Blank lines BETWEEN regions are SEAMS — dropped here and regenerated
397
+ # canonically on emit ("the graph owns formatting"). This is the plain-`.py`
398
+ # analogue of a notebook's ordered verbatim cells; bodies are stored verbatim (NOT
399
+ # AST-as-graph — the round-trip trap).
400
+ # ----------------------------------------------------------------------------- #
401
+
402
+ @dataclass
403
+ class SourceRegion:
404
+ """One ordered top-level region of a module, held verbatim (symbol or text)."""
405
+ kind: str # "symbol" (a def/class) | "text" (a non-def run)
406
+ text: str # The region's VERBATIM source (trailing blank lines trimmed; emit re-adds seams)
407
+ start_line: int # 1-based first source line of the region
408
+ end_line: int # 1-based last source line of the region
409
+ qualname: str = "" # Symbol regions: the top-level def/class name (matches the ParsedSymbol)
410
+ symbol_kind: str = "" # Symbol regions: "function" | "class"
411
+ region_key: str = "" # Text regions: a best-effort stable anchor (the region's leading line)
412
+
413
+
414
+ def _toplevel_start(
415
+ node: ast.stmt, # A top-level statement node
416
+ ) -> int: # Its first source line, decorator-aware
417
+ """The first source line of a top-level node, counting decorators above a def."""
418
+ start = node.lineno
419
+ for d in getattr(node, "decorator_list", []):
420
+ start = min(start, d.lineno)
421
+ return start
422
+
423
+
424
+ def _text_region_key(
425
+ text: str, # The region's verbatim text
426
+ ) -> str: # A best-effort stable anchor key
427
+ """A best-effort identity anchor for a text region: its first non-blank line.
428
+
429
+ Stable across edits that don't change what the region LEADS with (v1 limitation —
430
+ symbol identity is the rename-stable one that carries born-on-graph annotations;
431
+ text-region identity is best-effort, since text regions hold no annotations)."""
432
+ for line in text.splitlines():
433
+ s = line.strip()
434
+ if s:
435
+ return s[:80]
436
+ return "region"
437
+
438
+
439
+ def parse_regions(
440
+ text: str, # Full module source text
441
+ ) -> List[SourceRegion]: # The ordered top-level regions (verbatim), seams excluded
442
+ """Decompose a module into ordered verbatim top-level regions (the round-trip substrate).
443
+
444
+ Def/class nodes become "symbol" regions (verbatim body incl. decorators + the
445
+ contiguous comment block immediately above); runs of adjacent non-def top-level
446
+ statements merge into "text" regions. Leading blank lines of each gap are dropped
447
+ as seams (canonical emit regenerates them); a comment touching the following node
448
+ attaches to it. Raises `SyntaxError` on unparseable source (caller decides)."""
449
+ tree = ast.parse(text)
450
+ lines = text.splitlines(keepends=True) # 0-based; line L is lines[L-1]
451
+ n = len(lines)
452
+
453
+ def slice_text(start: int, end: int) -> str: # 1-based inclusive -> verbatim, trailing blanks trimmed
454
+ return "".join(lines[start - 1:end]).rstrip("\n")
455
+
456
+ regions: List[SourceRegion] = []
457
+ prev_end = 0 # last assigned source line (1-based)
458
+ for node in tree.body:
459
+ nstart, nend = _toplevel_start(node), (node.end_lineno or node.lineno)
460
+ seg_start = prev_end + 1
461
+ # Drop leading blank lines of the gap (seams); a comment then attaches to this node.
462
+ while seg_start < nstart and not lines[seg_start - 1].strip():
463
+ seg_start += 1
464
+ is_def = isinstance(node, _DEF_TYPES)
465
+ body = slice_text(seg_start, nend)
466
+ if is_def:
467
+ regions.append(SourceRegion(
468
+ kind="symbol", text=body, start_line=seg_start, end_line=nend,
469
+ qualname=node.name,
470
+ symbol_kind="class" if isinstance(node, ast.ClassDef) else "function"))
471
+ else:
472
+ # Merge with a directly-adjacent preceding text region (no blank line between),
473
+ # so an import block / consecutive statements stay ONE region (one seam, not N).
474
+ if (regions and regions[-1].kind == "text"
475
+ and seg_start <= regions[-1].end_line + 1):
476
+ merged = regions[-1]
477
+ merged.text = slice_text(merged.start_line, nend)
478
+ merged.end_line = nend
479
+ else:
480
+ regions.append(SourceRegion(
481
+ kind="text", text=body, start_line=seg_start, end_line=nend,
482
+ region_key=_text_region_key(body)))
483
+ prev_end = nend
484
+
485
+ # Trailing non-blank content after the last node (e.g. a trailing comment) -> a final text region.
486
+ tail_start = prev_end + 1
487
+ while tail_start <= n and not lines[tail_start - 1].strip():
488
+ tail_start += 1
489
+ if tail_start <= n:
490
+ tail = slice_text(tail_start, n)
491
+ regions.append(SourceRegion(kind="text", text=tail, start_line=tail_start,
492
+ end_line=n, region_key=_text_region_key(tail)))
493
+
494
+ # An empty / comments-only module has no ast nodes -> one text region holding it all.
495
+ if not regions and text.strip():
496
+ whole = text.rstrip("\n")
497
+ regions.append(SourceRegion(kind="text", text=whole, start_line=1, end_line=n,
498
+ region_key=_text_region_key(whole)))
499
+ return regions
500
+
501
+
502
+ def emit_regions(
503
+ regions: List[SourceRegion], # Ordered top-level regions (symbol/text) to reassemble
504
+ ) -> str: # Canonical module source (verbatim bodies + canonical seams)
505
+ """Reassemble ordered regions into canonical `.py` source — the graph owns formatting.
506
+
507
+ Bodies are emitted VERBATIM; only the SEAMS between top-level regions are canonical:
508
+ two blank lines around any def/class region (PEP-8), one blank line between text
509
+ regions, and exactly one trailing newline. So decompose→emit is identity on PEP-8
510
+ source and seam-normalizing otherwise (the deliberate v1 fidelity bar: bodies
511
+ byte-exact, seams canonical, semantically equal)."""
512
+ parts: List[str] = []
513
+ for i, r in enumerate(regions):
514
+ if i > 0:
515
+ either_symbol = r.kind == "symbol" or regions[i - 1].kind == "symbol"
516
+ parts.append("\n\n\n" if either_symbol else "\n\n")
517
+ parts.append(r.text.rstrip("\n"))
518
+ return ("".join(parts) + "\n") if parts else ""
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-python-decompose-core
3
+ Version: 0.0.6
4
+ Summary: A Python source-code decomposition core for context graphs: parses .py modules (docstrings, imports, and the symbol tree of functions/classes/methods with their calls) into provenance-carrying graph nodes and edges that co-reside with the markdown source-type. First source = the ecosystem's own code; generalizes to any Python corpus.
5
+ Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Repository, https://github.com/cj-mills/cjm-python-decompose-core
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cjm-dev-graph-schema>=0.0.8
14
+ Requires-Dist: cjm-context-graph-layer>=0.0.7
15
+ Requires-Dist: cjm-context-graph-primitives>=0.0.12
16
+ Dynamic: license-file
17
+
18
+ # cjm-python-decompose-core
19
+
20
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
21
+
22
+ A Python source-code decomposition core for context graphs: parses .py modules (docstrings, imports, and the symbol tree of functions/classes/methods with their calls) into provenance-carrying graph nodes and edges that co-reside with the markdown source-type. First source = the ecosystem's own code; generalizes to any Python corpus.
23
+
24
+ ## Modules
25
+
26
+ - **`cjm_python_decompose_core.__init__`**
27
+ - **`cjm_python_decompose_core.emit`** — Project a module BACK out of the graph (graph -> .py) — the canonical emit leg.
28
+ - **`cjm_python_decompose_core.extract`** — Bind parsed Python onto dev-graph-schema code nodes (the dev-domain binding).
29
+ - **`cjm_python_decompose_core.ingest`** — Flatten a decomposed Python corpus into graph elements (the queue-free half).
30
+ - **`cjm_python_decompose_core.parse`** — Schema-free Python parsing (stdlib `ast`).
31
+ - **`tests.test_extract_ingest`** — Extract (parse -> schema nodes) + ingest (corpus -> graph elements) binding.
32
+ - **`tests.test_imports_emit`** — Imports-as-projection: deriving a module's canonical import block from bindings.
33
+ - **`tests.test_method_attribution`** — First-param capture + monkey-patch-assignment detection (the cross-cell method idioms).
34
+ - **`tests.test_parse`** — Schema-free Python parsing: docstrings, imports, the symbol tree, and calls.
35
+ - **`tests.test_regions_emit`** — Verbatim region decomposition + canonical emit (the authoring / round-trip substrate).
36
+ - **`tests_manual.imports_projection`** — Imports-as-projection faithfulness sweep over the real arc libs (corpus harness).
37
+
38
+ ## API
39
+
40
+ ### `cjm_python_decompose_core.emit`
41
+
42
+ - `emit_module_from_nodes` _function_ — Reassemble a module's canonical `.py` source from its graph nodes (the round-trip).
43
+ - `module_used_bindings` _function_ — Union of every contained symbol's import bindings + the module-level ones (+ any
44
+ - `nodes_for_module` _function_ — Filter queried region nodes down to one module (by `module_id` property).
45
+ - `regions_from_nodes` _function_ — Collect a module's top-level regions from its nodes, ordered by `order_index`.
46
+ - `render_binding` _function_ — Render one import-binding descriptor back to its canonical import statement.
47
+ - `render_import_block` _function_ — Derive a module's canonical import block from its used import bindings.
48
+ - `synth_import` _function_ — A synthetic `from <import_name> import <local_name>` binding (USES-derived).
49
+
50
+ ### `cjm_python_decompose_core.extract`
51
+
52
+ - `DecomposedModule` _class_ — One module bound to schema nodes: the module + its symbols + local edges.
53
+ - `decompose_file` _function_ — Read a `.py` file and decompose it (hash over the raw file bytes).
54
+ - `decompose_package` _function_ — Decompose every `.py` under a package dir (the lib's own importable source).
55
+ - `decompose_paths` _function_ — Decompose an explicit set of files; unparseable files are skipped (recorded by the caller).
56
+ - `decompose_text` _function_ — Parse + bind in one step from in-memory source text.
57
+ - `import_name_for` _function_ — The dotted import name a module is reachable by (drops a trailing `__init__`).
58
+ - `iter_py_files` _function_ — Yield `.py` file paths under `root`, skipping `__pycache__`/build/etc.
59
+ - `module_path_for` _function_ — The repo-relative POSIX path used as the module's identity input.
60
+
61
+ ### `cjm_python_decompose_core.ingest`
62
+
63
+ - `build_call_map` _function_ — Map UNAMBIGUOUS bare symbol names to their node ids (the CALLS target table).
64
+ - `build_import_map` _function_ — Map every module's dotted import name to its node id (the IMPORTS target table).
65
+ - `corpus_graph_elements` _function_ — Collect a decomposed corpus into the node + edge wire-dict lists `extend_graph` expects.
66
+ - `resolve_import` _function_ — Resolve a (possibly relative) import to an absolute dotted module name.
67
+
68
+ ### `cjm_python_decompose_core.parse`
69
+
70
+ - `ParsedModule` _class_ — The structural decomposition of one Python module.
71
+ - `ParsedSymbol` _class_ — One definition within a module (function/class/method), with its nesting.
72
+ - `SourceRegion` _class_ — One ordered top-level region of a module, held verbatim (symbol or text).
73
+ - `emit_regions` _function_ — Reassemble ordered regions into canonical `.py` source — the graph owns formatting.
74
+ - `iter_symbols` _function_ — Flatten the symbol tree depth-first (parent before its children).
75
+ - `monkeypatch_assignments` _function_ — Top-level monkey-patch assignments: `Class.method = func` (the incremental-class idiom).
76
+ - `parse_module` _function_ — Parse Python source into module docstring + imports + the symbol tree.
77
+ - `parse_regions` _function_ — Decompose a module into ordered verbatim top-level regions (the round-trip substrate).
78
+
79
+ ### `tests.test_extract_ingest`
80
+
81
+ - `test_corpus_imports_and_calls_resolve_intra_corpus` _function_
82
+ - `test_decompose_text_builds_module_and_symbols` _function_
83
+ - `test_external_imports_not_minted` _function_
84
+ - `test_import_map_and_call_map` _function_
85
+ - `test_ingest_is_idempotent_in_ids` _function_
86
+ - `test_local_edges_about_repo_and_defines_nesting` _function_
87
+ - `test_module_path_and_import_name` _function_
88
+ - `test_resolve_import_relative_and_absolute` _function_
89
+
90
+ ### `tests.test_imports_emit`
91
+
92
+ - `test_coexisting_submodule_imports_both_survive_round_trip` _function_ — import urllib.request + import urllib.error both bind `urllib` and both are
93
+ - `test_derived_block_is_faithful_and_prunes_dead_imports` _function_
94
+ - `test_docstring_lines_starting_with_from_survive_derive` _function_
95
+ - `test_emit_module_default_is_verbatim_not_derived` _function_
96
+ - `test_emit_module_derive_imports_prunes_dead_and_keeps_bodies` _function_
97
+ - `test_parameter_default_reference_is_not_pruned` _function_
98
+ - `test_rebinding_imports_still_supersede` _function_ — Aliased / from-imports rebinding a name follow Python last-binding-wins —
99
+ - `test_render_binding_forms` _function_
100
+ - `test_render_import_block_orders_dedups_and_groups` _function_
101
+ - `test_render_import_block_wraps_long_from_lines_in_aligned_parens` _function_
102
+ - `test_same_module_imported_twice_dedupes` _function_ — A literally duplicated plain import collapses to one statement.
103
+
104
+ ### `tests.test_method_attribution`
105
+
106
+ - `test_first_param_and_annotation_captured` _function_
107
+ - `test_first_param_forward_ref_annotation` _function_
108
+ - `test_first_param_unannotated_and_classes_have_none` _function_
109
+ - `test_monkeypatch_assignments_detected` _function_
110
+ - `test_monkeypatch_is_structural_filtering_is_downstream` _function_
111
+ - `test_no_arg_function_has_empty_first_param` _function_
112
+
113
+ ### `tests.test_parse`
114
+
115
+ - `parsed` _function_
116
+ - `test_class_methods_are_children_with_method_kind` _function_
117
+ - `test_empty_and_docstringless_module` _function_
118
+ - `test_function_calls_are_direct_only` _function_
119
+ - `test_import_bindings_per_symbol_and_module_level` _function_
120
+ - `test_imports_collected_with_relative_and_submodule_forms` _function_
121
+ - `test_iter_symbols_flattens_parents_before_children` _function_
122
+ - `test_local_import_not_hoisted_to_bindings` _function_
123
+ - `test_method_calls` _function_
124
+ - `test_module_docstring_first_line` _function_
125
+ - `test_nested_function_is_its_own_symbol_with_own_calls` _function_
126
+ - `test_refs_are_superset_of_calls_capturing_bases_and_annotations` _function_
127
+ - `test_syntax_error_propagates` _function_
128
+ - `test_top_level_symbols` _function_
129
+
130
+ ### `tests.test_regions_emit`
131
+
132
+ - `test_codetext_regions_cover_the_non_def_source` _function_
133
+ - `test_decompose_attaches_body_and_order_to_top_level_symbols` _function_
134
+ - `test_decompose_emit_round_trip_from_nodes` _function_
135
+ - `test_emit_is_ast_equal_and_seam_canonical_on_messy_input` _function_
136
+ - `test_emit_regions_round_trips_pep8_byte_exact` _function_
137
+ - `test_parse_regions_orders_symbols_and_text` _function_
138
+ - `test_symbol_body_is_verbatim_with_decorators_and_leading_comments` _function_
139
+
140
+ ### `tests_manual.imports_projection`
141
+
142
+ - `sweep` _function_ — Decompose every arc-lib module and tally false prunes + genuine dead-import prunes.
143
+
144
+ ## Dependencies
145
+
146
+ **Depends on:** `cjm-dev-graph-schema`
147
+ **Used by:** `cjm-context-graph-projection`, `cjm-notebook-decompose-core`
@@ -0,0 +1,10 @@
1
+ cjm_python_decompose_core/__init__.py,sha256=QiiYsv0kcJaB8wCWyT-FnI2b6be87HA-CrrIUn8LQhg,22
2
+ cjm_python_decompose_core/emit.py,sha256=K50m-tD07nx9oIHA1j2gu7Pj4-hz5pGC1_OaOd6n0Ok,15914
3
+ cjm_python_decompose_core/extract.py,sha256=EYkK2_WDJhi_zyFJMa19BqugynfW_w0QXKcOp3Kjy9E,9732
4
+ cjm_python_decompose_core/ingest.py,sha256=hW_UMvXHFc14GcDytqSKZGByMFkN5BErH-UiwQ-ovG4,5034
5
+ cjm_python_decompose_core/parse.py,sha256=snvWjPSA2zOrNorLDuejQJMuB2Ovdhhv4lyLM1kf5q0,24581
6
+ cjm_python_decompose_core-0.0.6.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
7
+ cjm_python_decompose_core-0.0.6.dist-info/METADATA,sha256=J9SDIm856msOHs235aar-gBGpLSIBsqCzjm4j04erxE,9248
8
+ cjm_python_decompose_core-0.0.6.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ cjm_python_decompose_core-0.0.6.dist-info/top_level.txt,sha256=-owDtpMkqu616A4guA2evDg-fWe0K07MaJLzwHSQdWA,26
10
+ cjm_python_decompose_core-0.0.6.dist-info/RECORD,,