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.
- stackthread/__init__.py +6 -0
- stackthread/__main__.py +3 -0
- stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
- stackthread/_vendor/extract.mjs +15 -0
- stackthread/adapters.py +130 -0
- stackthread/cli.py +342 -0
- stackthread/config.py +409 -0
- stackthread/detect.py +150 -0
- stackthread/lang/__init__.py +1 -0
- stackthread/lang/js/__init__.py +1 -0
- stackthread/lang/js/aliases.py +133 -0
- stackthread/lang/js/build.py +455 -0
- stackthread/lang/js/worker.py +186 -0
- stackthread/lang/python/__init__.py +1 -0
- stackthread/lang/python/build.py +249 -0
- stackthread/lang/python/extract.py +494 -0
- stackthread/lang/python/frameworks.py +700 -0
- stackthread/lang/python/orm.py +300 -0
- stackthread/lang/python/resolve.py +448 -0
- stackthread/lineage.py +965 -0
- stackthread/link.py +130 -0
- stackthread/logs.py +314 -0
- stackthread/model.py +161 -0
- stackthread/paths.py +173 -0
- stackthread/project.py +414 -0
- stackthread/report.py +203 -0
- stackthread/rules.py +48 -0
- stackthread/server/__init__.py +5 -0
- stackthread/server/app.py +696 -0
- stackthread/server/edit.py +108 -0
- stackthread/server/security.py +73 -0
- stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
- stackthread/static/app.css +1 -0
- stackthread/static/app.js +29 -0
- stackthread/static/index.html +16 -0
- stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
- stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
- stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
- stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- 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]
|