stackthread 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__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.
@@ -1,494 +1,494 @@
1
- """Facts from one Python file (design §7.2). Pure: text in, JSON-ready dict out. Never raises on bad code.
2
-
3
- The facts are deliberately framework-neutral. Decorators, module-level assignments and calls are recorded with
4
- their arguments summarised as values, and detectors interpret them later with the whole project in view.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import ast
10
- import copy
11
- import re
12
- from typing import Any
13
-
14
- Value = dict[str, Any]
15
- MAX_LIST = 300
16
- MAX_EXPR = 120
17
-
18
-
19
- def module_name(rel_path: str) -> tuple[str, bool]:
20
- """`los/applications/engine.py` -> (`los.applications.engine`, False); `pkg/__init__.py` -> (`pkg`, True)."""
21
- parts = rel_path.removesuffix(".py").split("/")
22
- package = parts[-1] == "__init__"
23
- if package:
24
- parts = parts[:-1]
25
- return ".".join(parts), package
26
-
27
-
28
- def _session_get(call: ast.Call) -> str | None:
29
- """SQLAlchemy `session.get(Task, task_id)` returns a Task, so `task.done = True` is a write to Task."""
30
- if not (isinstance(call.func, ast.Attribute) and call.func.attr in ("get", "get_one") and len(call.args) >= 2):
31
- return None
32
- name = dotted(call.args[0]) or ""
33
- last = name.rsplit(".", 1)[-1]
34
- return f"{name}()" if last[:1].isupper() and not last.isupper() else None
35
-
36
-
37
- def dotted(node: ast.AST | None) -> str | None:
38
- """A readable callee path: `self.repo.get`, `Order.objects.filter().update`, `db[orders].find`, `super().save`."""
39
- if node is None:
40
- return None
41
- if isinstance(node, ast.Name):
42
- return node.id
43
- if isinstance(node, ast.Attribute):
44
- base = dotted(node.value)
45
- return f"{base}.{node.attr}" if base else f"?.{node.attr}"
46
- if isinstance(node, ast.Call):
47
- base = dotted(node.func)
48
- return f"{base}()" if base else None
49
- if isinstance(node, ast.Subscript):
50
- base = dotted(node.value)
51
- if base is None:
52
- return None
53
- key = node.slice
54
- if isinstance(key, ast.Constant) and isinstance(key.value, str):
55
- return f"{base}[{key.value}]"
56
- return f"{base}[]"
57
- return None
58
-
59
-
60
- def value(node: ast.AST | None, depth: int = 0) -> Value | None:
61
- """Summarise an expression for detectors: literals stay literals, names stay names, calls keep their arguments."""
62
- if node is None:
63
- return None
64
- if depth > 4:
65
- return {"t": "expr", "v": _short(node)}
66
- if isinstance(node, ast.Constant):
67
- if isinstance(node.value, str | int | float | bool) or node.value is None:
68
- return {"t": "lit", "v": node.value}
69
- return {"t": "expr", "v": _short(node)}
70
- if isinstance(node, ast.Name | ast.Attribute):
71
- name = dotted(node)
72
- return {"t": "name", "v": name} if name else {"t": "expr", "v": _short(node)}
73
- if isinstance(node, ast.Call):
74
- return {
75
- "t": "call",
76
- "f": dotted(node.func) or _short(node.func),
77
- "args": [value(a, depth + 1) for a in node.args[:8]],
78
- "kw": {k.arg: value(k.value, depth + 1) for k in node.keywords if k.arg},
79
- "line": node.lineno,
80
- }
81
- if isinstance(node, ast.List | ast.Tuple | ast.Set):
82
- return {"t": "list", "v": [value(e, depth + 1) for e in node.elts[:MAX_LIST]]}
83
- if isinstance(node, ast.Dict):
84
- out: dict[str, Any] = {}
85
- for k, v in zip(node.keys, node.values, strict=False):
86
- if isinstance(k, ast.Constant) and isinstance(k.value, str):
87
- out[k.value] = value(v, depth + 1)
88
- return {"t": "dict", "v": out}
89
- if isinstance(node, ast.JoinedStr):
90
- parts = []
91
- for part in node.values:
92
- if isinstance(part, ast.Constant) and isinstance(part.value, str):
93
- parts.append(part.value)
94
- else:
95
- parts.append("{}")
96
- return {"t": "lit", "v": "".join(parts), "approx": True}
97
- if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
98
- left, right = value(node.left, depth + 1), value(node.right, depth + 1)
99
- if (
100
- left
101
- and right
102
- and left["t"] == right["t"] == "lit"
103
- and isinstance(left["v"], str)
104
- and isinstance(right["v"], str)
105
- ):
106
- return {"t": "lit", "v": left["v"] + right["v"]}
107
- return {"t": "concat", "v": [left, right]}
108
- if isinstance(node, ast.Starred):
109
- return {"t": "star", "v": value(node.value, depth + 1)}
110
- if isinstance(node, ast.Lambda):
111
- return {"t": "lambda"}
112
- return {"t": "expr", "v": _short(node)}
113
-
114
-
115
- def _short(node: ast.AST) -> str:
116
- try:
117
- text = ast.unparse(node)
118
- except Exception: # unparse can fail on odd nodes; a placeholder is fine for display
119
- return "…"
120
- return text if len(text) <= MAX_EXPR else text[: MAX_EXPR - 1] + "…"
121
-
122
-
123
- def _names(node: ast.AST | None) -> list[str]:
124
- """Dotted names referenced by an annotation: `Annotated[Session, Depends(get_db)]` -> Annotated, Session, ..."""
125
- if node is None:
126
- return []
127
- out: list[str] = []
128
- for sub in ast.walk(node):
129
- if isinstance(sub, ast.Name | ast.Attribute):
130
- name = dotted(sub)
131
- if name and not name.startswith("?") and name not in out:
132
- out.append(name)
133
- return out
134
-
135
-
136
- def _doc(node: ast.AST) -> str:
137
- try:
138
- doc = ast.get_docstring(node) # type: ignore[arg-type]
139
- except TypeError:
140
- return ""
141
- return doc.strip().split("\n")[0][:200] if doc else ""
142
-
143
-
144
- class _Visitor(ast.NodeVisitor):
145
- def __init__(self, module: str, package: bool) -> None:
146
- self.module = module
147
- self.package = package
148
- self.imports: list[dict[str, Any]] = []
149
- self.defs: list[dict[str, Any]] = []
150
- self.top_calls: list[dict[str, Any]] = []
151
- self.assigns: list[dict[str, Any]] = []
152
- self.consts: dict[str, Any] = {}
153
- self.stack: list[dict[str, Any]] = []
154
-
155
- # ---- scope helpers ----
156
-
157
- @property
158
- def scope(self) -> dict[str, Any] | None:
159
- return self.stack[-1] if self.stack else None
160
-
161
- def _enclosing_class(self) -> dict[str, Any] | None:
162
- return next((fact for fact in reversed(self.stack) if fact["kind"] == "class"), None)
163
-
164
- def _qual(self, name: str) -> str:
165
- return f"{self.stack[-1]['q']}.{name}" if self.stack else name
166
-
167
- # ---- imports ----
168
-
169
- def _absolute(self, module: str | None, level: int) -> str:
170
- if level == 0:
171
- return module or ""
172
- parts = self.module.split(".") if self.module else []
173
- keep = len(parts) - level + (1 if self.package else 0)
174
- base = parts[: max(keep, 0)]
175
- return ".".join([*base, module] if module else base)
176
-
177
- def visit_Import(self, node: ast.Import) -> None:
178
- for alias in node.names:
179
- local = alias.asname or alias.name.split(".")[0]
180
- target = alias.name if alias.asname else alias.name.split(".")[0]
181
- self.imports.append(
182
- {
183
- "local": local,
184
- "module": target,
185
- "name": None,
186
- "line": node.lineno,
187
- "full": alias.name,
188
- "lazy": bool(self.stack),
189
- }
190
- )
191
-
192
- def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
193
- module = self._absolute(node.module, node.level)
194
- for alias in node.names:
195
- if alias.name == "*":
196
- self.imports.append(
197
- {"local": "*", "module": module, "name": "*", "line": node.lineno, "lazy": bool(self.stack)}
198
- )
199
- continue
200
- self.imports.append(
201
- {
202
- "local": alias.asname or alias.name,
203
- "module": module,
204
- "name": alias.name,
205
- "line": node.lineno,
206
- "lazy": bool(self.stack),
207
- }
208
- )
209
-
210
- # ---- definitions ----
211
-
212
- def _function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
213
- cls = self.scope if self.scope and self.scope["kind"] == "class" else None
214
- args = []
215
- all_args = [*node.args.posonlyargs, *node.args.args]
216
- defaults: list[ast.expr | None] = [None] * (len(all_args) - len(node.args.defaults)) + list(node.args.defaults)
217
- pairs = list(zip(all_args, defaults, strict=False))
218
- pairs += list(zip(node.args.kwonlyargs, node.args.kw_defaults, strict=False))
219
- for arg, default in pairs:
220
- args.append(
221
- {
222
- "name": arg.arg,
223
- "ann": _names(arg.annotation),
224
- "default": value(default),
225
- "ann_value": value(arg.annotation) if arg.annotation is not None else None,
226
- }
227
- )
228
- fact: dict[str, Any] = {
229
- "q": self._qual(node.name),
230
- "kind": "method" if cls else "function",
231
- "name": node.name,
232
- "line": node.lineno,
233
- "end": node.end_lineno or node.lineno,
234
- "col": node.col_offset + 1,
235
- "parent": self.scope["q"] if self.scope else None,
236
- "async": isinstance(node, ast.AsyncFunctionDef),
237
- "doc": _doc(node),
238
- "decorators": [self._decorator(d) for d in node.decorator_list],
239
- "args": args,
240
- "returns": _names(node.returns),
241
- "calls": [],
242
- "locals": {},
243
- }
244
- for arg in all_args + node.args.kwonlyargs:
245
- if arg.annotation is not None:
246
- ann = dotted(arg.annotation)
247
- if ann:
248
- fact["locals"][arg.arg] = ann
249
- self.defs.append(fact)
250
- self.stack.append(fact)
251
- for stmt in node.body:
252
- self.visit(stmt)
253
- self.stack.pop()
254
-
255
- visit_FunctionDef = _function
256
- visit_AsyncFunctionDef = _function
257
-
258
- def visit_ClassDef(self, node: ast.ClassDef) -> None:
259
- fact: dict[str, Any] = {
260
- "q": self._qual(node.name),
261
- "kind": "class",
262
- "name": node.name,
263
- "line": node.lineno,
264
- "end": node.end_lineno or node.lineno,
265
- "col": node.col_offset + 1,
266
- "parent": self.scope["q"] if self.scope else None,
267
- "doc": _doc(node),
268
- "decorators": [self._decorator(d) for d in node.decorator_list],
269
- "bases": [b for b in (dotted(x) for x in node.bases) if b],
270
- "kw": {k.arg: value(k.value) for k in node.keywords if k.arg},
271
- "attrs": {},
272
- "self_attrs": {},
273
- "calls": [],
274
- }
275
- self.defs.append(fact)
276
- self.stack.append(fact)
277
- for stmt in node.body:
278
- self.visit(stmt)
279
- self.stack.pop()
280
-
281
- def _decorator(self, node: ast.expr) -> dict[str, Any]:
282
- if isinstance(node, ast.Call):
283
- return {
284
- "f": dotted(node.func) or _short(node.func),
285
- "args": [value(a) for a in node.args],
286
- "kw": {k.arg: value(k.value) for k in node.keywords if k.arg},
287
- "line": node.lineno,
288
- }
289
- return {"f": dotted(node) or _short(node), "args": [], "kw": {}, "line": node.lineno, "bare": True}
290
-
291
- # ---- calls ----
292
-
293
- def visit_Raise(self, node: ast.Raise) -> None:
294
- if isinstance(node.exc, ast.Call):
295
- self._raising = node.exc
296
- self.generic_visit(node)
297
- self._raising = None
298
-
299
- _raising: ast.Call | None = None
300
-
301
- def visit_Call(self, node: ast.Call) -> None:
302
- spec: dict[str, Any] = {
303
- "f": dotted(node.func) or "?",
304
- "line": node.lineno,
305
- "col": node.col_offset + 1,
306
- "args": [value(a, 2) for a in node.args[:6]],
307
- "kw": {k.arg: value(k.value, 2) for k in node.keywords if k.arg},
308
- }
309
- if node is self._raising:
310
- spec["raise"] = True
311
- if self.scope is None:
312
- self.top_calls.append(spec)
313
- else:
314
- self.scope["calls"].append(spec)
315
- self.generic_visit(node)
316
-
317
- # ---- assignments ----
318
-
319
- def _record_assign(
320
- self, target: ast.expr, val: ast.expr | None, line: int, aug: bool = False, annotation: ast.expr | None = None
321
- ) -> None:
322
- scope = self.scope
323
- if scope is None:
324
- if isinstance(target, ast.Name):
325
- summary = value(val) if val is not None else None
326
- self.assigns.append({"name": target.id, "line": line, "value": summary, "aug": aug})
327
- if summary and summary["t"] == "lit" and not aug:
328
- self.consts[target.id] = summary["v"]
329
- return
330
- if scope["kind"] == "class":
331
- if isinstance(target, ast.Name):
332
- scope["attrs"][target.id] = value(val) if val is not None else None
333
- if annotation is not None and (ann := dotted(annotation)):
334
- scope["self_attrs"].setdefault(target.id, ann)
335
- elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
336
- scope["self_attrs"].setdefault(target.id, f"{callee}()")
337
- return
338
- # inside a function
339
- if isinstance(target, ast.Name):
340
- hint = None
341
- if annotation is not None:
342
- hint = dotted(annotation)
343
- elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
344
- hint = _session_get(val) or f"{callee}()"
345
- if hint:
346
- scope["locals"][target.id] = hint
347
- elif isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id != "self":
348
- scope.setdefault("sets", []).append({"f": dotted(target), "line": line})
349
- elif isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self":
350
- cls = self._enclosing_class()
351
- if cls is None:
352
- return
353
- hint = None
354
- if annotation is not None:
355
- hint = dotted(annotation)
356
- elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
357
- hint = f"{callee}()"
358
- if hint:
359
- cls["self_attrs"].setdefault(target.attr, hint)
360
-
361
- def visit_Assign(self, node: ast.Assign) -> None:
362
- for target in node.targets:
363
- self._record_assign(target, node.value, node.lineno)
364
- self.visit(node.value)
365
-
366
- def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
367
- self._record_assign(node.target, node.value, node.lineno, annotation=node.annotation)
368
- if node.value is not None:
369
- self.visit(node.value)
370
-
371
- def visit_AugAssign(self, node: ast.AugAssign) -> None:
372
- if isinstance(node.op, ast.Add):
373
- self._record_assign(node.target, node.value, node.lineno, aug=True)
374
- self.visit(node.value)
375
-
376
- def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
377
- scope = self.scope
378
- for item in node.items:
379
- if (
380
- scope is not None
381
- and scope["kind"] != "class"
382
- and isinstance(item.optional_vars, ast.Name)
383
- and isinstance(item.context_expr, ast.Call)
384
- and (callee := dotted(item.context_expr.func))
385
- ):
386
- scope["locals"][item.optional_vars.id] = f"{callee}()"
387
- self.generic_visit(node)
388
-
389
- visit_AsyncWith = visit_With
390
-
391
- def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
392
- scope = self.scope
393
- if (
394
- scope is not None
395
- and scope["kind"] != "class"
396
- and isinstance(node.target, ast.Name)
397
- and isinstance(node.iter, ast.Call)
398
- and (callee := dotted(node.iter.func))
399
- ):
400
- scope["locals"][node.target.id] = f"{callee}()[]"
401
- # `for r in (a, b, c): api.add_router("", r)` registers a, b and c. Unroll short loops over literal
402
- # names so the calls in the body carry the real arguments.
403
- if (
404
- isinstance(node.target, ast.Name)
405
- and isinstance(node.iter, ast.Tuple | ast.List)
406
- and 0 < len(node.iter.elts) <= 50
407
- and all(isinstance(e, ast.Name | ast.Attribute | ast.Constant) for e in node.iter.elts)
408
- ):
409
- for element in node.iter.elts:
410
- for stmt in node.body:
411
- self.visit(_Substitute(node.target.id, element).visit(copy.deepcopy(stmt)))
412
- for stmt in node.orelse:
413
- self.visit(stmt)
414
- return
415
- self.generic_visit(node)
416
-
417
- visit_AsyncFor = visit_For
418
-
419
-
420
- class _Substitute(ast.NodeTransformer):
421
- """Replace loads of one name with an expression, keeping line numbers of the original statement."""
422
-
423
- def __init__(self, name: str, replacement: ast.expr) -> None:
424
- self.name = name
425
- self.replacement = replacement
426
-
427
- def visit_Name(self, node: ast.Name) -> ast.AST:
428
- if node.id == self.name and isinstance(node.ctx, ast.Load):
429
- return ast.copy_location(copy.deepcopy(self.replacement), node)
430
- return node
431
-
432
-
433
- _BARE_EXCEPT_TUPLE = re.compile(r"^(\s*except\*?\s+)(?!\()([^:\n#]*,[^:\n#]*?)(\s*:)", re.MULTILINE)
434
-
435
-
436
- def _parse(text: str, file: str) -> ast.Module:
437
- """Parse with the running interpreter. Code written for Python 3.14 may use `except A, B:` (PEP 758), which
438
- older interpreters reject; that one construct is rewritten in place, keeping every line number."""
439
- try:
440
- return ast.parse(text, filename=file)
441
- except SyntaxError as first:
442
- if "parenthesized" not in str(first.msg):
443
- raise
444
-
445
- def wrap(m: re.Match[str]) -> str:
446
- body = m.group(2)
447
- if " as " in body:
448
- return m.group(0)
449
- return f"{m.group(1)[:-1]}({body}){m.group(3)}" if m.group(1).endswith(" ") else m.group(0)
450
-
451
- rewritten = _BARE_EXCEPT_TUPLE.sub(wrap, text)
452
- if rewritten == text:
453
- raise
454
- return ast.parse(rewritten, filename=file)
455
-
456
-
457
- def extract(text: str, rel_path: str, file: str) -> dict[str, Any]:
458
- """Facts for one file. `rel_path` is relative to the source root (gives the module name); `file` is relative to
459
- the project root (used in the model)."""
460
- module, package = module_name(rel_path)
461
- facts: dict[str, Any] = {
462
- "lang": "python",
463
- "file": file,
464
- "module": module,
465
- "package": package,
466
- "lines": text.count("\n") + 1,
467
- "doc": "",
468
- "imports": [],
469
- "defs": [],
470
- "calls": [],
471
- "assigns": [],
472
- "consts": {},
473
- "errors": [],
474
- }
475
- try:
476
- tree = _parse(text, file)
477
- except SyntaxError as e:
478
- facts["errors"].append({"line": e.lineno or 0, "message": f"cannot parse: {e.msg}"})
479
- return facts
480
- except ValueError as e: # null bytes and similar
481
- facts["errors"].append({"line": 0, "message": f"cannot parse: {e}"})
482
- return facts
483
- visitor = _Visitor(module, package)
484
- for stmt in tree.body:
485
- visitor.visit(stmt)
486
- facts.update(
487
- doc=_doc(tree),
488
- imports=visitor.imports,
489
- defs=visitor.defs,
490
- calls=visitor.top_calls,
491
- assigns=visitor.assigns,
492
- consts=visitor.consts,
493
- )
494
- return facts
1
+ """Facts from one Python file (design §7.2). Pure: text in, JSON-ready dict out. Never raises on bad code.
2
+
3
+ The facts are deliberately framework-neutral. Decorators, module-level assignments and calls are recorded with
4
+ their arguments summarised as values, and detectors interpret them later with the whole project in view.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import copy
11
+ import re
12
+ from typing import Any
13
+
14
+ Value = dict[str, Any]
15
+ MAX_LIST = 300
16
+ MAX_EXPR = 120
17
+
18
+
19
+ def module_name(rel_path: str) -> tuple[str, bool]:
20
+ """`shop/orders/service.py` -> (`shop.orders.service`, False); `pkg/__init__.py` -> (`pkg`, True)."""
21
+ parts = rel_path.removesuffix(".py").split("/")
22
+ package = parts[-1] == "__init__"
23
+ if package:
24
+ parts = parts[:-1]
25
+ return ".".join(parts), package
26
+
27
+
28
+ def _session_get(call: ast.Call) -> str | None:
29
+ """SQLAlchemy `session.get(Task, task_id)` returns a Task, so `task.done = True` is a write to Task."""
30
+ if not (isinstance(call.func, ast.Attribute) and call.func.attr in ("get", "get_one") and len(call.args) >= 2):
31
+ return None
32
+ name = dotted(call.args[0]) or ""
33
+ last = name.rsplit(".", 1)[-1]
34
+ return f"{name}()" if last[:1].isupper() and not last.isupper() else None
35
+
36
+
37
+ def dotted(node: ast.AST | None) -> str | None:
38
+ """A readable callee path: `self.repo.get`, `Order.objects.filter().update`, `db[orders].find`, `super().save`."""
39
+ if node is None:
40
+ return None
41
+ if isinstance(node, ast.Name):
42
+ return node.id
43
+ if isinstance(node, ast.Attribute):
44
+ base = dotted(node.value)
45
+ return f"{base}.{node.attr}" if base else f"?.{node.attr}"
46
+ if isinstance(node, ast.Call):
47
+ base = dotted(node.func)
48
+ return f"{base}()" if base else None
49
+ if isinstance(node, ast.Subscript):
50
+ base = dotted(node.value)
51
+ if base is None:
52
+ return None
53
+ key = node.slice
54
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
55
+ return f"{base}[{key.value}]"
56
+ return f"{base}[]"
57
+ return None
58
+
59
+
60
+ def value(node: ast.AST | None, depth: int = 0) -> Value | None:
61
+ """Summarise an expression for detectors: literals stay literals, names stay names, calls keep their arguments."""
62
+ if node is None:
63
+ return None
64
+ if depth > 4:
65
+ return {"t": "expr", "v": _short(node)}
66
+ if isinstance(node, ast.Constant):
67
+ if isinstance(node.value, str | int | float | bool) or node.value is None:
68
+ return {"t": "lit", "v": node.value}
69
+ return {"t": "expr", "v": _short(node)}
70
+ if isinstance(node, ast.Name | ast.Attribute):
71
+ name = dotted(node)
72
+ return {"t": "name", "v": name} if name else {"t": "expr", "v": _short(node)}
73
+ if isinstance(node, ast.Call):
74
+ return {
75
+ "t": "call",
76
+ "f": dotted(node.func) or _short(node.func),
77
+ "args": [value(a, depth + 1) for a in node.args[:8]],
78
+ "kw": {k.arg: value(k.value, depth + 1) for k in node.keywords if k.arg},
79
+ "line": node.lineno,
80
+ }
81
+ if isinstance(node, ast.List | ast.Tuple | ast.Set):
82
+ return {"t": "list", "v": [value(e, depth + 1) for e in node.elts[:MAX_LIST]]}
83
+ if isinstance(node, ast.Dict):
84
+ out: dict[str, Any] = {}
85
+ for k, v in zip(node.keys, node.values, strict=False):
86
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
87
+ out[k.value] = value(v, depth + 1)
88
+ return {"t": "dict", "v": out}
89
+ if isinstance(node, ast.JoinedStr):
90
+ parts = []
91
+ for part in node.values:
92
+ if isinstance(part, ast.Constant) and isinstance(part.value, str):
93
+ parts.append(part.value)
94
+ else:
95
+ parts.append("{}")
96
+ return {"t": "lit", "v": "".join(parts), "approx": True}
97
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
98
+ left, right = value(node.left, depth + 1), value(node.right, depth + 1)
99
+ if (
100
+ left
101
+ and right
102
+ and left["t"] == right["t"] == "lit"
103
+ and isinstance(left["v"], str)
104
+ and isinstance(right["v"], str)
105
+ ):
106
+ return {"t": "lit", "v": left["v"] + right["v"]}
107
+ return {"t": "concat", "v": [left, right]}
108
+ if isinstance(node, ast.Starred):
109
+ return {"t": "star", "v": value(node.value, depth + 1)}
110
+ if isinstance(node, ast.Lambda):
111
+ return {"t": "lambda"}
112
+ return {"t": "expr", "v": _short(node)}
113
+
114
+
115
+ def _short(node: ast.AST) -> str:
116
+ try:
117
+ text = ast.unparse(node)
118
+ except Exception: # unparse can fail on odd nodes; a placeholder is fine for display
119
+ return "…"
120
+ return text if len(text) <= MAX_EXPR else text[: MAX_EXPR - 1] + "…"
121
+
122
+
123
+ def _names(node: ast.AST | None) -> list[str]:
124
+ """Dotted names referenced by an annotation: `Annotated[Session, Depends(get_db)]` -> Annotated, Session, ..."""
125
+ if node is None:
126
+ return []
127
+ out: list[str] = []
128
+ for sub in ast.walk(node):
129
+ if isinstance(sub, ast.Name | ast.Attribute):
130
+ name = dotted(sub)
131
+ if name and not name.startswith("?") and name not in out:
132
+ out.append(name)
133
+ return out
134
+
135
+
136
+ def _doc(node: ast.AST) -> str:
137
+ try:
138
+ doc = ast.get_docstring(node) # type: ignore[arg-type]
139
+ except TypeError:
140
+ return ""
141
+ return doc.strip().split("\n")[0][:200] if doc else ""
142
+
143
+
144
+ class _Visitor(ast.NodeVisitor):
145
+ def __init__(self, module: str, package: bool) -> None:
146
+ self.module = module
147
+ self.package = package
148
+ self.imports: list[dict[str, Any]] = []
149
+ self.defs: list[dict[str, Any]] = []
150
+ self.top_calls: list[dict[str, Any]] = []
151
+ self.assigns: list[dict[str, Any]] = []
152
+ self.consts: dict[str, Any] = {}
153
+ self.stack: list[dict[str, Any]] = []
154
+
155
+ # ---- scope helpers ----
156
+
157
+ @property
158
+ def scope(self) -> dict[str, Any] | None:
159
+ return self.stack[-1] if self.stack else None
160
+
161
+ def _enclosing_class(self) -> dict[str, Any] | None:
162
+ return next((fact for fact in reversed(self.stack) if fact["kind"] == "class"), None)
163
+
164
+ def _qual(self, name: str) -> str:
165
+ return f"{self.stack[-1]['q']}.{name}" if self.stack else name
166
+
167
+ # ---- imports ----
168
+
169
+ def _absolute(self, module: str | None, level: int) -> str:
170
+ if level == 0:
171
+ return module or ""
172
+ parts = self.module.split(".") if self.module else []
173
+ keep = len(parts) - level + (1 if self.package else 0)
174
+ base = parts[: max(keep, 0)]
175
+ return ".".join([*base, module] if module else base)
176
+
177
+ def visit_Import(self, node: ast.Import) -> None:
178
+ for alias in node.names:
179
+ local = alias.asname or alias.name.split(".")[0]
180
+ target = alias.name if alias.asname else alias.name.split(".")[0]
181
+ self.imports.append(
182
+ {
183
+ "local": local,
184
+ "module": target,
185
+ "name": None,
186
+ "line": node.lineno,
187
+ "full": alias.name,
188
+ "lazy": bool(self.stack),
189
+ }
190
+ )
191
+
192
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
193
+ module = self._absolute(node.module, node.level)
194
+ for alias in node.names:
195
+ if alias.name == "*":
196
+ self.imports.append(
197
+ {"local": "*", "module": module, "name": "*", "line": node.lineno, "lazy": bool(self.stack)}
198
+ )
199
+ continue
200
+ self.imports.append(
201
+ {
202
+ "local": alias.asname or alias.name,
203
+ "module": module,
204
+ "name": alias.name,
205
+ "line": node.lineno,
206
+ "lazy": bool(self.stack),
207
+ }
208
+ )
209
+
210
+ # ---- definitions ----
211
+
212
+ def _function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
213
+ cls = self.scope if self.scope and self.scope["kind"] == "class" else None
214
+ args = []
215
+ all_args = [*node.args.posonlyargs, *node.args.args]
216
+ defaults: list[ast.expr | None] = [None] * (len(all_args) - len(node.args.defaults)) + list(node.args.defaults)
217
+ pairs = list(zip(all_args, defaults, strict=False))
218
+ pairs += list(zip(node.args.kwonlyargs, node.args.kw_defaults, strict=False))
219
+ for arg, default in pairs:
220
+ args.append(
221
+ {
222
+ "name": arg.arg,
223
+ "ann": _names(arg.annotation),
224
+ "default": value(default),
225
+ "ann_value": value(arg.annotation) if arg.annotation is not None else None,
226
+ }
227
+ )
228
+ fact: dict[str, Any] = {
229
+ "q": self._qual(node.name),
230
+ "kind": "method" if cls else "function",
231
+ "name": node.name,
232
+ "line": node.lineno,
233
+ "end": node.end_lineno or node.lineno,
234
+ "col": node.col_offset + 1,
235
+ "parent": self.scope["q"] if self.scope else None,
236
+ "async": isinstance(node, ast.AsyncFunctionDef),
237
+ "doc": _doc(node),
238
+ "decorators": [self._decorator(d) for d in node.decorator_list],
239
+ "args": args,
240
+ "returns": _names(node.returns),
241
+ "calls": [],
242
+ "locals": {},
243
+ }
244
+ for arg in all_args + node.args.kwonlyargs:
245
+ if arg.annotation is not None:
246
+ ann = dotted(arg.annotation)
247
+ if ann:
248
+ fact["locals"][arg.arg] = ann
249
+ self.defs.append(fact)
250
+ self.stack.append(fact)
251
+ for stmt in node.body:
252
+ self.visit(stmt)
253
+ self.stack.pop()
254
+
255
+ visit_FunctionDef = _function
256
+ visit_AsyncFunctionDef = _function
257
+
258
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
259
+ fact: dict[str, Any] = {
260
+ "q": self._qual(node.name),
261
+ "kind": "class",
262
+ "name": node.name,
263
+ "line": node.lineno,
264
+ "end": node.end_lineno or node.lineno,
265
+ "col": node.col_offset + 1,
266
+ "parent": self.scope["q"] if self.scope else None,
267
+ "doc": _doc(node),
268
+ "decorators": [self._decorator(d) for d in node.decorator_list],
269
+ "bases": [b for b in (dotted(x) for x in node.bases) if b],
270
+ "kw": {k.arg: value(k.value) for k in node.keywords if k.arg},
271
+ "attrs": {},
272
+ "self_attrs": {},
273
+ "calls": [],
274
+ }
275
+ self.defs.append(fact)
276
+ self.stack.append(fact)
277
+ for stmt in node.body:
278
+ self.visit(stmt)
279
+ self.stack.pop()
280
+
281
+ def _decorator(self, node: ast.expr) -> dict[str, Any]:
282
+ if isinstance(node, ast.Call):
283
+ return {
284
+ "f": dotted(node.func) or _short(node.func),
285
+ "args": [value(a) for a in node.args],
286
+ "kw": {k.arg: value(k.value) for k in node.keywords if k.arg},
287
+ "line": node.lineno,
288
+ }
289
+ return {"f": dotted(node) or _short(node), "args": [], "kw": {}, "line": node.lineno, "bare": True}
290
+
291
+ # ---- calls ----
292
+
293
+ def visit_Raise(self, node: ast.Raise) -> None:
294
+ if isinstance(node.exc, ast.Call):
295
+ self._raising = node.exc
296
+ self.generic_visit(node)
297
+ self._raising = None
298
+
299
+ _raising: ast.Call | None = None
300
+
301
+ def visit_Call(self, node: ast.Call) -> None:
302
+ spec: dict[str, Any] = {
303
+ "f": dotted(node.func) or "?",
304
+ "line": node.lineno,
305
+ "col": node.col_offset + 1,
306
+ "args": [value(a, 2) for a in node.args[:6]],
307
+ "kw": {k.arg: value(k.value, 2) for k in node.keywords if k.arg},
308
+ }
309
+ if node is self._raising:
310
+ spec["raise"] = True
311
+ if self.scope is None:
312
+ self.top_calls.append(spec)
313
+ else:
314
+ self.scope["calls"].append(spec)
315
+ self.generic_visit(node)
316
+
317
+ # ---- assignments ----
318
+
319
+ def _record_assign(
320
+ self, target: ast.expr, val: ast.expr | None, line: int, aug: bool = False, annotation: ast.expr | None = None
321
+ ) -> None:
322
+ scope = self.scope
323
+ if scope is None:
324
+ if isinstance(target, ast.Name):
325
+ summary = value(val) if val is not None else None
326
+ self.assigns.append({"name": target.id, "line": line, "value": summary, "aug": aug})
327
+ if summary and summary["t"] == "lit" and not aug:
328
+ self.consts[target.id] = summary["v"]
329
+ return
330
+ if scope["kind"] == "class":
331
+ if isinstance(target, ast.Name):
332
+ scope["attrs"][target.id] = value(val) if val is not None else None
333
+ if annotation is not None and (ann := dotted(annotation)):
334
+ scope["self_attrs"].setdefault(target.id, ann)
335
+ elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
336
+ scope["self_attrs"].setdefault(target.id, f"{callee}()")
337
+ return
338
+ # inside a function
339
+ if isinstance(target, ast.Name):
340
+ hint = None
341
+ if annotation is not None:
342
+ hint = dotted(annotation)
343
+ elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
344
+ hint = _session_get(val) or f"{callee}()"
345
+ if hint:
346
+ scope["locals"][target.id] = hint
347
+ elif isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id != "self":
348
+ scope.setdefault("sets", []).append({"f": dotted(target), "line": line})
349
+ elif isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self":
350
+ cls = self._enclosing_class()
351
+ if cls is None:
352
+ return
353
+ hint = None
354
+ if annotation is not None:
355
+ hint = dotted(annotation)
356
+ elif isinstance(val, ast.Call) and (callee := dotted(val.func)):
357
+ hint = f"{callee}()"
358
+ if hint:
359
+ cls["self_attrs"].setdefault(target.attr, hint)
360
+
361
+ def visit_Assign(self, node: ast.Assign) -> None:
362
+ for target in node.targets:
363
+ self._record_assign(target, node.value, node.lineno)
364
+ self.visit(node.value)
365
+
366
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
367
+ self._record_assign(node.target, node.value, node.lineno, annotation=node.annotation)
368
+ if node.value is not None:
369
+ self.visit(node.value)
370
+
371
+ def visit_AugAssign(self, node: ast.AugAssign) -> None:
372
+ if isinstance(node.op, ast.Add):
373
+ self._record_assign(node.target, node.value, node.lineno, aug=True)
374
+ self.visit(node.value)
375
+
376
+ def visit_With(self, node: ast.With | ast.AsyncWith) -> None:
377
+ scope = self.scope
378
+ for item in node.items:
379
+ if (
380
+ scope is not None
381
+ and scope["kind"] != "class"
382
+ and isinstance(item.optional_vars, ast.Name)
383
+ and isinstance(item.context_expr, ast.Call)
384
+ and (callee := dotted(item.context_expr.func))
385
+ ):
386
+ scope["locals"][item.optional_vars.id] = f"{callee}()"
387
+ self.generic_visit(node)
388
+
389
+ visit_AsyncWith = visit_With
390
+
391
+ def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
392
+ scope = self.scope
393
+ if (
394
+ scope is not None
395
+ and scope["kind"] != "class"
396
+ and isinstance(node.target, ast.Name)
397
+ and isinstance(node.iter, ast.Call)
398
+ and (callee := dotted(node.iter.func))
399
+ ):
400
+ scope["locals"][node.target.id] = f"{callee}()[]"
401
+ # `for r in (a, b, c): api.add_router("", r)` registers a, b and c. Unroll short loops over literal
402
+ # names so the calls in the body carry the real arguments.
403
+ if (
404
+ isinstance(node.target, ast.Name)
405
+ and isinstance(node.iter, ast.Tuple | ast.List)
406
+ and 0 < len(node.iter.elts) <= 50
407
+ and all(isinstance(e, ast.Name | ast.Attribute | ast.Constant) for e in node.iter.elts)
408
+ ):
409
+ for element in node.iter.elts:
410
+ for stmt in node.body:
411
+ self.visit(_Substitute(node.target.id, element).visit(copy.deepcopy(stmt)))
412
+ for stmt in node.orelse:
413
+ self.visit(stmt)
414
+ return
415
+ self.generic_visit(node)
416
+
417
+ visit_AsyncFor = visit_For
418
+
419
+
420
+ class _Substitute(ast.NodeTransformer):
421
+ """Replace loads of one name with an expression, keeping line numbers of the original statement."""
422
+
423
+ def __init__(self, name: str, replacement: ast.expr) -> None:
424
+ self.name = name
425
+ self.replacement = replacement
426
+
427
+ def visit_Name(self, node: ast.Name) -> ast.AST:
428
+ if node.id == self.name and isinstance(node.ctx, ast.Load):
429
+ return ast.copy_location(copy.deepcopy(self.replacement), node)
430
+ return node
431
+
432
+
433
+ _BARE_EXCEPT_TUPLE = re.compile(r"^(\s*except\*?\s+)(?!\()([^:\n#]*,[^:\n#]*?)(\s*:)", re.MULTILINE)
434
+
435
+
436
+ def _parse(text: str, file: str) -> ast.Module:
437
+ """Parse with the running interpreter. Code written for Python 3.14 may use `except A, B:` (PEP 758), which
438
+ older interpreters reject; that one construct is rewritten in place, keeping every line number."""
439
+ try:
440
+ return ast.parse(text, filename=file)
441
+ except SyntaxError as first:
442
+ if "parenthesized" not in str(first.msg):
443
+ raise
444
+
445
+ def wrap(m: re.Match[str]) -> str:
446
+ body = m.group(2)
447
+ if " as " in body:
448
+ return m.group(0)
449
+ return f"{m.group(1)[:-1]}({body}){m.group(3)}" if m.group(1).endswith(" ") else m.group(0)
450
+
451
+ rewritten = _BARE_EXCEPT_TUPLE.sub(wrap, text)
452
+ if rewritten == text:
453
+ raise
454
+ return ast.parse(rewritten, filename=file)
455
+
456
+
457
+ def extract(text: str, rel_path: str, file: str) -> dict[str, Any]:
458
+ """Facts for one file. `rel_path` is relative to the source root (gives the module name); `file` is relative to
459
+ the project root (used in the model)."""
460
+ module, package = module_name(rel_path)
461
+ facts: dict[str, Any] = {
462
+ "lang": "python",
463
+ "file": file,
464
+ "module": module,
465
+ "package": package,
466
+ "lines": text.count("\n") + 1,
467
+ "doc": "",
468
+ "imports": [],
469
+ "defs": [],
470
+ "calls": [],
471
+ "assigns": [],
472
+ "consts": {},
473
+ "errors": [],
474
+ }
475
+ try:
476
+ tree = _parse(text, file)
477
+ except SyntaxError as e:
478
+ facts["errors"].append({"line": e.lineno or 0, "message": f"cannot parse: {e.msg}"})
479
+ return facts
480
+ except ValueError as e: # null bytes and similar
481
+ facts["errors"].append({"line": 0, "message": f"cannot parse: {e}"})
482
+ return facts
483
+ visitor = _Visitor(module, package)
484
+ for stmt in tree.body:
485
+ visitor.visit(stmt)
486
+ facts.update(
487
+ doc=_doc(tree),
488
+ imports=visitor.imports,
489
+ defs=visitor.defs,
490
+ calls=visitor.top_calls,
491
+ assigns=visitor.assigns,
492
+ consts=visitor.consts,
493
+ )
494
+ return facts