stackthread 0.1.0.dev2__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 stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,700 @@
1
+ """HTTP framework detectors: FastAPI, Django URLconf, django-ninja, Django REST framework (design §8.4).
2
+
3
+ Each detector reads the framework-neutral facts (decorators, module assignments, module and function calls,
4
+ urlpatterns lists) through the resolver and adds endpoint nodes with `routes` edges to their handlers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from stackthread.model import Edge, Node
15
+
16
+ if TYPE_CHECKING:
17
+ from stackthread.lang.python.build import PyBuild
18
+ from stackthread.lang.python.resolve import Ref
19
+
20
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
21
+
22
+ #: External class paths that make a framework object, matched on the resolved import path.
23
+ OBJECT_KINDS = {
24
+ "fastapi-app": ("fastapi.FastAPI", "fastapi.applications.FastAPI"),
25
+ "fastapi-router": ("fastapi.APIRouter", "fastapi.routing.APIRouter"),
26
+ "ninja-api": ("ninja.NinjaAPI", "ninja.main.NinjaAPI"),
27
+ "ninja-router": ("ninja.Router", "ninja.router.Router"),
28
+ "drf-router": ("rest_framework.routers.DefaultRouter", "rest_framework.routers.SimpleRouter"),
29
+ }
30
+
31
+ #: Methods served by class-based views whose handlers live outside the project (Django and DRF generics).
32
+ GENERIC_VIEW_METHODS = {
33
+ "ListAPIView": ["GET"],
34
+ "CreateAPIView": ["POST"],
35
+ "ListCreateAPIView": ["GET", "POST"],
36
+ "RetrieveAPIView": ["GET"],
37
+ "UpdateAPIView": ["PUT", "PATCH"],
38
+ "DestroyAPIView": ["DELETE"],
39
+ "RetrieveUpdateAPIView": ["GET", "PUT", "PATCH"],
40
+ "RetrieveDestroyAPIView": ["GET", "DELETE"],
41
+ "RetrieveUpdateDestroyAPIView": ["GET", "PUT", "PATCH", "DELETE"],
42
+ "TemplateView": ["GET"],
43
+ "ListView": ["GET"],
44
+ "DetailView": ["GET"],
45
+ "RedirectView": ["GET"],
46
+ "ArchiveIndexView": ["GET"],
47
+ "FormView": ["GET", "POST"],
48
+ "CreateView": ["GET", "POST"],
49
+ "UpdateView": ["GET", "POST"],
50
+ "DeleteView": ["GET", "POST"],
51
+ "LoginView": ["GET", "POST"],
52
+ "LogoutView": ["POST"],
53
+ "PasswordChangeView": ["GET", "POST"],
54
+ }
55
+
56
+ #: Router-generated ViewSet actions: action -> (HTTP method, detail route?).
57
+ VIEWSET_ACTIONS = {
58
+ "list": ("GET", False),
59
+ "create": ("POST", False),
60
+ "retrieve": ("GET", True),
61
+ "update": ("PUT", True),
62
+ "partial_update": ("PATCH", True),
63
+ "destroy": ("DELETE", True),
64
+ }
65
+ VIEWSET_BASE_ACTIONS = {
66
+ "ModelViewSet": list(VIEWSET_ACTIONS),
67
+ "ReadOnlyModelViewSet": ["list", "retrieve"],
68
+ "ListModelMixin": ["list"],
69
+ "CreateModelMixin": ["create"],
70
+ "RetrieveModelMixin": ["retrieve"],
71
+ "UpdateModelMixin": ["update", "partial_update"],
72
+ "DestroyModelMixin": ["destroy"],
73
+ }
74
+
75
+ _CONVERTER = re.compile(r"<(?:(?P<conv>[\w]+):)?(?P<name>\w+)>")
76
+ _NAMED_GROUP = re.compile(r"\(\?P<(?P<name>\w+)>[^)]*\)")
77
+ _PLAIN_GROUP = re.compile(r"\([^)]*\)")
78
+
79
+
80
+ def join_paths(*parts: str) -> str:
81
+ """Join URL parts with single slashes; keep a trailing slash when the last non-empty part has one."""
82
+ pieces = [p for p in parts if p]
83
+ if not pieces:
84
+ return "/"
85
+ joined = "/".join(p.strip("/") for p in pieces)
86
+ joined = "/" + re.sub(r"/{2,}", "/", joined).strip("/")
87
+ if pieces[-1].endswith("/") and joined != "/":
88
+ joined += "/"
89
+ return joined
90
+
91
+
92
+ def django_route(route: str, regex: bool = False) -> tuple[str, bool]:
93
+ """A Django `path()` route or `re_path()` pattern as a display path with `{name}` parameters.
94
+ Returns the path and whether it is exact (regex patterns with leftover syntax are approximations)."""
95
+ if not regex:
96
+ return _CONVERTER.sub(lambda m: "{" + m.group("name") + "}", route), True
97
+ text = route.lstrip("^").rstrip("$")
98
+ text = _NAMED_GROUP.sub(lambda m: "{" + m.group("name") + "}", text)
99
+ text = _PLAIN_GROUP.sub("{}", text)
100
+ text = text.replace("\\.", ".").replace("\\-", "-").replace("\\/", "/")
101
+ exact = not re.search(r"[\\^$*+?\[\]|]", text)
102
+ text = re.sub(r"[*+?]", "", text)
103
+ return text, exact
104
+
105
+
106
+ @dataclass
107
+ class FwObject:
108
+ key: str
109
+ kind: str
110
+ module: str
111
+ name: str
112
+ line: int
113
+ prefix: str = ""
114
+ kw: dict[str, Any] = field(default_factory=dict)
115
+
116
+
117
+ class Detector:
118
+ def __init__(self, b: PyBuild) -> None:
119
+ self.b = b
120
+ self.idx = b.idx
121
+ self.objects: dict[str, FwObject] = {}
122
+ self.parents: dict[str, list[tuple[str, str]]] = defaultdict(list) # child object -> [(parent, prefix)]
123
+ self.mounts: dict[str, list[str]] = defaultdict(list) # ninja api / drf router -> URL prefixes
124
+ self.endpoints = 0
125
+ enabled = set(b.source.frameworks)
126
+ self.auto = "auto" in enabled
127
+ self.enabled = enabled
128
+
129
+ def on(self, name: str) -> bool:
130
+ return self.auto or name in self.enabled
131
+
132
+ # ---- objects ----
133
+
134
+ def classify(self, ref: Ref | None) -> str | None:
135
+ if ref is None:
136
+ return None
137
+ if ref.kind == "external":
138
+ for kind, paths in OBJECT_KINDS.items():
139
+ if ref.key in paths:
140
+ return kind
141
+ return None
142
+ if ref.kind == "def" and self.idx.defs.get(ref.key, {}).get("kind") == "class":
143
+ for base in self.idx.external_bases(ref.key):
144
+ for kind, paths in OBJECT_KINDS.items():
145
+ if base in paths:
146
+ return kind
147
+ return None
148
+
149
+ def find_objects(self) -> None:
150
+ for key, var in self.idx.vars.items():
151
+ value = var.get("value")
152
+ if not value or value.get("t") != "call":
153
+ continue
154
+ kind = self.classify(self.idx.resolve_dotted(var["module"], value["f"]))
155
+ if kind:
156
+ prefix = self.idx.const(var["module"], value["kw"].get("prefix")) or ""
157
+ self.objects[key] = FwObject(
158
+ key,
159
+ kind,
160
+ var["module"],
161
+ var["name"],
162
+ var["line"],
163
+ prefix if isinstance(prefix, str) else "",
164
+ value.get("kw", {}),
165
+ )
166
+ for dkey, d in self.idx.defs.items():
167
+ for name, hint in d.get("locals", {}).items():
168
+ if not hint.endswith("()") or "(" in hint[:-2]:
169
+ continue
170
+ kind = self.classify(self.idx.resolve_dotted(d["module"], hint[:-2]))
171
+ if kind:
172
+ key = f"{dkey}:{name}"
173
+ self.objects[key] = FwObject(key, kind, d["module"], name, d["line"])
174
+
175
+ def object_at(self, module: str, expr: str | None, scope: dict[str, Any] | None = None) -> FwObject | None:
176
+ if not expr:
177
+ return None
178
+ if scope is not None and "." not in expr:
179
+ local = self.objects.get(f"{scope['key']}:{expr}")
180
+ if local:
181
+ return local
182
+ ref = self.idx.resolve_dotted(module, expr)
183
+ if ref is not None and ref.kind == "var":
184
+ return self.objects.get(ref.key)
185
+ return None
186
+
187
+ def object_from_string(self, text: str) -> FwObject | None:
188
+ """`"shop.api.router"` or `"shop.api:router"` (django-ninja's lazy form)."""
189
+ module, _, name = text.replace(":", ".").rpartition(".")
190
+ return self.objects.get(f"{module}:{name}")
191
+
192
+ def all_calls(self) -> list[tuple[str, dict[str, Any] | None, dict[str, Any]]]:
193
+ """(module, scope or None, call) for every module-level and function-level call."""
194
+ out: list[tuple[str, dict[str, Any] | None, dict[str, Any]]] = []
195
+ for module, f in self.idx.modules.items():
196
+ out += [(module, None, c) for c in f["calls"]]
197
+ for d in self.idx.defs.values():
198
+ out += [(d["module"], d, c) for c in d.get("calls", [])]
199
+ return out
200
+
201
+ def prefixes(self, key: str, seen: frozenset[str] = frozenset()) -> list[tuple[str, bool]]:
202
+ """Full URL prefixes of an object (its own prefix included) and whether it is reachable from an app."""
203
+ obj = self.objects[key]
204
+ if key in seen:
205
+ return []
206
+ parents = self.parents.get(key, [])
207
+ if obj.kind in ("fastapi-app",) and not parents:
208
+ return [(obj.prefix, True)]
209
+ if obj.kind in ("ninja-api", "drf-router"):
210
+ mounts = self.mounts.get(key)
211
+ return [(join_paths(m, obj.prefix), True) for m in mounts] if mounts else [(obj.prefix, False)]
212
+ if not parents:
213
+ return [(obj.prefix, False)]
214
+ out = []
215
+ for parent, prefix in parents:
216
+ for base, mounted in self.prefixes(parent, seen | {key}):
217
+ out.append((join_paths(base, prefix, obj.prefix), mounted))
218
+ return out or [(obj.prefix, False)]
219
+
220
+ # ---- endpoints ----
221
+
222
+ def endpoint(
223
+ self,
224
+ method: str,
225
+ path: str,
226
+ handler: str | None,
227
+ framework: str,
228
+ module: str,
229
+ line: int,
230
+ mounted: bool = True,
231
+ exact: bool = True,
232
+ note: str = "",
233
+ ) -> None:
234
+ b = self.b
235
+ method = method.upper()
236
+ node_id = f"ep:{b.source.name}:{method} {path}"
237
+ tags = [framework] + ([] if mounted else ["unmounted"])
238
+ attrs: dict[str, Any] = {"method": method, "path": path, "framework": framework}
239
+ if note:
240
+ attrs["note"] = note
241
+ if handler and handler in b.graph.nodes:
242
+ attrs["handler"] = handler
243
+ b.graph.add_node(
244
+ Node(
245
+ id=node_id,
246
+ kind="endpoint",
247
+ name=f"{method} {path}",
248
+ lang="python",
249
+ source=b.source.name,
250
+ file=b.module_files[module],
251
+ line=line,
252
+ end=line,
253
+ layer="network",
254
+ tags=tags,
255
+ attrs=attrs,
256
+ )
257
+ )
258
+ if handler and handler in b.graph.nodes:
259
+ b.graph.add_edge(
260
+ Edge(
261
+ node_id,
262
+ handler,
263
+ "routes",
264
+ "exact" if (mounted and exact) else "inferred",
265
+ site=b.site(module, line),
266
+ )
267
+ )
268
+ self.endpoints += 1
269
+
270
+ def handler_id(self, key: str | None) -> str | None:
271
+ return self.b.node_of(key) if key and key in self.idx.defs else None
272
+
273
+
274
+ # ---------------------------------------------------------------- FastAPI
275
+
276
+
277
+ def _route_path(d: Detector, module: str, deco: dict[str, Any], index: int = 0) -> str | None:
278
+ value = deco["args"][index] if len(deco["args"]) > index else deco["kw"].get("path")
279
+ path = d.idx.const(module, value)
280
+ return path if isinstance(path, str) else None
281
+
282
+
283
+ def detect_fastapi(d: Detector) -> None:
284
+ for module, scope, call in d.all_calls():
285
+ f = call["f"]
286
+ if f.endswith((".include_router", ".mount")):
287
+ receiver = f.rsplit(".", 1)[0]
288
+ parent = d.object_at(module, receiver, scope)
289
+ if parent is None or not parent.kind.startswith("fastapi"):
290
+ continue
291
+ if f.endswith(".include_router"):
292
+ child_value = call["args"][0] if call["args"] else call["kw"].get("router")
293
+ prefix = d.idx.const(module, call["kw"].get("prefix")) or ""
294
+ else:
295
+ child_value = call["args"][1] if len(call["args"]) > 1 else call["kw"].get("app")
296
+ prefix = d.idx.const(module, call["args"][0] if call["args"] else call["kw"].get("path")) or ""
297
+ if not child_value or child_value.get("t") != "name":
298
+ continue
299
+ child = d.object_at(module, child_value["v"], scope)
300
+ if child is not None and child.kind.startswith("fastapi"):
301
+ d.parents[child.key].append((parent.key, prefix if isinstance(prefix, str) else ""))
302
+ for key, fn in d.idx.defs.items():
303
+ for deco in fn.get("decorators", []):
304
+ receiver, _, method = deco["f"].rpartition(".")
305
+ obj = d.object_at(fn["module"], receiver)
306
+ if obj is None or not obj.kind.startswith("fastapi"):
307
+ continue
308
+ if method in HTTP_METHODS:
309
+ methods = [method]
310
+ elif method == "api_route":
311
+ listed = d.idx.const(fn["module"], deco["kw"].get("methods")) or ["GET"]
312
+ methods = [m for m in listed if isinstance(m, str)]
313
+ elif method == "websocket":
314
+ methods = ["WS"]
315
+ else:
316
+ continue
317
+ path = _route_path(d, fn["module"], deco)
318
+ if path is None:
319
+ continue
320
+ for prefix, mounted in d.prefixes(obj.key):
321
+ for m in methods:
322
+ d.endpoint(
323
+ m, join_paths(prefix, path), d.handler_id(key), "fastapi", fn["module"], deco["line"], mounted
324
+ )
325
+ _fastapi_dependencies(d, key, fn, deco, obj)
326
+ for module, scope, call in d.all_calls():
327
+ if not call["f"].endswith(".add_api_route"):
328
+ continue
329
+ obj = d.object_at(module, call["f"].rsplit(".", 1)[0], scope)
330
+ if obj is None or not obj.kind.startswith("fastapi") or len(call["args"]) < 2:
331
+ continue
332
+ path = d.idx.const(module, call["args"][0])
333
+ target = call["args"][1]
334
+ if not isinstance(path, str) or not target or target.get("t") != "name":
335
+ continue
336
+ ref = d.idx.resolve_dotted(module, target["v"], scope)
337
+ methods = d.idx.const(module, call["kw"].get("methods")) or ["GET"]
338
+ for prefix, mounted in d.prefixes(obj.key):
339
+ for m in methods:
340
+ d.endpoint(
341
+ str(m), join_paths(prefix, path), d.b.ref_node(ref), "fastapi", module, call["line"], mounted
342
+ )
343
+
344
+
345
+ def _fastapi_dependencies(d: Detector, key: str, fn: dict[str, Any], deco: dict[str, Any], obj: FwObject) -> None:
346
+ """`dependencies=[Depends(x)]` on the route or its router, and `response_model=Schema`."""
347
+ from stackthread.lang.python.build import _calls_in
348
+
349
+ node = d.handler_id(key)
350
+ if node is None:
351
+ return
352
+ site = d.b.site(fn["module"], deco["line"])
353
+ sources = [(fn["module"], deco["kw"].get("dependencies")), (obj.module, obj.kw.get("dependencies"))]
354
+ for module, value in sources:
355
+ for call in _calls_in(value):
356
+ if call["f"].rsplit(".", 1)[-1] in ("Depends", "Security") and call.get("args"):
357
+ arg = call["args"][0]
358
+ if arg and arg.get("t") == "name":
359
+ target = d.b.ref_node(d.idx.resolve_dotted(module, arg["v"]))
360
+ if target:
361
+ d.b.graph.add_edge(Edge(node, target, "uses", site=site, label="Depends"))
362
+ model = deco["kw"].get("response_model")
363
+ if model and model.get("t") == "name":
364
+ target = d.b.ref_node(d.idx.resolve_dotted(fn["module"], model["v"]))
365
+ if target:
366
+ d.b.graph.add_edge(Edge(node, target, "uses", site=site, label="response"))
367
+
368
+
369
+ # ---------------------------------------------------------------- Django URLconf (+ mounts for ninja and DRF)
370
+
371
+
372
+ def _view_methods(d: Detector, fn: dict[str, Any]) -> list[str]:
373
+ for deco in fn.get("decorators", []):
374
+ name = deco["f"].rsplit(".", 1)[-1]
375
+ if name in ("require_http_methods", "api_view"):
376
+ listed = d.idx.const(fn["module"], deco["args"][0] if deco["args"] else deco["kw"].get("http_method_names"))
377
+ if isinstance(listed, list) and listed:
378
+ return [str(m).upper() for m in listed]
379
+ if name == "api_view":
380
+ return ["GET"]
381
+ if name == "require_GET":
382
+ return ["GET"]
383
+ if name == "require_POST":
384
+ return ["POST"]
385
+ if name == "require_safe":
386
+ return ["GET", "HEAD"]
387
+ return ["ANY"]
388
+
389
+
390
+ def _class_view(
391
+ d: Detector,
392
+ module: str,
393
+ class_key: str,
394
+ path: str,
395
+ line: int,
396
+ mapping: dict[str, str] | None,
397
+ framework: str,
398
+ exact: bool,
399
+ ) -> None:
400
+ cls = d.idx.defs[class_key]
401
+ if mapping:
402
+ for method, action in mapping.items():
403
+ ref = d.idx.find_in_class(class_key, action)
404
+ d.endpoint(method, path, d.b.ref_node(ref) or d.handler_id(class_key), framework, module, line, exact=exact)
405
+ return
406
+ found = False
407
+ for method in HTTP_METHODS:
408
+ ref = d.idx.find_in_class(class_key, method)
409
+ if ref is not None:
410
+ d.endpoint(method, path, d.b.ref_node(ref), framework, module, line, exact=exact)
411
+ found = True
412
+ if found:
413
+ return
414
+ for base in d.idx.external_bases(class_key):
415
+ methods = GENERIC_VIEW_METHODS.get(base.rsplit(".", 1)[-1])
416
+ if methods:
417
+ for m in methods:
418
+ d.endpoint(
419
+ m,
420
+ path,
421
+ d.handler_id(class_key),
422
+ framework,
423
+ module,
424
+ line,
425
+ exact=exact,
426
+ note=f"inherited from {base.rsplit('.', 1)[-1]}",
427
+ )
428
+ return
429
+ d.endpoint(
430
+ "ANY", path, d.handler_id(class_key), framework, module, line, exact=exact, note=f"class view {cls['name']}"
431
+ )
432
+
433
+
434
+ def _urlconf_roots(d: Detector) -> list[str]:
435
+ roots = []
436
+ for var in d.idx.vars.values():
437
+ if var["name"] == "ROOT_URLCONF":
438
+ value = d.idx.const(var["module"], var.get("value"))
439
+ if isinstance(value, str) and value in d.idx.modules:
440
+ roots.append(value)
441
+ if roots:
442
+ return sorted(set(roots))
443
+ included: set[str] = set()
444
+ candidates = [m for m in d.idx.modules if f"{m}:urlpatterns" in d.idx.vars]
445
+ for m in candidates:
446
+ for entry in _url_entries(d, m, d.idx.vars[f"{m}:urlpatterns"]):
447
+ if entry and entry.get("t") == "call":
448
+ for arg in entry["args"][1:2]:
449
+ if arg and arg.get("t") == "call" and arg["f"].rsplit(".", 1)[-1] == "include" and arg["args"]:
450
+ target = d.idx.const(m, arg["args"][0])
451
+ if isinstance(target, str):
452
+ included.add(target)
453
+ return [m for m in candidates if m not in included]
454
+
455
+
456
+ def _url_entries(d: Detector, module: str, var: dict[str, Any]) -> list[dict[str, Any] | None]:
457
+ entries: list[dict[str, Any] | None] = []
458
+ for value in [var.get("value"), *[a.get("value") for a in var.get("augments", [])]]:
459
+ entries += _expand(d, module, value)
460
+ return entries
461
+
462
+
463
+ def _expand(d: Detector, module: str, value: dict[str, Any] | None, depth: int = 0) -> list[dict[str, Any] | None]:
464
+ if value is None or depth > 6:
465
+ return []
466
+ t = value.get("t")
467
+ if t == "list":
468
+ out: list[dict[str, Any] | None] = []
469
+ for item in value["v"]:
470
+ if item and item.get("t") in ("star", "name", "concat"):
471
+ out += _expand(d, module, item, depth + 1)
472
+ else:
473
+ out.append(item)
474
+ return out
475
+ if t == "concat":
476
+ return [x for part in value["v"] for x in _expand(d, module, part, depth + 1)]
477
+ if t == "star":
478
+ return _expand(d, module, value["v"], depth + 1)
479
+ if t == "name":
480
+ name = value["v"]
481
+ if name.endswith(".urls"):
482
+ return [value] # router.urls or api.urls; handled by the walker
483
+ ref = d.idx.resolve_dotted(module, name)
484
+ if ref is not None and ref.kind == "var":
485
+ var = d.idx.vars[ref.key]
486
+ return _url_entries(d, ref.module, var) if var["name"] != name else []
487
+ return [value]
488
+
489
+
490
+ def detect_django(d: Detector) -> None:
491
+ for root in _urlconf_roots(d):
492
+ _walk_urls(d, root, d.idx.vars.get(f"{root}:urlpatterns"), "", set())
493
+
494
+
495
+ def _walk_urls(d: Detector, module: str, var: dict[str, Any] | None, prefix: str, seen: set[str]) -> None:
496
+ if var is None or module in seen:
497
+ return
498
+ seen = seen | {module}
499
+ for entry in _url_entries(d, module, var):
500
+ if entry is None:
501
+ continue
502
+ if entry.get("t") == "name" and entry["v"].endswith(".urls"):
503
+ _mount(d, module, entry["v"][: -len(".urls")], prefix)
504
+ continue
505
+ if entry.get("t") != "call":
506
+ continue
507
+ fname = entry["f"].rsplit(".", 1)[-1]
508
+ if fname not in ("path", "re_path", "url") or len(entry["args"]) < 2:
509
+ continue
510
+ route = d.idx.const(module, entry["args"][0])
511
+ if not isinstance(route, str):
512
+ continue
513
+ sub, exact = django_route(route, regex=fname != "path")
514
+ full = join_paths(prefix, sub) if sub else (prefix or "/")
515
+ view = entry["args"][1]
516
+ line = entry.get("line", 0)
517
+ if view is None:
518
+ continue
519
+ if view.get("t") == "call" and view["f"].rsplit(".", 1)[-1] == "include" and view["args"]:
520
+ target = view["args"][0]
521
+ if target and target.get("t") == "list" and target["v"]:
522
+ target = target["v"][0]
523
+ text = d.idx.const(module, target)
524
+ if isinstance(text, str) and text in d.idx.modules:
525
+ _walk_urls(d, text, d.idx.vars.get(f"{text}:urlpatterns"), full, seen)
526
+ elif target and target.get("t") == "name" and target["v"].endswith(".urls"):
527
+ _mount(d, module, target["v"][: -len(".urls")], full)
528
+ elif target and target.get("t") == "name":
529
+ ref = d.idx.resolve_dotted(module, target["v"])
530
+ if ref is not None and ref.kind == "var":
531
+ _walk_urls(d, ref.module, d.idx.vars[ref.key], full, seen)
532
+ continue
533
+ if view.get("t") == "name" and view["v"].endswith(".urls"):
534
+ _mount(d, module, view["v"][: -len(".urls")], full)
535
+ continue
536
+ if view.get("t") == "call" and view["f"].endswith(".as_view"):
537
+ ref = d.idx.resolve_dotted(module, view["f"][: -len(".as_view")])
538
+ if ref is not None and ref.kind == "def" and d.idx.defs[ref.key]["kind"] == "class":
539
+ mapping = None
540
+ if view["args"] and view["args"][0] and view["args"][0].get("t") == "dict":
541
+ mapping = {k.upper(): d.idx.const(module, v) for k, v in view["args"][0]["v"].items()}
542
+ mapping = {k: v for k, v in mapping.items() if isinstance(v, str)}
543
+ framework = "drf" if _is_drf(d, ref.key) else "django"
544
+ _class_view(d, module, ref.key, full, line, mapping, framework, exact)
545
+ continue
546
+ if view.get("t") == "name":
547
+ ref = d.idx.resolve_dotted(module, view["v"])
548
+ if ref is not None and ref.kind == "def":
549
+ fn = d.idx.defs[ref.key]
550
+ if fn["kind"] == "class":
551
+ _class_view(d, module, ref.key, full, line, None, "django", exact)
552
+ continue
553
+ framework = "drf" if any(x["f"].endswith("api_view") for x in fn.get("decorators", [])) else "django"
554
+ for m in _view_methods(d, fn):
555
+ d.endpoint(m, full, d.handler_id(ref.key), framework, module, line, exact=exact)
556
+
557
+
558
+ def _is_drf(d: Detector, class_key: str) -> bool:
559
+ return any(base.startswith("rest_framework") for base in d.idx.external_bases(class_key))
560
+
561
+
562
+ def _mount(d: Detector, module: str, expr: str, prefix: str) -> None:
563
+ obj = d.object_at(module, expr)
564
+ if obj is not None and obj.kind in ("ninja-api", "drf-router"):
565
+ d.mounts[obj.key].append(prefix or "/")
566
+
567
+
568
+ # ---------------------------------------------------------------- django-ninja
569
+
570
+
571
+ def detect_ninja(d: Detector) -> None:
572
+ for module, scope, call in d.all_calls():
573
+ if not call["f"].endswith(".add_router"):
574
+ continue
575
+ parent = d.object_at(module, call["f"].rsplit(".", 1)[0], scope)
576
+ if parent is None or not parent.kind.startswith("ninja") or not call["args"]:
577
+ continue
578
+ prefix = d.idx.const(module, call["args"][0]) or ""
579
+ target = call["args"][1] if len(call["args"]) > 1 else call["kw"].get("router")
580
+ child = None
581
+ if target and target.get("t") == "name":
582
+ child = d.object_at(module, target["v"], scope)
583
+ elif target and target.get("t") == "lit" and isinstance(target["v"], str):
584
+ child = d.object_from_string(target["v"])
585
+ if child is not None and child.kind == "ninja-router":
586
+ d.parents[child.key].append((parent.key, prefix if isinstance(prefix, str) else ""))
587
+ for key, fn in d.idx.defs.items():
588
+ for deco in fn.get("decorators", []):
589
+ receiver, _, method = deco["f"].rpartition(".")
590
+ obj = d.object_at(fn["module"], receiver)
591
+ if obj is None or not obj.kind.startswith("ninja"):
592
+ continue
593
+ if method in HTTP_METHODS:
594
+ methods, path = [method], _route_path(d, fn["module"], deco)
595
+ elif method == "api_operation":
596
+ listed = d.idx.const(fn["module"], deco["args"][0] if deco["args"] else deco["kw"].get("methods"))
597
+ methods = [str(m) for m in listed] if isinstance(listed, list) else ["GET"]
598
+ path = _route_path(d, fn["module"], deco, 1)
599
+ else:
600
+ continue
601
+ if path is None:
602
+ continue
603
+ for prefix, mounted in _ninja_prefixes(d, obj.key):
604
+ for m in methods:
605
+ d.endpoint(
606
+ m, join_paths(prefix, path), d.handler_id(key), "ninja", fn["module"], deco["line"], mounted
607
+ )
608
+
609
+
610
+ def _ninja_prefixes(d: Detector, key: str, seen: frozenset[str] = frozenset()) -> list[tuple[str, bool]]:
611
+ obj = d.objects[key]
612
+ if obj.kind == "ninja-api":
613
+ mounts = d.mounts.get(key)
614
+ return [(m, True) for m in mounts] if mounts else [("", False)]
615
+ if key in seen:
616
+ return []
617
+ out = []
618
+ for parent, prefix in d.parents.get(key, []):
619
+ for base, mounted in _ninja_prefixes(d, parent, seen | {key}):
620
+ out.append((join_paths(base, prefix), mounted))
621
+ return out or [("", False)]
622
+
623
+
624
+ # ---------------------------------------------------------------- Django REST framework routers
625
+
626
+
627
+ def detect_drf(d: Detector) -> None:
628
+ for module, scope, call in d.all_calls():
629
+ if not call["f"].endswith(".register") or len(call["args"]) < 2:
630
+ continue
631
+ router = d.object_at(module, call["f"].rsplit(".", 1)[0], scope)
632
+ if router is None or router.kind != "drf-router":
633
+ continue
634
+ prefix = d.idx.const(module, call["args"][0])
635
+ target = call["args"][1]
636
+ if not isinstance(prefix, str) or not target or target.get("t") != "name":
637
+ continue
638
+ ref = d.idx.resolve_dotted(module, target["v"])
639
+ if ref is None or ref.kind != "def" or d.idx.defs[ref.key]["kind"] != "class":
640
+ continue
641
+ mounts = d.mounts.get(router.key) or [""]
642
+ for mount in mounts:
643
+ _viewset_routes(d, module, call["line"], ref.key, join_paths(mount, prefix), bool(d.mounts.get(router.key)))
644
+
645
+
646
+ def _viewset_routes(d: Detector, module: str, line: int, class_key: str, base: str, mounted: bool) -> None:
647
+ cls = d.idx.defs[class_key]
648
+ lookup = "pk"
649
+ for key in d.idx.mro(class_key):
650
+ attrs = d.idx.defs[key].get("attrs", {})
651
+ for name in ("lookup_url_kwarg", "lookup_field"):
652
+ value = d.idx.const(d.idx.defs[key]["module"], attrs.get(name))
653
+ if isinstance(value, str):
654
+ lookup = value
655
+ break
656
+ else:
657
+ continue
658
+ break
659
+ actions: list[str] = []
660
+ for action in VIEWSET_ACTIONS:
661
+ if d.idx.find_in_class(class_key, action) is not None:
662
+ actions.append(action)
663
+ for ext in d.idx.external_bases(class_key):
664
+ for action in VIEWSET_BASE_ACTIONS.get(ext.rsplit(".", 1)[-1], []):
665
+ if action not in actions:
666
+ actions.append(action)
667
+ for action in actions:
668
+ method, detail = VIEWSET_ACTIONS[action]
669
+ path = join_paths(base, "{" + lookup + "}", "/") if detail else join_paths(base, "/")
670
+ ref = d.idx.find_in_class(class_key, action)
671
+ handler = d.b.ref_node(ref) if ref else d.handler_id(class_key)
672
+ note = "" if ref else f"{action} inherited by {cls['name']}"
673
+ d.endpoint(method, path, handler, "drf", module, line, mounted, note=note)
674
+ for key in d.idx.mro(class_key):
675
+ for child_key in d.idx.children.get(key, {}).values():
676
+ fn = d.idx.defs[child_key]
677
+ deco = next((x for x in fn.get("decorators", []) if x["f"].rsplit(".", 1)[-1] == "action"), None)
678
+ if deco is None:
679
+ continue
680
+ detail = d.idx.const(fn["module"], deco["kw"].get("detail"))
681
+ methods = d.idx.const(fn["module"], deco["kw"].get("methods")) or ["get"]
682
+ url_path = d.idx.const(fn["module"], deco["kw"].get("url_path")) or fn["name"]
683
+ parts = [base, "{" + lookup + "}"] if detail else [base]
684
+ path = join_paths(*parts, str(url_path), "/")
685
+ for m in methods:
686
+ d.endpoint(str(m), path, d.handler_id(child_key), "drf", module, line, mounted)
687
+
688
+
689
+ def detect(b: PyBuild) -> None:
690
+ d = Detector(b)
691
+ d.find_objects()
692
+ if d.on("django") or d.on("ninja") or d.on("drf"):
693
+ detect_django(d)
694
+ if d.on("fastapi"):
695
+ detect_fastapi(d)
696
+ if d.on("ninja"):
697
+ detect_ninja(d)
698
+ if d.on("drf"):
699
+ detect_drf(d)
700
+ b.detector = d # type: ignore[attr-defined]