@ihelpee/prumo-plugin-python-fastapi 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Prumo AST Extractor para Python/FastAPI — contrato canônico JSON v1.
4
+
5
+ Spec: docs/especificacao-prumo-python.md §1.4. Este script é DADO do gate,
6
+ não implementador da lei: ele apenas serializa AST em JSON. A decisão de
7
+ "é violação?" fica 100% no core Node — a lei permanece única.
8
+
9
+ Invariantes deste arquivo (atômicos, da spec):
10
+ R1.1 — apenas stdlib (`ast`, `json`, `sys`, `pathlib`). Import de terceiros
11
+ é falha de build.
12
+ R1.2 — nunca executa `import`/`exec`/`eval` do código analisado. Apenas
13
+ `ast.parse`. O subprocesso é o isolador: a análise é estática, o app
14
+ nunca dá boot.
15
+ R1.3 — arquivo com SyntaxError NÃO derruba o run: emite
16
+ { "path", "error": "syntax" } e sai com código 0.
17
+
18
+ Uso: python3 ast_extractor.py --files <path> [<path> ...]
19
+ Saída: um único JSON no stdout: {"version": 1, "files": [...]}
20
+ """
21
+
22
+ import ast
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ VERSION = 1
28
+
29
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
30
+
31
+ # Kwargs de Field(...) que constituem constraint de domínio (Pydantic v2;
32
+ # `regex` é o nome v1 — R3.6).
33
+ FIELD_CONSTRAINT_KWARGS = {
34
+ "gt", "ge", "lt", "le",
35
+ "min_length", "max_length",
36
+ "pattern", "regex",
37
+ "min_items", "max_items",
38
+ "multiple_of", "decimal_places",
39
+ "min_digits", "max_digits",
40
+ "allow_inf_nan", "strict",
41
+ }
42
+
43
+ # Tipos/constraints que já são constraint por construção (R3.6).
44
+ CONSTRAINED_TYPES = {
45
+ "EmailStr", "PhoneEmail", "AnyUrl", "HttpUrl",
46
+ "conint", "constr", "confloat", "condecimal", "conlist", "conset",
47
+ "PositiveInt", "NonNegativeInt", "NegativeInt", "NonPositiveInt",
48
+ "PositiveFloat", "NonNegativeFloat", "NegativeFloat",
49
+ "FilePath", "DirectoryPath", "UUID4", "UUID", "DateTime",
50
+ }
51
+
52
+ # Anotações de parâmetro que NÃO são body model (FastAPI builtins).
53
+ NON_BODY_ANNOTATIONS = {
54
+ "str", "int", "float", "bool", "bytes", "complex",
55
+ "dict", "list", "set", "tuple", "frozenset",
56
+ "Request", "WebSocket", "Response", "BackgroundTasks", "BackgroundTask",
57
+ "UploadFile", "Form", "File", "Query", "Path", "Header", "Cookie",
58
+ "Body", "Depends", "Security", "HTTPException",
59
+ }
60
+
61
+
62
+ def _name_of(node):
63
+ """Nome legível de um nó: Name.id, Attribute.attr, Subscript base."""
64
+ if node is None:
65
+ return ""
66
+ if isinstance(node, ast.Name):
67
+ return node.id
68
+ if isinstance(node, ast.Attribute):
69
+ return node.attr
70
+ if isinstance(node, ast.Subscript):
71
+ return _name_of(node.value)
72
+ if isinstance(node, ast.Call):
73
+ return _name_of(node.func)
74
+ return ""
75
+
76
+
77
+ def _dotted(node):
78
+ """Cadeia pontuada completa de um Attribute/Name (ex.: `importlib.import_module`)."""
79
+ if isinstance(node, ast.Name):
80
+ return node.id
81
+ if isinstance(node, ast.Attribute):
82
+ prefix = _dotted(node.value)
83
+ return f"{prefix}.{node.attr}" if prefix else node.attr
84
+ return ""
85
+
86
+
87
+ def _call_name(node):
88
+ if isinstance(node, ast.Call):
89
+ return _name_of(node.func)
90
+ return ""
91
+
92
+
93
+ def _field_has_constraint(node):
94
+ """Call a Field(...) com ao menos um kwarg de constraint de domínio."""
95
+ if not isinstance(node, ast.Call):
96
+ return False
97
+ if _call_name(node) != "Field":
98
+ return False
99
+ return any(k.arg in FIELD_CONSTRAINT_KWARGS for k in node.keywords)
100
+
101
+
102
+ def _annotation_constrained(ann):
103
+ """A anotação carrega constraint? (Field em Annotated, tipos con*, EmailStr, unions)."""
104
+ if ann is None:
105
+ return False
106
+ if isinstance(ann, ast.BinOp): # X | None
107
+ return _annotation_constrained(ann.left) or _annotation_constrained(ann.right)
108
+ if isinstance(ann, ast.Subscript):
109
+ base = _name_of(ann.value)
110
+ if base == "Annotated":
111
+ sl = ann.slice
112
+ elts = sl.elts if isinstance(sl, ast.Tuple) else [sl]
113
+ for extra in elts[1:]:
114
+ if _field_has_constraint(extra):
115
+ return True
116
+ if _name_of(extra) in CONSTRAINED_TYPES:
117
+ return True
118
+ if isinstance(extra, ast.Call) and _name_of(extra.func) in CONSTRAINED_TYPES:
119
+ return True
120
+ return False
121
+ if base in {"Optional", "Union"}:
122
+ sl = ann.slice
123
+ elts = sl.elts if isinstance(sl, ast.Tuple) else [sl]
124
+ return any(_annotation_constrained(e) for e in elts)
125
+ return _annotation_constrained(ann.value)
126
+ if isinstance(ann, ast.Call): # conint(gt=0)
127
+ return _name_of(ann.func) in CONSTRAINED_TYPES
128
+ return _name_of(ann) in CONSTRAINED_TYPES
129
+
130
+
131
+ def _depends_inner(call):
132
+ """Depends(X) -> 'X' (Name, Attribute base, ou func de Call: Depends(HTTPBearer()))."""
133
+ if not (isinstance(call, ast.Call) and _call_name(call) == "Depends"):
134
+ return None
135
+ if not call.args:
136
+ return "Depends"
137
+ arg = call.args[0]
138
+ if isinstance(arg, ast.Call):
139
+ return _name_of(arg.func) or "Depends"
140
+ return _name_of(arg) or "Depends"
141
+
142
+
143
+ def _param_depends(fn):
144
+ """R3.1: Depends(X) em default de parâmetro OU em Annotated[X, Depends(...)]."""
145
+ out = []
146
+ positional = list(fn.args.posonlyargs) + list(fn.args.args)
147
+ # defaults de posicionais alinham com os ÚLTIMOS N args (ast padrão).
148
+ defaults = list(fn.args.defaults)
149
+ pad = [None] * (len(positional) - len(defaults)) + defaults
150
+ kwonly = list(fn.args.kwonlyargs)
151
+ kwdefaults = list(fn.args.kw_defaults)
152
+ pairs = list(zip(positional, pad)) + list(zip(kwonly, kwdefaults))
153
+ for a, default in pairs:
154
+ dep = _depends_inner(default)
155
+ if dep:
156
+ out.append(dep)
157
+ ann = a.annotation
158
+ if isinstance(ann, ast.Subscript) and _name_of(ann.value) == "Annotated":
159
+ sl = ann.slice
160
+ elts = sl.elts if isinstance(sl, ast.Tuple) else [sl]
161
+ for extra in elts[1:]:
162
+ dep = _depends_inner(extra)
163
+ if dep:
164
+ out.append(dep)
165
+ return out
166
+
167
+
168
+ def _body_annotation(fn):
169
+ """Primeira anotação de parâmetro que é candidata a Pydantic model (body)."""
170
+ args = list(fn.args.posonlyargs) + list(fn.args.args) + list(fn.args.kwonlyargs)
171
+ for a in args:
172
+ ann = a.annotation
173
+ if ann is None:
174
+ continue
175
+ name = _name_of(ann)
176
+ if not name or name in NON_BODY_ANNOTATIONS:
177
+ continue
178
+ # Annotated[Model, Body(...)] -> Model
179
+ if isinstance(ann, ast.Subscript) and _name_of(ann.value) == "Annotated":
180
+ sl = ann.slice
181
+ elts = sl.elts if isinstance(sl, ast.Tuple) else [sl]
182
+ name = _name_of(elts[0]) if elts else name
183
+ if name and name not in NON_BODY_ANNOTATIONS:
184
+ return name
185
+ return None
186
+
187
+
188
+ def _dependencies_kw(call):
189
+ """dependencies=[Depends(X), ...] -> [X, ...] (R3.2/R3.3)."""
190
+ out = []
191
+ for k in call.keywords:
192
+ if k.arg != "dependencies" or not isinstance(k.value, ast.List):
193
+ continue
194
+ for elt in k.value.elts:
195
+ dep = _depends_inner(elt)
196
+ out.append(dep if dep else _name_of(elt))
197
+ return [d for d in out if d]
198
+
199
+
200
+ def _route_from_decorators(fn):
201
+ """Decorators @app.post / @router.put / @api_router.delete (FastAPI)."""
202
+ routes = []
203
+ for dec in fn.decorator_list:
204
+ if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
205
+ continue
206
+ method = dec.func.attr.upper()
207
+ if dec.func.attr.lower() not in HTTP_METHODS:
208
+ continue
209
+ prefix = _dotted(dec.func.value) # 'app', 'router', 'module.router'
210
+ path = ""
211
+ if dec.args and isinstance(dec.args[0], ast.Constant) and isinstance(dec.args[0].value, str):
212
+ path = dec.args[0].value
213
+ response_model = None
214
+ for k in dec.keywords:
215
+ if k.arg == "response_model":
216
+ if isinstance(k.value, ast.Constant) and isinstance(k.value.value, str):
217
+ response_model = k.value.value
218
+ else:
219
+ response_model = _name_of(k.value) or None
220
+ routes.append({
221
+ "method": method,
222
+ "path": path,
223
+ "handler": fn.name,
224
+ "decorators": [f"{prefix}.{dec.func.attr}" if prefix else dec.func.attr],
225
+ "router_var": prefix.split(".")[-1] if prefix else "",
226
+ "router_dependencies": _dependencies_kw(dec),
227
+ "param_depends": _param_depends(fn),
228
+ "body_model": _body_annotation(fn),
229
+ "response_model": response_model,
230
+ "line": dec.lineno,
231
+ })
232
+ return routes
233
+
234
+
235
+ def _is_pydantic_model(classdef):
236
+ for b in classdef.bases:
237
+ if _name_of(b) in {"BaseModel", "BaseSettings"}:
238
+ return True
239
+ return False
240
+
241
+
242
+ def _collect_validators(classdef):
243
+ """(campos citados por @field_validator/@validator, model_validator presente)."""
244
+ cited = set()
245
+ model_level = False
246
+ for item in classdef.body:
247
+ if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
248
+ continue
249
+ for dec in item.decorator_list:
250
+ dname = _call_name(dec) if isinstance(dec, ast.Call) else _name_of(dec)
251
+ if dname in {"field_validator", "validator"} and isinstance(dec, ast.Call):
252
+ for a in dec.args:
253
+ if isinstance(a, ast.Constant) and isinstance(a.value, str):
254
+ cited.add(a.value)
255
+ if dname in {"model_validator", "root_validator"}:
256
+ model_level = True
257
+ return cited, model_level
258
+
259
+
260
+ def _extract_model(classdef):
261
+ cited, model_level = _collect_validators(classdef)
262
+ fields = []
263
+ for item in classdef.body:
264
+ if not isinstance(item, ast.AnnAssign) or not isinstance(item.target, ast.Name):
265
+ continue
266
+ fname = item.target.id
267
+ has_constraint = _annotation_constrained(item.annotation) or _field_has_constraint(item.value)
268
+ has_validator = model_level or fname in cited
269
+ fields.append({
270
+ "name": fname,
271
+ "annotation": _name_of(item.annotation),
272
+ "has_constraint": has_constraint,
273
+ "has_validator": has_validator,
274
+ })
275
+ return {
276
+ "name": classdef.name,
277
+ "bases": [_name_of(b) for b in classdef.bases],
278
+ "line": classdef.lineno,
279
+ "fields": fields,
280
+ }
281
+
282
+
283
+ def _module_of(rel_path):
284
+ """'app/orders/router.py' -> 'app.orders.router'; __init__.py -> pacote."""
285
+ p = rel_path[:-3] if rel_path.endswith(".py") else rel_path
286
+ parts = p.replace("\\", "/").split("/")
287
+ if parts and parts[-1] == "__init__":
288
+ parts = parts[:-1]
289
+ return ".".join(parts)
290
+
291
+
292
+ def _resolve_relative(file_module_parts, level, module):
293
+ """R2.3: resolve `from ..x import y` contra o pacote do arquivo."""
294
+ base = list(file_module_parts) # partes do diretório do módulo
295
+ drop = level - 1
296
+ if drop > len(base):
297
+ return None
298
+ if drop:
299
+ base = base[: len(base) - drop]
300
+ parts = base + (module.split(".") if module else [])
301
+ return ".".join(parts) if parts else None
302
+
303
+
304
+ def _extract_imports(tree, rel_path):
305
+ imports = []
306
+ parts = _module_of(rel_path).split(".") if _module_of(rel_path) else []
307
+ dir_parts = parts[:-1] if parts else []
308
+ for node in ast.walk(tree):
309
+ if isinstance(node, ast.Import):
310
+ for alias in node.names:
311
+ imports.append({
312
+ "module": alias.name,
313
+ "names": [alias.asname or alias.name],
314
+ "relative": False,
315
+ "line": node.lineno,
316
+ })
317
+ elif isinstance(node, ast.ImportFrom):
318
+ if node.level and node.level > 0:
319
+ resolved = _resolve_relative(dir_parts, node.level, node.module or "")
320
+ if resolved is None:
321
+ continue
322
+ imports.append({
323
+ "module": resolved,
324
+ "names": [a.asname or a.name for a in node.names],
325
+ "relative": True,
326
+ "line": node.lineno,
327
+ })
328
+ else:
329
+ imports.append({
330
+ "module": node.module or "",
331
+ "names": [a.asname or a.name for a in node.names],
332
+ "relative": False,
333
+ "line": node.lineno,
334
+ })
335
+ return imports
336
+
337
+
338
+ def _extract_dynamic_imports(tree):
339
+ """R2.4 anti-burla: importlib.import_module('...') e __import__('...')."""
340
+ out = []
341
+ for node in ast.walk(tree):
342
+ if not isinstance(node, ast.Call):
343
+ continue
344
+ dotted = _dotted(node.func)
345
+ is_dynamic = dotted == "__import__" or dotted.endswith("importlib.import_module") or dotted == "import_module"
346
+ if not is_dynamic or not node.args:
347
+ continue
348
+ first = node.args[0]
349
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
350
+ out.append({"expr": f"{dotted}({first.value!r})", "module": first.value, "line": node.lineno})
351
+ return out
352
+
353
+
354
+ def _extract_router_defs(tree):
355
+ """`router = APIRouter(dependencies=[...])` (R3.3, camada de definição)."""
356
+ routers = []
357
+ for node in ast.walk(tree):
358
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
359
+ continue
360
+ if _call_name(node.value) != "APIRouter":
361
+ continue
362
+ for t in node.targets:
363
+ if isinstance(t, ast.Name):
364
+ routers.append({"var": t.id, "dependencies": _dependencies_kw(node.value)})
365
+ return routers
366
+
367
+
368
+ def _extract_include_routers(tree):
369
+ """`app.include_router(x, dependencies=[...])` (R3.3, camada de montagem)."""
370
+ out = []
371
+ for node in ast.walk(tree):
372
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
373
+ continue
374
+ if node.func.attr != "include_router" or not node.args:
375
+ continue
376
+ target = _dotted(node.args[0])
377
+ if not target:
378
+ continue
379
+ out.append({"target": target, "dependencies": _dependencies_kw(node)})
380
+ return out
381
+
382
+
383
+ def _extract_file(path, root):
384
+ rel = str(path.relative_to(root)) if root in path.parents or path == root else str(path)
385
+ rel = rel.replace("\\", "/")
386
+ try:
387
+ source = path.read_text(encoding="utf-8")
388
+ except (OSError, UnicodeDecodeError):
389
+ return {"path": rel, "error": "unreadable"}
390
+ try:
391
+ tree = ast.parse(source, filename=rel)
392
+ except SyntaxError:
393
+ # R1.3: nunca derruba o run, nunca stack trace no stdout.
394
+ return {"path": rel, "error": "syntax"}
395
+
396
+ pydantic_v1 = any(
397
+ imp["module"] == "pydantic.v1" or imp["module"].startswith("pydantic.v1.")
398
+ for imp in _extract_imports(tree, rel)
399
+ )
400
+
401
+ routes = []
402
+ models = []
403
+ for node in ast.walk(tree):
404
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
405
+ routes.extend(_route_from_decorators(node))
406
+ elif isinstance(node, ast.ClassDef) and _is_pydantic_model(node):
407
+ models.append(_extract_model(node))
408
+
409
+ return {
410
+ "path": rel,
411
+ "imports": _extract_imports(tree, rel),
412
+ "dynamic_imports": _extract_dynamic_imports(tree),
413
+ "routes": routes,
414
+ "models": models,
415
+ "routers": _extract_router_defs(tree),
416
+ "include_routers": _extract_include_routers(tree),
417
+ "pydantic_v1": pydantic_v1,
418
+ }
419
+
420
+
421
+ def main(argv):
422
+ if "--files" not in argv:
423
+ print(json.dumps({"version": VERSION, "files": []}))
424
+ return 0
425
+ idx = argv.index("--files")
426
+ root = Path.cwd()
427
+ files = []
428
+ for raw in argv[idx + 1:]:
429
+ p = Path(raw)
430
+ if not p.is_absolute():
431
+ p = root / p
432
+ files.append(p)
433
+ out = [_extract_file(p, root) for p in files]
434
+ print(json.dumps({"version": VERSION, "files": out}))
435
+ return 0
436
+
437
+
438
+ if __name__ == "__main__":
439
+ sys.exit(main(sys.argv[1:]))