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,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,,
|