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.
- stackthread/__init__.py +1 -1
- stackthread/lang/python/extract.py +494 -494
- stackthread/lineage.py +965 -965
- stackthread/static/app.js +1 -1
- stackthread-0.1.0.dev3.dist-info/METADATA +532 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/RECORD +9 -9
- stackthread-0.1.0.dev2.dist-info/METADATA +0 -51
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/WHEEL +0 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/entry_points.txt +0 -0
- {stackthread-0.1.0.dev2.dist-info → stackthread-0.1.0.dev3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
"""`
|
|
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
|