blasttrace 1.2.0__py2.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.

Potentially problematic release.


This version of blasttrace might be problematic. Click here for more details.

@@ -0,0 +1,340 @@
1
+ """
2
+ BlastTrace Python AST Parser
3
+ Extracts function/method signatures, parameter defaults, return types, class fields,
4
+ ORM/SQL references, and cross-module call edges for the Deterministic Dependency Reachability Engine.
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import re
10
+ from typing import Dict, Any, List, Optional
11
+
12
+
13
+ def _unparse_safe(node: Optional[ast.AST]) -> str:
14
+ if node is None:
15
+ return ""
16
+ try:
17
+ return ast.unparse(node)
18
+ except Exception:
19
+ return ""
20
+
21
+
22
+ def _extract_function_signature(fn_node: ast.AST) -> Dict[str, Any]:
23
+ args_obj = getattr(fn_node, "args", None)
24
+ params: List[Dict[str, Any]] = []
25
+ if args_obj:
26
+ pos_args = list(getattr(args_obj, "posonlyargs", [])) + list(args_obj.args)
27
+ defaults = list(args_obj.defaults)
28
+ num_no_default = len(pos_args) - len(defaults)
29
+
30
+ for idx, arg in enumerate(pos_args):
31
+ if arg.arg in ("self", "cls"):
32
+ continue
33
+ has_def = idx >= num_no_default
34
+ def_node = defaults[idx - num_no_default] if has_def else None
35
+ params.append({
36
+ "name": arg.arg,
37
+ "annotation": _unparse_safe(arg.annotation),
38
+ "required": not has_def,
39
+ "default": _unparse_safe(def_node) if has_def else None,
40
+ "kind": "positional_or_keyword",
41
+ })
42
+
43
+ if args_obj.vararg:
44
+ params.append({
45
+ "name": f"*{args_obj.vararg.arg}",
46
+ "annotation": _unparse_safe(args_obj.vararg.annotation),
47
+ "required": False,
48
+ "default": None,
49
+ "kind": "vararg",
50
+ })
51
+
52
+ kw_defaults = list(args_obj.kw_defaults)
53
+ for idx, kwarg in enumerate(args_obj.kwonlyargs):
54
+ def_node = kw_defaults[idx] if idx < len(kw_defaults) else None
55
+ has_def = def_node is not None
56
+ params.append({
57
+ "name": kwarg.arg,
58
+ "annotation": _unparse_safe(kwarg.annotation),
59
+ "required": not has_def,
60
+ "default": _unparse_safe(def_node) if has_def else None,
61
+ "kind": "keyword_only",
62
+ })
63
+
64
+ if args_obj.kwarg:
65
+ params.append({
66
+ "name": f"**{args_obj.kwarg.arg}",
67
+ "annotation": _unparse_safe(args_obj.kwarg.annotation),
68
+ "required": False,
69
+ "default": None,
70
+ "kind": "kwarg",
71
+ })
72
+
73
+ return_type = _unparse_safe(getattr(fn_node, "returns", None))
74
+ decorators = [_unparse_safe(d) for d in getattr(fn_node, "decorator_list", [])]
75
+
76
+ sig_parts = []
77
+ for p in params:
78
+ part = p["name"]
79
+ if p["annotation"]:
80
+ part += f": {p['annotation']}"
81
+ if not p["required"] and p["default"] is not None:
82
+ part += f" = {p['default']}"
83
+ sig_parts.append(part)
84
+ sig_str = f"def {getattr(fn_node, 'name', 'unknown')}({', '.join(sig_parts)})"
85
+ if return_type:
86
+ sig_str += f" -> {return_type}"
87
+
88
+ return {
89
+ "name": getattr(fn_node, "name", ""),
90
+ "is_async": isinstance(fn_node, ast.AsyncFunctionDef),
91
+ "params": params,
92
+ "return_type": return_type,
93
+ "decorators": decorators,
94
+ "signature_string": sig_str,
95
+ "lineno": getattr(fn_node, "lineno", 1),
96
+ }
97
+
98
+
99
+ def _inspect_function_body(fn_node: ast.AST) -> Dict[str, Any]:
100
+ calls: List[str] = []
101
+ attributes: List[str] = []
102
+ returned_keys: List[str] = []
103
+ string_literals: List[str] = []
104
+
105
+ for sub in ast.walk(fn_node):
106
+ if isinstance(sub, ast.Call):
107
+ if isinstance(sub.func, ast.Name):
108
+ calls.append(sub.func.id)
109
+ elif isinstance(sub.func, ast.Attribute):
110
+ owner = _unparse_safe(sub.func.value)
111
+ calls.append(f"{owner}.{sub.func.attr}" if owner else sub.func.attr)
112
+ calls.append(sub.func.attr)
113
+ elif isinstance(sub, ast.Attribute):
114
+ owner = _unparse_safe(sub.value)
115
+ attributes.append(f"{owner}.{sub.attr}" if owner else sub.attr)
116
+ attributes.append(sub.attr)
117
+ elif isinstance(sub, ast.Dict):
118
+ for k in sub.keys:
119
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
120
+ returned_keys.append(k.value)
121
+ elif isinstance(sub, ast.Constant) and isinstance(sub.value, str):
122
+ if len(sub.value) > 2:
123
+ string_literals.append(sub.value)
124
+
125
+ return {
126
+ "calls": list(dict.fromkeys(calls)),
127
+ "attributes": list(dict.fromkeys(attributes)),
128
+ "dict_keys": list(dict.fromkeys(returned_keys)),
129
+ "string_literals": string_literals,
130
+ }
131
+
132
+
133
+ def parse_python_content(content: str, rel_file: str) -> Dict[str, Any]:
134
+ functions: Dict[str, Dict[str, Any]] = {}
135
+ classes: Dict[str, Dict[str, Any]] = {}
136
+ imports: List[Dict[str, str]] = []
137
+ nodes: List[Dict[str, Any]] = []
138
+ edges: List[Dict[str, str]] = []
139
+
140
+ module_node_id = f"py:{rel_file}"
141
+ nodes.append({
142
+ "id": module_node_id,
143
+ "type": "python_module",
144
+ "name": rel_file,
145
+ "file": rel_file,
146
+ "layer": "backend",
147
+ })
148
+
149
+ try:
150
+ tree = ast.parse(content)
151
+ except SyntaxError as exc:
152
+ return {
153
+ "error": f"SyntaxError in {rel_file}: {exc}",
154
+ "functions": {},
155
+ "classes": {},
156
+ "imports": [],
157
+ "nodes": nodes,
158
+ "edges": edges,
159
+ }
160
+
161
+ for node in tree.body:
162
+ if isinstance(node, ast.Import):
163
+ for alias in node.names:
164
+ imports.append({
165
+ "module": alias.name,
166
+ "symbol": alias.asname or alias.name,
167
+ "raw": f"import {alias.name}",
168
+ })
169
+ elif isinstance(node, ast.ImportFrom):
170
+ mod = node.module or ""
171
+ for alias in node.names:
172
+ imports.append({
173
+ "module": mod,
174
+ "symbol": alias.name,
175
+ "alias": alias.asname or alias.name,
176
+ "raw": f"from {mod} import {alias.name}",
177
+ })
178
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
179
+ sig = _extract_function_signature(node)
180
+ body_info = _inspect_function_body(node)
181
+ sig.update(body_info)
182
+ sig["file"] = rel_file
183
+ fn_id = f"py:{rel_file}:{node.name}"
184
+ functions[node.name] = sig
185
+ nodes.append({
186
+ "id": fn_id,
187
+ "type": "python_function",
188
+ "name": node.name,
189
+ "qualified_name": f"{rel_file}:{node.name}",
190
+ "signature": sig["signature_string"],
191
+ "params": sig["params"],
192
+ "return_type": sig["return_type"],
193
+ "file": rel_file,
194
+ "lineno": sig["lineno"],
195
+ "layer": "backend",
196
+ })
197
+ edges.append({
198
+ "source": module_node_id,
199
+ "target": fn_id,
200
+ "relation": "defines_function",
201
+ })
202
+
203
+ elif isinstance(node, ast.ClassDef):
204
+ bases = [_unparse_safe(b) for b in node.bases]
205
+ cls_fields: Dict[str, Dict[str, Any]] = {}
206
+ cls_methods: Dict[str, Dict[str, Any]] = {}
207
+ tablename = None
208
+
209
+ for item in node.body:
210
+ if isinstance(item, ast.Assign):
211
+ for target in item.targets:
212
+ if isinstance(target, ast.Name):
213
+ val_str = _unparse_safe(item.value)
214
+ if target.id == "__tablename__":
215
+ tablename = val_str.strip("\"'")
216
+ elif not target.id.startswith("__"):
217
+ cls_fields[target.id] = {
218
+ "name": target.id,
219
+ "annotation": "",
220
+ "default": val_str,
221
+ "lineno": item.lineno,
222
+ }
223
+ elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
224
+ fname = item.target.id
225
+ cls_fields[fname] = {
226
+ "name": fname,
227
+ "annotation": _unparse_safe(item.annotation),
228
+ "default": _unparse_safe(item.value) if item.value else None,
229
+ "lineno": item.lineno,
230
+ }
231
+ elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
232
+ msig = _extract_function_body_and_sig(item, rel_file, node.name)
233
+ cls_methods[item.name] = msig
234
+ functions[f"{node.name}.{item.name}"] = msig
235
+
236
+ cls_id = f"py:{rel_file}:{node.name}"
237
+ classes[node.name] = {
238
+ "name": node.name,
239
+ "bases": bases,
240
+ "tablename": tablename,
241
+ "fields": cls_fields,
242
+ "methods": cls_methods,
243
+ "file": rel_file,
244
+ "lineno": node.lineno,
245
+ }
246
+ nodes.append({
247
+ "id": cls_id,
248
+ "type": "python_class",
249
+ "name": node.name,
250
+ "bases": bases,
251
+ "fields": list(cls_fields.keys()),
252
+ "file": rel_file,
253
+ "lineno": node.lineno,
254
+ "layer": "backend",
255
+ })
256
+ edges.append({
257
+ "source": module_node_id,
258
+ "target": cls_id,
259
+ "relation": "defines_class",
260
+ })
261
+
262
+ for fname, fmeta in cls_fields.items():
263
+ field_id = f"py:{rel_file}:{node.name}.{fname}"
264
+ nodes.append({
265
+ "id": field_id,
266
+ "type": "class_field",
267
+ "name": f"{node.name}.{fname}",
268
+ "class_name": node.name,
269
+ "field": fname,
270
+ "annotation": fmeta["annotation"],
271
+ "file": rel_file,
272
+ "layer": "backend",
273
+ })
274
+ edges.append({
275
+ "source": cls_id,
276
+ "target": field_id,
277
+ "relation": "has_field",
278
+ })
279
+
280
+ for mname, mmeta in cls_methods.items():
281
+ method_id = f"py:{rel_file}:{node.name}.{mname}"
282
+ nodes.append({
283
+ "id": method_id,
284
+ "type": "python_method",
285
+ "name": f"{node.name}.{mname}",
286
+ "signature": mmeta["signature_string"],
287
+ "params": mmeta["params"],
288
+ "return_type": mmeta["return_type"],
289
+ "file": rel_file,
290
+ "lineno": mmeta["lineno"],
291
+ "layer": "backend",
292
+ })
293
+ edges.append({
294
+ "source": cls_id,
295
+ "target": method_id,
296
+ "relation": "has_method",
297
+ })
298
+
299
+ # Link intra-module function calls (callee -> caller impact direction)
300
+ for fn_name, fn_meta in functions.items():
301
+ caller_id = f"py:{rel_file}:{fn_name}"
302
+ for called in fn_meta.get("calls", []):
303
+ if called in functions and called != fn_name:
304
+ callee_id = f"py:{rel_file}:{called}"
305
+ edges.append({
306
+ "source": callee_id,
307
+ "target": caller_id,
308
+ "relation": "called_by",
309
+ })
310
+
311
+ return {
312
+ "file": rel_file,
313
+ "functions": functions,
314
+ "classes": classes,
315
+ "imports": imports,
316
+ "nodes": nodes,
317
+ "edges": edges,
318
+ }
319
+
320
+
321
+ def _extract_function_body_and_sig(fn_node: ast.AST, rel_file: str, cls_name: str) -> Dict[str, Any]:
322
+ sig = _extract_function_signature(fn_node)
323
+ body_info = _inspect_function_body(fn_node)
324
+ sig.update(body_info)
325
+ sig["file"] = rel_file
326
+ sig["class_name"] = cls_name
327
+ return sig
328
+
329
+
330
+ def parse_python_file(file_path: str, repo_root: str = "") -> Dict[str, Any]:
331
+ if not os.path.exists(file_path):
332
+ return {"error": f"Python file not found: {file_path}", "functions": {}, "classes": {}, "imports": [], "nodes": [], "edges": []}
333
+ rel_file = os.path.relpath(file_path, repo_root).replace("\\", "/") if repo_root else os.path.basename(file_path)
334
+ try:
335
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
336
+ content = f.read()
337
+ except Exception as exc:
338
+ return {"error": str(exc), "functions": {}, "classes": {}, "imports": [], "nodes": [], "edges": []}
339
+ return parse_python_content(content, rel_file)
340
+