cg-code-graph 0.10.1__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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Django framework plugin (on the Python plugin).
|
|
2
|
+
|
|
3
|
+
register_hooks: builds the model registry (models.py) and adds ORM type rules to the Python inference
|
|
4
|
+
(Model.objects.* -> queryset, .get()/.first()/.create() -> instance, FK attribute -> related model,
|
|
5
|
+
reverse relations, get_object_or_404).
|
|
6
|
+
contribute:
|
|
7
|
+
* settings modules (DJANGO_SETTINGS_MODULE or */settings*.py): config:settings.KEY nodes, READS_ENV from the
|
|
8
|
+
keys whose value reads the environment, READS_CONFIG for `settings.X` / getattr(settings, 'X');
|
|
9
|
+
* models -> table/column nodes (Meta.db_table or <app_label>_<model>), FK/M2M relations, ORM
|
|
10
|
+
READS/WRITES_TABLE and READS/WRITES_COLUMN edges;
|
|
11
|
+
* urlconfs from ROOT_URLCONF (or every unincluded urls module): path/re_path/url/include (nested, namespaces,
|
|
12
|
+
i18n_patterns), function views (require_http_methods / api_view), class-based views (method handlers,
|
|
13
|
+
generic views), DRF routers (ViewSets, @action, trailing_slash, nested routers), django-ninja (NinjaAPI,
|
|
14
|
+
Router, add_router chains, @router.<verb>, auth inheritance, request/response schemas), ninja-extra
|
|
15
|
+
controllers; route nodes are http_route entry points with ROUTES_TO edges;
|
|
16
|
+
* wire schemas (ninja/pydantic Schema, DRF serializers) as class attrs.schema_fields, USES_SCHEMA edges;
|
|
17
|
+
* Channels websocket routes (websocket entry points) and group_send -> consumer handler DISPATCHES;
|
|
18
|
+
* Celery tasks (queue_job entries, .delay/.apply_async/.s DISPATCHES, beat schedules = scheduled entries);
|
|
19
|
+
* signals (@receiver / .connect -> listener entries; model writes that fire post_save/post_delete; custom
|
|
20
|
+
Signal.send);
|
|
21
|
+
* management commands (management_command entries = operator-only), call_command DISPATCHES;
|
|
22
|
+
* admin registrations (admin_panel entries = operator-only) with table reads/writes.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
from ... import presets
|
|
30
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
31
|
+
from ...core.plugin import FrameworkPlugin, GraphBuilder, Project
|
|
32
|
+
from ..python.plugin import ClassInfo, Ctx, FuncInfo, ModInfo, PyProgram, ann_text, const_str, dotted, kwarg, walk_body
|
|
33
|
+
from .models import Models, emit_models, orm_edges
|
|
34
|
+
from .schemas import Schemas
|
|
35
|
+
from .urls import ACTION_ROUTES, GENERIC_METHODS, HTTP_METHODS, VIEWSET_ACTIONS, DrfRouters, Ninja, UrlResolver, regex_to_template
|
|
36
|
+
from . import extras
|
|
37
|
+
|
|
38
|
+
# view-level access checks recorded on route nodes (attrs.access); `routes` / `search` read them
|
|
39
|
+
# (codegraph/presets/django.yaml `access`)
|
|
40
|
+
ACCESS_DECORATORS = set(presets.values("django", "access", "decorators", default=[]))
|
|
41
|
+
ACCESS_NAME = re.compile(presets.values("django", "access", "name_pattern"), re.I)
|
|
42
|
+
ACCESS_OPT_OUT = re.compile(presets.values("django", "access", "opt_out_pattern"), re.I) # login_not_required, csrf_exempt
|
|
43
|
+
ACCESS_MIXINS = set(presets.values("django", "access", "mixins", default=[]))
|
|
44
|
+
SECRET_NAME = re.compile(presets.values("common", "secret", "token_pattern")) # on word tokens (verify_signature)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def project_access_rx(project) -> re.Pattern | None:
|
|
48
|
+
"""The project's .cg.yaml auth / secret extra_patterns: decorators matching them are recorded as access checks."""
|
|
49
|
+
cfg = (project.options.get("config") or {}) if project is not None else {}
|
|
50
|
+
pats = [p for k in ("auth", "secret") for p in ((cfg.get(k) or {}).get("extra_patterns") or [])]
|
|
51
|
+
return re.compile("|".join(f"(?:{p})" for p in pats), re.I) if pats else None
|
|
52
|
+
|
|
53
|
+
CONSUMER_BASES = ("WebsocketConsumer", "AsyncWebsocketConsumer", "JsonWebsocketConsumer", "AsyncJsonWebsocketConsumer",
|
|
54
|
+
"AsyncConsumer", "SyncConsumer", "AsyncHttpConsumer", "GenericAsyncAPIConsumer", "ObserverModelInstanceMixin")
|
|
55
|
+
CONSUMER_HANDLERS = ("connect", "receive", "receive_json", "disconnect", "websocket_connect", "websocket_receive",
|
|
56
|
+
"websocket_disconnect", "handle", "http_request")
|
|
57
|
+
DRF_HOOKS = ("get_queryset", "get_serializer_class", "get_object", "perform_create", "perform_update", "perform_destroy",
|
|
58
|
+
"filter_queryset", "get_serializer_context", "get_permissions", "initial", "paginate_queryset", "get_serializer")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mod_of(path):
|
|
62
|
+
return ("/".join(path.split("/")[:-1]) or None) if path else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DjangoPlugin(FrameworkPlugin):
|
|
66
|
+
name, language = "django", "python"
|
|
67
|
+
|
|
68
|
+
def detect(self, project: Project) -> bool:
|
|
69
|
+
if project.exists("manage.py") or any(project.root.glob("*/manage.py")) or any(project.root.glob("*/*/manage.py")):
|
|
70
|
+
return True
|
|
71
|
+
for f in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile", "requirements/base.txt"):
|
|
72
|
+
p = project.root / f
|
|
73
|
+
if p.exists():
|
|
74
|
+
try:
|
|
75
|
+
txt = p.read_text(errors="replace")
|
|
76
|
+
except OSError:
|
|
77
|
+
continue
|
|
78
|
+
if re.search(r"(?im)^\s*[\"']?(django|djangorestframework|django-ninja)\b", txt) or \
|
|
79
|
+
re.search(r"(?i)[\"'](django|django-ninja|djangorestframework)[<>=~\"' ]", txt):
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def register_hooks(self, prog: PyProgram) -> None:
|
|
84
|
+
self.prog = prog
|
|
85
|
+
self.models = Models(prog)
|
|
86
|
+
self.models.build()
|
|
87
|
+
prog.attr_rules.append(self.models.attr_rule)
|
|
88
|
+
prog.call_rules.append(self.models.call_rule)
|
|
89
|
+
|
|
90
|
+
def contribute(self, project: Project, builder: GraphBuilder, prog: PyProgram) -> dict:
|
|
91
|
+
self.b = b = builder
|
|
92
|
+
self.prog = prog
|
|
93
|
+
self.access_rx = project_access_rx(project)
|
|
94
|
+
st = {}
|
|
95
|
+
self.schemas = Schemas(prog, self.models)
|
|
96
|
+
from .shapes import ShapeBuilder
|
|
97
|
+
self.shapes = ShapeBuilder(self)
|
|
98
|
+
st["models"] = emit_models(self.models, b)
|
|
99
|
+
st["settings"] = extras.settings(self)
|
|
100
|
+
self.drf = DrfRouters(prog)
|
|
101
|
+
self.ninja = Ninja(prog)
|
|
102
|
+
self.urls = UrlResolver(prog, self)
|
|
103
|
+
self.route_list = self.urls.routes
|
|
104
|
+
roots = self.url_roots()
|
|
105
|
+
for m in roots:
|
|
106
|
+
self.urls.walk(m, "", [], 0, [])
|
|
107
|
+
orphan_roots = []
|
|
108
|
+
if self.root_urlconf_found:
|
|
109
|
+
for m in prog.modules.values():
|
|
110
|
+
if m.name not in self.urls.included and "urlpatterns" in m.vars and "test" not in m.name:
|
|
111
|
+
orphan_roots.append(m.name)
|
|
112
|
+
self.ninja_routes()
|
|
113
|
+
self.drf_routes()
|
|
114
|
+
self.channels()
|
|
115
|
+
n_routes = self.emit_routes()
|
|
116
|
+
ops = {"orm_reads": 0, "orm_writes": 0}
|
|
117
|
+
for f in prog.funcs.values():
|
|
118
|
+
orm_edges(self.models, b, f, ops)
|
|
119
|
+
st["orm"] = ops
|
|
120
|
+
st["settings_reads"] = extras.settings_reads(self)
|
|
121
|
+
st["admin"] = extras.admin(self)
|
|
122
|
+
st["signals"] = extras.signals(self)
|
|
123
|
+
st["celery"] = extras.celery(self)
|
|
124
|
+
st["channels_dispatch"] = extras.group_sends(self)
|
|
125
|
+
st["commands"] = extras.commands(self)
|
|
126
|
+
st["schemas"] = self.emit_schemas()
|
|
127
|
+
st["routes"] = n_routes
|
|
128
|
+
st["root_urlconf"] = [m.name for m in roots]
|
|
129
|
+
st["urlconf_unresolved"] = self.urls.unresolved[:30]
|
|
130
|
+
prog.url_unresolved = self.urls.unresolved # read by the blind-spot detectors (codegraph/blindspots.py)
|
|
131
|
+
st["urlconf_unincluded"] = orphan_roots[:30]
|
|
132
|
+
st["ninja"] = {"apis": sum(1 for v in self.ninja.objs.values() if v["kind"] == "api"),
|
|
133
|
+
"routers": sum(1 for v in self.ninja.objs.values() if v["kind"] == "router"),
|
|
134
|
+
"operations": len(self.ninja.ops), "unmounted_ops": self._unmounted_ops}
|
|
135
|
+
st["drf"] = {"routers": len(self.drf.routers), "registrations": sum(len(r["regs"]) for r in self.drf.routers.values())}
|
|
136
|
+
return st
|
|
137
|
+
|
|
138
|
+
def setting_str(self, key: str) -> str | None:
|
|
139
|
+
for m, v in reversed(self.settings_values.get(key, [])):
|
|
140
|
+
s = const_str(v)
|
|
141
|
+
if s:
|
|
142
|
+
return s
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def url_roots(self) -> list[ModInfo]:
|
|
146
|
+
prog = self.prog
|
|
147
|
+
self.root_urlconf_found = False
|
|
148
|
+
rcs = []
|
|
149
|
+
for m, v in self.settings_values.get("ROOT_URLCONF", []):
|
|
150
|
+
s = const_str(v)
|
|
151
|
+
if s and prog.module(s) and prog.module(s) not in rcs:
|
|
152
|
+
rcs.append(prog.module(s))
|
|
153
|
+
if rcs: # one per Django project in the repo
|
|
154
|
+
self.root_urlconf_found = True
|
|
155
|
+
return rcs
|
|
156
|
+
cands = [m for m in prog.modules.values() if "urlpatterns" in m.vars]
|
|
157
|
+
included = set()
|
|
158
|
+
for m in cands:
|
|
159
|
+
for sub in ast.walk(m.tree):
|
|
160
|
+
if isinstance(sub, ast.Call) and (dotted(sub.func) or "").split(".")[-1] == "include" and sub.args:
|
|
161
|
+
a = sub.args[0]
|
|
162
|
+
if isinstance(a, ast.Tuple) and a.elts:
|
|
163
|
+
a = a.elts[0]
|
|
164
|
+
s = const_str(a)
|
|
165
|
+
if s:
|
|
166
|
+
tm = prog.module(s)
|
|
167
|
+
included.add(tm.name if tm else s)
|
|
168
|
+
else:
|
|
169
|
+
t = prog.infer(a, Ctx(m, None, None))
|
|
170
|
+
if t and t[0] == "mod":
|
|
171
|
+
included.add(t[1].name)
|
|
172
|
+
return sorted((m for m in cands if m.name not in included), key=lambda m: m.name)
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ views -> routes
|
|
175
|
+
def http_methods_of_func(self, f: FuncInfo) -> tuple[list[str], str | None]:
|
|
176
|
+
for d in f.decorators:
|
|
177
|
+
name = (dotted(d.func if isinstance(d, ast.Call) else d) or "").split(".")[-1]
|
|
178
|
+
if name in ("require_GET", "require_safe"):
|
|
179
|
+
return ["GET"], name
|
|
180
|
+
if name == "require_POST":
|
|
181
|
+
return ["POST"], name
|
|
182
|
+
if name in ("require_http_methods", "api_view"):
|
|
183
|
+
a = (d.args[0] if d.args else kwarg(d, "http_method_names")) if isinstance(d, ast.Call) else None
|
|
184
|
+
if isinstance(a, (ast.List, ast.Tuple)):
|
|
185
|
+
return [const_str(x).upper() for x in a.elts if const_str(x)], name
|
|
186
|
+
if name == "api_view":
|
|
187
|
+
return ["GET"], name
|
|
188
|
+
return ["ANY"], None
|
|
189
|
+
|
|
190
|
+
def _route(self, **kw):
|
|
191
|
+
kw.setdefault("framework", "django")
|
|
192
|
+
kw.setdefault("mounted", True)
|
|
193
|
+
if not kw["uri"].startswith("/"):
|
|
194
|
+
kw["uri"] = "/" + kw["uri"]
|
|
195
|
+
self.route_list.append(kw)
|
|
196
|
+
|
|
197
|
+
def emit_fbv(self, f: FuncInfo, full, where, name, ns, chain, conds, ok, raw):
|
|
198
|
+
methods, how = self.http_methods_of_func(f)
|
|
199
|
+
fw = "drf" if how == "api_view" else "django"
|
|
200
|
+
for mth in methods:
|
|
201
|
+
self._route(method=mth, uri=full, file=where["file"], line=where["line"], handler=f, view=f.qual, framework=fw,
|
|
202
|
+
name=name, namespace=":".join(n for n in ns if n) or None, conf=EXACT if ok else HEURISTIC,
|
|
203
|
+
chain=chain, conds=list(conds), method_source=how or "any (function view)", raw=raw)
|
|
204
|
+
|
|
205
|
+
def emit_cbv(self, t, expr, full, where, name, ns, chain, conds, ok, actions, raw):
|
|
206
|
+
prog = self.prog
|
|
207
|
+
base = dict(uri=full, file=where["file"], line=where["line"], name=name, namespace=":".join(n for n in ns if n) or None,
|
|
208
|
+
conf=EXACT if ok else HEURISTIC, chain=chain, conds=list(conds), raw=raw)
|
|
209
|
+
if not t or t[0] != "type":
|
|
210
|
+
ext = t[1] if t and t[0] in ("ext", "einst") else ann_text(expr)
|
|
211
|
+
last = (ext or "").split(".")[-1]
|
|
212
|
+
for mth in sorted(GENERIC_METHODS.get(last) or []) or ["ANY"]:
|
|
213
|
+
self._route(method=mth, handler=None, view=ext, external_view=ext, **base)
|
|
214
|
+
return
|
|
215
|
+
c: ClassInfo = t[1]
|
|
216
|
+
if prog.subclass_of(c, *CONSUMER_BASES):
|
|
217
|
+
hs = [prog.find_method(c, h) for h in CONSUMER_HANDLERS]
|
|
218
|
+
self._route(method="WS", handler=c, handlers=[h for h in hs if h], view=c.qual, framework="channels",
|
|
219
|
+
entry_kind="websocket", **base)
|
|
220
|
+
return
|
|
221
|
+
if actions:
|
|
222
|
+
for verb, act in actions.items():
|
|
223
|
+
h = prog.find_method(c, act) if act else None
|
|
224
|
+
self._route(method=verb.upper(), handler=h or c, view=f"{c.qual}.{act}", framework="drf", action=act,
|
|
225
|
+
viewset=c, generic=h is None, **base)
|
|
226
|
+
return
|
|
227
|
+
found = [(v.upper(), prog.find_method(c, v)) for v in HTTP_METHODS if prog.find_method(c, v)]
|
|
228
|
+
ext_ms = set()
|
|
229
|
+
for e in prog.ext_bases(c):
|
|
230
|
+
ext_ms |= GENERIC_METHODS.get(e.split(".")[-1], set())
|
|
231
|
+
hm = c.attrs.get("http_method_names")
|
|
232
|
+
allowed = {const_str(x).upper() for x in hm[0].elts if const_str(x)} if hm and isinstance(hm[0], (ast.List, ast.Tuple)) else None
|
|
233
|
+
verbs = {v for v, _ in found} | ext_ms
|
|
234
|
+
if allowed:
|
|
235
|
+
verbs &= allowed
|
|
236
|
+
fw = "drf" if prog.subclass_of(c, "APIView", "GenericAPIView", "ViewSet", "GenericViewSet") else "django"
|
|
237
|
+
if not verbs:
|
|
238
|
+
self._route(method="ANY", handler=c, view=c.qual, framework=fw, **base)
|
|
239
|
+
return
|
|
240
|
+
hmap = dict(found)
|
|
241
|
+
for v in sorted(verbs):
|
|
242
|
+
if v in ("HEAD", "OPTIONS") and v not in hmap:
|
|
243
|
+
continue
|
|
244
|
+
self._route(method=v, handler=hmap.get(v) or c, view=c.qual, framework=fw, generic=v not in hmap, viewset=c, **base)
|
|
245
|
+
|
|
246
|
+
# ---- DRF routers
|
|
247
|
+
def viewset_actions(self, c: ClassInfo) -> set[str]:
|
|
248
|
+
acts = set()
|
|
249
|
+
for e in self.prog.ext_bases(c):
|
|
250
|
+
acts |= VIEWSET_ACTIONS.get(e.split(".")[-1], set())
|
|
251
|
+
for a, _, _ in ACTION_ROUTES:
|
|
252
|
+
if self.prog.find_method(c, a):
|
|
253
|
+
acts.add(a)
|
|
254
|
+
return acts
|
|
255
|
+
|
|
256
|
+
def class_attr_str(self, c, name):
|
|
257
|
+
for k in [c] + [x[1] for x in self.prog.mro(c) if x[0] == "type"]:
|
|
258
|
+
if name in k.attrs:
|
|
259
|
+
return const_str(k.attrs[name][0])
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
def all_methods(self, c: ClassInfo):
|
|
263
|
+
seen = set()
|
|
264
|
+
for k in [c] + [x[1] for x in self.prog.mro(c) if x[0] == "type"]:
|
|
265
|
+
for n, f in k.methods.items():
|
|
266
|
+
if n not in seen:
|
|
267
|
+
seen.add(n)
|
|
268
|
+
yield f
|
|
269
|
+
|
|
270
|
+
def drf_routes(self):
|
|
271
|
+
prog = self.prog
|
|
272
|
+
for key, r in self.drf.routers.items():
|
|
273
|
+
mounts = self.drf.mounts.get(key) or []
|
|
274
|
+
if r.get("parent") and not mounts:
|
|
275
|
+
mounts = [dict(pm) for pm in self.drf.mounts.get(r["parent"]) or []]
|
|
276
|
+
mounted = bool(mounts)
|
|
277
|
+
if not mounts:
|
|
278
|
+
mounts = [{"prefix": "", "file": r["file"], "line": r["line"], "chain": [], "conds": (), "ns": []}]
|
|
279
|
+
slash = "/" if r["trailing_slash"] else ""
|
|
280
|
+
for mt in mounts:
|
|
281
|
+
pre = mt["prefix"]
|
|
282
|
+
if r.get("parent"):
|
|
283
|
+
pre = pre + (r.get("parent_prefix") or "") + "/{" + r.get("lookup", "parent") + "_pk}/"
|
|
284
|
+
if r["default"] and not r.get("parent"):
|
|
285
|
+
self._route(method="GET", uri=pre or "/", file=r["file"], line=r["line"], handler=None, view="api-root",
|
|
286
|
+
framework="drf", conf=EXACT, chain=mt["chain"], conds=list(mt["conds"]), mounted=mounted,
|
|
287
|
+
name="api-root", namespace=None)
|
|
288
|
+
for reg in self.drf.regs(key):
|
|
289
|
+
t = prog.infer(reg["viewset"], Ctx(reg["module"], None, None))
|
|
290
|
+
kw = dict(file=reg["file"], line=reg["line"], framework="drf", conf=EXACT if reg["prefix"] != "{?}" else HEURISTIC,
|
|
291
|
+
chain=mt["chain"] + [f"{reg['file']}:{reg['line']}"], conds=list(mt["conds"]), mounted=mounted,
|
|
292
|
+
namespace=":".join(n for n in mt.get("ns", []) if n) or None, router=f"{r['file']}:{r['line']}")
|
|
293
|
+
pfx = reg["prefix"]
|
|
294
|
+
if not t or t[0] != "type":
|
|
295
|
+
self._route(method="ANY", uri=pre + pfx + slash, handler=None, view=ann_text(reg["viewset"]), **kw)
|
|
296
|
+
continue
|
|
297
|
+
c = t[1]
|
|
298
|
+
lk = self.class_attr_str(c, "lookup_url_kwarg") or self.class_attr_str(c, "lookup_field") or "pk"
|
|
299
|
+
acts = self.viewset_actions(c)
|
|
300
|
+
base = reg["basename"] or self.drf_basename(c) or pfx
|
|
301
|
+
for act, verb, detail in ACTION_ROUTES:
|
|
302
|
+
if act not in acts:
|
|
303
|
+
continue
|
|
304
|
+
h = prog.find_method(c, act)
|
|
305
|
+
self._route(method=verb, uri=pre + pfx + ("/{" + lk + "}" if detail else "") + slash, handler=h or c,
|
|
306
|
+
view=f"{c.qual}.{act}", action=act, viewset=c, generic=h is None,
|
|
307
|
+
name=f"{base}-{'detail' if detail else 'list'}", **kw)
|
|
308
|
+
for f in self.all_methods(c):
|
|
309
|
+
for d in f.decorators:
|
|
310
|
+
dn = (dotted(d.func if isinstance(d, ast.Call) else d) or "").split(".")[-1]
|
|
311
|
+
if dn not in ("action", "detail_route", "list_route"):
|
|
312
|
+
continue
|
|
313
|
+
detail, ms, url_path, uname = dn == "detail_route", ["GET"], f.name, None
|
|
314
|
+
if isinstance(d, ast.Call):
|
|
315
|
+
dv = kwarg(d, "detail")
|
|
316
|
+
if isinstance(dv, ast.Constant):
|
|
317
|
+
detail = bool(dv.value)
|
|
318
|
+
mv = kwarg(d, "methods")
|
|
319
|
+
if isinstance(mv, (ast.List, ast.Tuple)):
|
|
320
|
+
ms = [const_str(x).upper() for x in mv.elts if const_str(x)] or ms
|
|
321
|
+
url_path = const_str(kwarg(d, "url_path")) or url_path
|
|
322
|
+
if "(" in url_path or "\\" in url_path: # regex url_path: versions/(?P<version_id>\d+)
|
|
323
|
+
url_path = regex_to_template(url_path)[0].strip("/")
|
|
324
|
+
uname = const_str(kwarg(d, "url_name"))
|
|
325
|
+
uri = pre + pfx + ("/{" + lk + "}" if detail else "") + "/" + url_path + slash
|
|
326
|
+
# DRF names an extra action's route '<basename>-<url_name>' (url_name defaults to the
|
|
327
|
+
# method name with '-' for '_'), so reverse('review-upvote') finds it
|
|
328
|
+
rname = f"{base}-{uname or f.name.replace('_', '-')}"
|
|
329
|
+
for mth in ms:
|
|
330
|
+
self._route(method=mth, uri=uri, handler=f, view=f.qual, action=f.name, viewset=c,
|
|
331
|
+
name=rname, extra_action=True, **kw)
|
|
332
|
+
|
|
333
|
+
# ---- ninja
|
|
334
|
+
def ninja_routes(self):
|
|
335
|
+
chains = self.ninja.mount_chains(self.urls.mounts)
|
|
336
|
+
self._unmounted_ops = 0
|
|
337
|
+
orphan = [{"prefixes": [""], "auths": [None], "evidence": [], "mounted": False, "api": None}]
|
|
338
|
+
for op in self.ninja.ops:
|
|
339
|
+
for ch in chains.get(op["key"]) or orphan:
|
|
340
|
+
uri = Ninja.full_path(ch["prefixes"][0] or "", ch["prefixes"][1:], op["path"])
|
|
341
|
+
if op["auth_set"]:
|
|
342
|
+
auth, src = op["auth"], "operation"
|
|
343
|
+
else:
|
|
344
|
+
auth, src = None, None
|
|
345
|
+
for a in reversed(ch["auths"]):
|
|
346
|
+
if a is not None:
|
|
347
|
+
auth, src = a, "router/api"
|
|
348
|
+
break
|
|
349
|
+
auth_txt = None if auth is None or (isinstance(auth, ast.Constant) and auth.value is None) else ann_text(auth)
|
|
350
|
+
if not ch["mounted"]:
|
|
351
|
+
self._unmounted_ops += 1
|
|
352
|
+
ev = [f"{e['file']}:{e['line']}" for e in ch["evidence"] if e]
|
|
353
|
+
for mth in op["methods"]:
|
|
354
|
+
self._route(method=mth, uri=uri, file=op["file"], line=op["line"], handler=op["func"], view=op["func"].qual,
|
|
355
|
+
framework="ninja", conf=EXACT if "{?}" not in uri else HEURISTIC, chain=ev, conds=[],
|
|
356
|
+
mounted=ch["mounted"], name=op["url_name"], namespace=None, auth=auth_txt,
|
|
357
|
+
auth_source=src or "none", ninja_op=op,
|
|
358
|
+
api=f"{ch['api'][0]}.{ch['api'][1]}" if ch.get("api") else None)
|
|
359
|
+
for ctl in self.ninja.controllers:
|
|
360
|
+
c = ctl["cls"]
|
|
361
|
+
for f in c.methods.values():
|
|
362
|
+
for d in f.decorators:
|
|
363
|
+
if not isinstance(d, ast.Call):
|
|
364
|
+
continue
|
|
365
|
+
dn = dotted(d.func) or ""
|
|
366
|
+
last = dn.split(".")[-1]
|
|
367
|
+
verb = last[5:] if last.startswith("http_") else (last if dn.startswith("route.") else None)
|
|
368
|
+
if verb not in HTTP_METHODS:
|
|
369
|
+
continue
|
|
370
|
+
p = const_str(d.args[0]) if d.args else (const_str(kwarg(d, "path")) or "")
|
|
371
|
+
self._route(method=verb.upper(), uri=Ninja.full_path("", [ctl["prefix"]], p or ""), file=f.file, line=d.lineno,
|
|
372
|
+
handler=f, view=f.qual, framework="ninja-extra", conf=RESOLVED, chain=[], conds=[], mounted=False,
|
|
373
|
+
name=None, namespace=None, auth=ann_text(kwarg(d, "auth") or ctl["auth"]), auth_source="controller")
|
|
374
|
+
|
|
375
|
+
# ---- channels
|
|
376
|
+
def channels(self):
|
|
377
|
+
prog = self.prog
|
|
378
|
+
seen = set()
|
|
379
|
+
for m in prog.modules.values():
|
|
380
|
+
roots = [("websocket_urlpatterns", None)] if "websocket_urlpatterns" in m.vars else []
|
|
381
|
+
for sub in ast.walk(m.tree):
|
|
382
|
+
if isinstance(sub, ast.Call) and (dotted(sub.func) or "").split(".")[-1] == "URLRouter" and sub.args:
|
|
383
|
+
roots.append((None, sub.args[0]))
|
|
384
|
+
for var, expr in roots:
|
|
385
|
+
if var:
|
|
386
|
+
if (m.name, var) not in seen:
|
|
387
|
+
seen.add((m.name, var))
|
|
388
|
+
self.urls.walk(m, "", [], 0, [], var=var)
|
|
389
|
+
continue
|
|
390
|
+
t = prog.infer(expr, Ctx(m, None, None)) if isinstance(expr, (ast.Name, ast.Attribute)) else None
|
|
391
|
+
if t and t[0] == "var":
|
|
392
|
+
if (t[1].name, t[2]) not in seen:
|
|
393
|
+
seen.add((t[1].name, t[2]))
|
|
394
|
+
self.urls.walk(t[1], "", [], 0, [], var=t[2])
|
|
395
|
+
elif isinstance(expr, (ast.List, ast.Tuple)):
|
|
396
|
+
self.urls.walk(m, "", [], 0, [], var=f"<urlrouter@{expr.lineno}>", items=self.urls.eval_list(m, expr))
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------ route nodes
|
|
399
|
+
def emit_routes(self) -> int:
|
|
400
|
+
b = self.b
|
|
401
|
+
n = 0
|
|
402
|
+
for r in self.route_list:
|
|
403
|
+
uri = re.sub(r"/{2,}", "/", r["uri"])
|
|
404
|
+
key = f"{r['method']} {uri}"
|
|
405
|
+
h = r.get("handler")
|
|
406
|
+
attrs = {"uri": uri, "method": r["method"], "framework": r.get("framework"), "name": r.get("name"),
|
|
407
|
+
"namespace": r.get("namespace"), "view": r.get("view"), "mounted": r.get("mounted", True),
|
|
408
|
+
"urlconf_chain": r.get("chain") or None, "conditions": r.get("conds") or None,
|
|
409
|
+
"trailing_slash": uri.endswith("/"), "path_params": re.findall(r"\{(\w+)\}", uri)}
|
|
410
|
+
for k in ("auth", "auth_source", "action", "external_view", "method_source", "generic", "router", "api", "raw", "extra_action"):
|
|
411
|
+
if r.get(k) is not None:
|
|
412
|
+
attrs[k] = r[k]
|
|
413
|
+
access = self.access_of(h, r.get("viewset"))
|
|
414
|
+
if access:
|
|
415
|
+
attrs["access"] = access
|
|
416
|
+
if isinstance(h, (FuncInfo, ClassInfo)):
|
|
417
|
+
attrs["handler"] = h.id
|
|
418
|
+
entry = r.get("entry_kind") or ("http_route" if r.get("mounted", True) else None)
|
|
419
|
+
if r.get("framework") == "django-admin":
|
|
420
|
+
entry = "admin_panel"
|
|
421
|
+
rid = b.add_node("route", key, name=key, file=r["file"], line=r["line"], module=mod_of(r["file"]), lang="python",
|
|
422
|
+
entry_kind=entry, attrs=attrs)
|
|
423
|
+
n += 1
|
|
424
|
+
conf = r.get("conf", EXACT)
|
|
425
|
+
if isinstance(h, (FuncInfo, ClassInfo)):
|
|
426
|
+
b.add_edge(rid, h.id, "ROUTES_TO", r["file"], r["line"], conf)
|
|
427
|
+
if isinstance(h, ClassInfo):
|
|
428
|
+
for hh in r.get("handlers") or []:
|
|
429
|
+
b.add_edge(rid, hh.id, "ROUTES_TO", r["file"], r["line"], RESOLVED, via="consumer-handler")
|
|
430
|
+
self.class_hooks(h)
|
|
431
|
+
elif isinstance(h, FuncInfo) and h.cls is not None:
|
|
432
|
+
self.class_hooks(h.cls)
|
|
433
|
+
self.payload_facts(rid, r, h)
|
|
434
|
+
return n
|
|
435
|
+
|
|
436
|
+
access_rx = None
|
|
437
|
+
|
|
438
|
+
def access_of(self, h, viewset=None) -> list[dict]:
|
|
439
|
+
"""Access checks declared on the view: auth decorators (login_required, permission_required, DRF
|
|
440
|
+
@permission_classes, method_decorator(...)), access mixins (LoginRequiredMixin, ...) and DRF
|
|
441
|
+
permission_classes / authentication_classes (class attribute or @action kwarg). Project-wide defaults
|
|
442
|
+
(REST_FRAMEWORK DEFAULT_PERMISSION_CLASSES, middleware) are not per-view and are not listed."""
|
|
443
|
+
out: list[dict] = []
|
|
444
|
+
|
|
445
|
+
def add(name, via):
|
|
446
|
+
if name and not any(o["name"] == name for o in out):
|
|
447
|
+
out.append({"name": name, "via": via})
|
|
448
|
+
|
|
449
|
+
def elems(v):
|
|
450
|
+
if isinstance(v, (ast.List, ast.Tuple, ast.Set)):
|
|
451
|
+
return [ann_text(x.func if isinstance(x, ast.Call) else x) for x in v.elts]
|
|
452
|
+
return [ann_text(v)] if v is not None else []
|
|
453
|
+
|
|
454
|
+
def decos(decs, via):
|
|
455
|
+
for d in decs or []:
|
|
456
|
+
fn = d.func if isinstance(d, ast.Call) else d
|
|
457
|
+
nm = (dotted(fn) or "").split(".")[-1]
|
|
458
|
+
if nm == "method_decorator" and isinstance(d, ast.Call) and d.args:
|
|
459
|
+
for x in elems(d.args[0]):
|
|
460
|
+
add(x.split(".")[-1], f"{via} method_decorator")
|
|
461
|
+
elif nm in ("permission_classes", "authentication_classes") and isinstance(d, ast.Call) and d.args:
|
|
462
|
+
for x in elems(d.args[0]):
|
|
463
|
+
add(x.split(".")[-1], f"@{nm}")
|
|
464
|
+
elif nm == "action" and isinstance(d, ast.Call):
|
|
465
|
+
for kw in ("permission_classes", "authentication_classes"):
|
|
466
|
+
for x in elems(kwarg(d, kw)):
|
|
467
|
+
add(x.split(".")[-1], f"@action {kw}")
|
|
468
|
+
elif (nm in ACCESS_DECORATORS or ACCESS_NAME.search(nm) or SECRET_NAME.search(presets.name_tokens(nm))
|
|
469
|
+
or (self.access_rx and self.access_rx.search(nm))) and not ACCESS_OPT_OUT.search(nm):
|
|
470
|
+
add(nm, via)
|
|
471
|
+
|
|
472
|
+
cls = h if isinstance(h, ClassInfo) else (h.cls if isinstance(h, FuncInfo) else None)
|
|
473
|
+
if isinstance(h, FuncInfo):
|
|
474
|
+
decos(h.decorators, "decorator")
|
|
475
|
+
for c in [x for x in (cls, viewset) if isinstance(x, ClassInfo)][:1]:
|
|
476
|
+
decos(c.decorators, "class decorator")
|
|
477
|
+
for b in self.prog.lineage(c):
|
|
478
|
+
last = b.split(".")[-1]
|
|
479
|
+
if last in ACCESS_MIXINS or last.endswith("RequiredMixin"):
|
|
480
|
+
add(last, "mixin")
|
|
481
|
+
for k in [c] + [x[1] for x in self.prog.mro(c) if x[0] == "type"]:
|
|
482
|
+
for attr in ("permission_classes", "authentication_classes"):
|
|
483
|
+
if attr in k.attrs and not any(o["via"] == attr for o in out):
|
|
484
|
+
for x in elems(k.attrs[attr][0]):
|
|
485
|
+
add(x.split(".")[-1], attr)
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
def class_hooks(self, c: ClassInfo):
|
|
489
|
+
"""Generic (inherited) view actions run the class's hook overrides and use its queryset/serializer."""
|
|
490
|
+
if getattr(c, "_hooked", False):
|
|
491
|
+
return
|
|
492
|
+
c._hooked = True
|
|
493
|
+
b, prog = self.b, self.prog
|
|
494
|
+
for hname in DRF_HOOKS + ("get_context_data", "form_valid", "get_success_url", "dispatch", "setup"):
|
|
495
|
+
f = prog.find_method(c, hname)
|
|
496
|
+
if f:
|
|
497
|
+
b.add_edge(c.id, f.id, "CALLS", f.file, f.line, RESOLVED, via="framework-hook")
|
|
498
|
+
for attr, kind in (("queryset", "qs"), ("model", "type")):
|
|
499
|
+
x = self.class_attr_expr(c, attr)
|
|
500
|
+
if x is None:
|
|
501
|
+
continue
|
|
502
|
+
t = prog.infer(x[0], Ctx(x[1].module, None, x[1]))
|
|
503
|
+
if t and t[0] == kind and self.models.table(t[1].qual):
|
|
504
|
+
b.add_edge(c.id, f"table:{self.models.table(t[1].qual)}", "READS_TABLE", x[1].file, x[0].lineno, RESOLVED, via=attr)
|
|
505
|
+
ser = self.class_attr_expr(c, "serializer_class")
|
|
506
|
+
if ser is not None:
|
|
507
|
+
t = prog.infer(ser[0], Ctx(ser[1].module, None, ser[1]))
|
|
508
|
+
if t and t[0] == "type":
|
|
509
|
+
b.add_edge(c.id, t[1].id, "USES_SCHEMA", ser[1].file, ser[0].lineno, RESOLVED, role="serializer_class")
|
|
510
|
+
|
|
511
|
+
def drf_basename(self, c: ClassInfo) -> str | None:
|
|
512
|
+
"""DRF's default router basename: the lowercased model name of the viewset's `queryset`."""
|
|
513
|
+
x = self.class_attr_expr(c, "queryset")
|
|
514
|
+
if x is None:
|
|
515
|
+
return None
|
|
516
|
+
t = self.prog.infer(x[0], Ctx(x[1].module, None, x[1]))
|
|
517
|
+
return t[1].name.lower() if t and t[0] == "qs" else None
|
|
518
|
+
|
|
519
|
+
def class_attr_expr(self, c: ClassInfo, name):
|
|
520
|
+
for k in [c] + [x[1] for x in self.prog.mro(c) if x[0] == "type"]:
|
|
521
|
+
if name in k.attrs and k.attrs[name][0] is not None:
|
|
522
|
+
return k.attrs[name][0], k
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
# ---- payload facts (request/response shape per route)
|
|
526
|
+
def schema_ref(self, e, ctx) -> dict | None:
|
|
527
|
+
if e is None:
|
|
528
|
+
return None
|
|
529
|
+
if isinstance(e, ast.Subscript):
|
|
530
|
+
head = (dotted(e.value) or "").split(".")[-1]
|
|
531
|
+
sl = e.slice.elts[0] if isinstance(e.slice, ast.Tuple) else e.slice
|
|
532
|
+
if head in ("Form", "Body", "Query", "Path", "File", "Optional", "List", "list", "Sequence", "Annotated", "Iterable"):
|
|
533
|
+
r = self.schema_ref(sl, ctx)
|
|
534
|
+
if r:
|
|
535
|
+
if head in ("List", "list", "Sequence", "Iterable"):
|
|
536
|
+
r["many"] = True
|
|
537
|
+
if head in ("Form", "Body", "Query", "File", "Path"):
|
|
538
|
+
r["location"] = head.lower()
|
|
539
|
+
return r
|
|
540
|
+
return None
|
|
541
|
+
if isinstance(e, ast.List) and e.elts:
|
|
542
|
+
r = self.schema_ref(e.elts[0], ctx)
|
|
543
|
+
if r:
|
|
544
|
+
r["many"] = True
|
|
545
|
+
return r
|
|
546
|
+
t = self.prog.infer(e, ctx)
|
|
547
|
+
if t and t[0] == "type" and self.schemas.kind(t[1]):
|
|
548
|
+
return {"class": t[1].qual, "many": False}
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def payload_facts(self, rid, r, h):
|
|
552
|
+
b, prog = self.b, self.prog
|
|
553
|
+
a = b.nodes[rid].attrs
|
|
554
|
+
req, resp = {"schemas": [], "keys": [], "query": [], "files": []}, {"schemas": [], "keys": []}
|
|
555
|
+
op = r.get("ninja_op")
|
|
556
|
+
if isinstance(h, FuncInfo):
|
|
557
|
+
ctx = Ctx(h.module, h, h.cls)
|
|
558
|
+
if op is not None:
|
|
559
|
+
self.ninja_signature(h, op, a, req, resp)
|
|
560
|
+
self.body_facts(h, ctx, req, resp)
|
|
561
|
+
vs = r.get("viewset")
|
|
562
|
+
if isinstance(vs, ClassInfo) and r.get("framework") == "drf" and (r.get("generic") or not isinstance(h, FuncInfo) or
|
|
563
|
+
r.get("action") in ("list", "create", "retrieve", "update", "partial_update")):
|
|
564
|
+
ser = self.class_attr_expr(vs, "serializer_class")
|
|
565
|
+
if ser is not None and not r.get("extra_action"):
|
|
566
|
+
t = prog.infer(ser[0], Ctx(ser[1].module, None, ser[1]))
|
|
567
|
+
if t and t[0] == "type" and self.schemas.kind(t[1]):
|
|
568
|
+
if r["method"] in ("POST", "PUT", "PATCH"):
|
|
569
|
+
req["schemas"].append({"class": t[1].qual, "location": "body", "via": "serializer_class",
|
|
570
|
+
"partial": r["method"] == "PATCH"})
|
|
571
|
+
if r["method"] != "DELETE":
|
|
572
|
+
resp["schemas"].append({"class": t[1].qual, "many": r.get("action") == "list", "via": "serializer_class", "status": 200})
|
|
573
|
+
if any(req.values()):
|
|
574
|
+
a["request"] = req
|
|
575
|
+
if any(resp.values()):
|
|
576
|
+
a["response"] = resp
|
|
577
|
+
|
|
578
|
+
def ninja_signature(self, h: FuncInfo, op, a, req, resp):
|
|
579
|
+
b = self.b
|
|
580
|
+
args = h.node.args
|
|
581
|
+
params = list(args.posonlyargs) + list(args.args)
|
|
582
|
+
defaults = [None] * (len(params) - len(args.defaults)) + list(args.defaults)
|
|
583
|
+
params += list(args.kwonlyargs)
|
|
584
|
+
defaults += list(args.kw_defaults)
|
|
585
|
+
pp = set(a.get("path_params") or [])
|
|
586
|
+
mctx = Ctx(h.module, None, None)
|
|
587
|
+
for i, p in enumerate(params[1:], start=1):
|
|
588
|
+
if p.arg in pp:
|
|
589
|
+
continue
|
|
590
|
+
d = defaults[i] if i < len(defaults) else None
|
|
591
|
+
loc = None
|
|
592
|
+
if isinstance(d, ast.Call):
|
|
593
|
+
loc = {"Form": "form", "Body": "body", "Query": "query", "File": "file", "Path": "path"}.get((dotted(d.func) or "").split(".")[-1])
|
|
594
|
+
ref = self.schema_ref(p.annotation, mctx) if p.annotation is not None else None
|
|
595
|
+
ann = ann_text(p.annotation)
|
|
596
|
+
if ref:
|
|
597
|
+
ref["param"] = p.arg
|
|
598
|
+
ref["location"] = ref.get("location") or loc or "body"
|
|
599
|
+
req["schemas"].append(ref)
|
|
600
|
+
b.add_edge(h.id, f"class:{ref['class']}", "USES_SCHEMA", h.file, h.line, EXACT, role="request", param=p.arg)
|
|
601
|
+
elif ann and ("UploadedFile" in ann or loc == "file"):
|
|
602
|
+
req["files"].append({"name": p.arg, "line": h.line, "file": h.file})
|
|
603
|
+
else:
|
|
604
|
+
req["query" if loc in (None, "query") else "keys"].append(
|
|
605
|
+
{"name": p.arg, "type": ann, "required": d is None, "file": h.file, "line": h.line, "location": loc or "query"})
|
|
606
|
+
rsp = op.get("response")
|
|
607
|
+
if isinstance(rsp, ast.Dict):
|
|
608
|
+
for k, v in zip(rsp.keys, rsp.values):
|
|
609
|
+
ref = self.schema_ref(v, mctx)
|
|
610
|
+
if ref:
|
|
611
|
+
ref["status"] = k.value if isinstance(k, ast.Constant) else ann_text(k)
|
|
612
|
+
resp["schemas"].append(ref)
|
|
613
|
+
elif rsp is not None:
|
|
614
|
+
ref = self.schema_ref(rsp, mctx)
|
|
615
|
+
if ref:
|
|
616
|
+
ref["status"] = 200
|
|
617
|
+
resp["schemas"].append(ref)
|
|
618
|
+
for ref in resp["schemas"]:
|
|
619
|
+
b.add_edge(h.id, f"class:{ref['class']}", "USES_SCHEMA", h.file, op["line"], EXACT, role="response", status=ref.get("status"))
|
|
620
|
+
if op.get("by_alias") is not None:
|
|
621
|
+
a["by_alias"] = ann_text(op["by_alias"])
|
|
622
|
+
|
|
623
|
+
REQ_BASE = re.compile(r"(self\.)?request\.(data|POST|GET|query_params|FILES)")
|
|
624
|
+
|
|
625
|
+
def body_facts(self, h: FuncInfo, ctx, req, resp):
|
|
626
|
+
"""Literal dict keys returned (status codes kept); keys read from request data; DRF serializers in/out."""
|
|
627
|
+
prog = self.prog
|
|
628
|
+
jvars = set()
|
|
629
|
+
for sub in ast.walk(h.node):
|
|
630
|
+
if isinstance(sub, ast.Assign) and isinstance(sub.targets[0], ast.Name):
|
|
631
|
+
txt = ann_text(sub.value) or ""
|
|
632
|
+
if re.search(r"json\.loads\(\s*(self\.)?request\.body", txt) or re.fullmatch(r"(self\.)?request\.(data|POST|GET|query_params)(\.copy\(\)|\.dict\(\))?", txt):
|
|
633
|
+
jvars.add(sub.targets[0].id)
|
|
634
|
+
|
|
635
|
+
def loc(base):
|
|
636
|
+
return "query" if base.endswith(("GET", "query_params")) else ("file" if base.endswith("FILES") else "body")
|
|
637
|
+
for sub in ast.walk(h.node):
|
|
638
|
+
if isinstance(sub, ast.Return) and sub.value is not None:
|
|
639
|
+
status = None
|
|
640
|
+
v = sub.value
|
|
641
|
+
if isinstance(v, ast.Tuple) and len(v.elts) == 2 and isinstance(v.elts[0], ast.Constant):
|
|
642
|
+
status = v.elts[0].value
|
|
643
|
+
for keys, line in self.dict_keys(v, ctx):
|
|
644
|
+
for k in keys:
|
|
645
|
+
resp["keys"].append({"name": k, "file": h.file, "line": line, **({"status": status} if status is not None else {})})
|
|
646
|
+
try:
|
|
647
|
+
shp = self.shapes.shape(v.elts[1] if status is not None else v, ctx)
|
|
648
|
+
except RecursionError:
|
|
649
|
+
shp = None
|
|
650
|
+
if shp:
|
|
651
|
+
resp.setdefault("shapes", []).append({"status": status, "file": h.file, "line": sub.lineno, "keys": shp})
|
|
652
|
+
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr in ("get", "pop", "getlist") and sub.args:
|
|
653
|
+
k = const_str(sub.args[0])
|
|
654
|
+
base = ann_text(sub.func.value) or ""
|
|
655
|
+
if k and (self.REQ_BASE.fullmatch(base) or base in jvars):
|
|
656
|
+
req["keys"].append({"name": k, "file": h.file, "line": sub.lineno, "via": f"{base}.{sub.func.attr}",
|
|
657
|
+
"optional": True, "location": loc(base)})
|
|
658
|
+
if isinstance(sub, ast.Subscript) and isinstance(sub.ctx, ast.Load):
|
|
659
|
+
k = const_str(sub.slice)
|
|
660
|
+
base = ann_text(sub.value) or ""
|
|
661
|
+
if k and (self.REQ_BASE.fullmatch(base) or base in jvars):
|
|
662
|
+
req["keys"].append({"name": k, "file": h.file, "line": sub.lineno, "via": f"{base}[]", "optional": False, "location": loc(base)})
|
|
663
|
+
if isinstance(sub, ast.Call):
|
|
664
|
+
t = prog.infer(sub.func, ctx)
|
|
665
|
+
if t and t[0] == "type" and self.schemas.kind(t[1]) in ("drf", "drf_model"):
|
|
666
|
+
if kwarg(sub, "data") is not None:
|
|
667
|
+
pv = kwarg(sub, "partial")
|
|
668
|
+
req["schemas"].append({"class": t[1].qual, "location": "body", "via": f"{t[1].name}(data=...)", "line": sub.lineno,
|
|
669
|
+
"partial": isinstance(pv, ast.Constant) and pv.value is True})
|
|
670
|
+
self.b.add_edge(h.id, t[1].id, "USES_SCHEMA", h.file, sub.lineno, RESOLVED, role="request")
|
|
671
|
+
else:
|
|
672
|
+
mv = kwarg(sub, "many")
|
|
673
|
+
resp["schemas"].append({"class": t[1].qual, "many": isinstance(mv, ast.Constant) and mv.value is True,
|
|
674
|
+
"via": f"{t[1].name}(...)", "line": sub.lineno})
|
|
675
|
+
self.b.add_edge(h.id, t[1].id, "USES_SCHEMA", h.file, sub.lineno, RESOLVED, role="response")
|
|
676
|
+
|
|
677
|
+
def dict_keys(self, e, ctx, depth=0) -> list:
|
|
678
|
+
"""[(keys, line)] for a returned expression: dict literal, (status, dict), JsonResponse/Response(dict),
|
|
679
|
+
a local var assigned a dict literal, or a local helper returning one (one level)."""
|
|
680
|
+
out = []
|
|
681
|
+
if isinstance(e, ast.Tuple) and len(e.elts) == 2:
|
|
682
|
+
e = e.elts[1]
|
|
683
|
+
if isinstance(e, ast.Dict):
|
|
684
|
+
ks = [const_str(k) for k in e.keys if const_str(k)]
|
|
685
|
+
return [(ks, e.lineno)] if ks else []
|
|
686
|
+
if isinstance(e, ast.Call):
|
|
687
|
+
fn = (dotted(e.func) or "").split(".")[-1]
|
|
688
|
+
if fn == "dict" and e.keywords:
|
|
689
|
+
return [([k.arg for k in e.keywords if k.arg], e.lineno)]
|
|
690
|
+
if fn in ("JsonResponse", "Response", "JSONResponse") and e.args:
|
|
691
|
+
return self.dict_keys(e.args[0], ctx, depth)
|
|
692
|
+
if depth < 1:
|
|
693
|
+
t = self.prog.infer(e.func, ctx)
|
|
694
|
+
if t and t[0] in ("func", "bound"):
|
|
695
|
+
f = t[1]
|
|
696
|
+
c2 = Ctx(f.module, f, f.cls)
|
|
697
|
+
for sub in ast.walk(f.node):
|
|
698
|
+
if isinstance(sub, ast.Return) and sub.value is not None:
|
|
699
|
+
out += self.dict_keys(sub.value, c2, depth + 1)
|
|
700
|
+
return out
|
|
701
|
+
if isinstance(e, ast.Name) and ctx.func is not None:
|
|
702
|
+
for val, ann, kind in self.prog.local_vars(ctx).get(e.id, []):
|
|
703
|
+
if isinstance(val, ast.Dict):
|
|
704
|
+
out += self.dict_keys(val, ctx, depth)
|
|
705
|
+
return out
|
|
706
|
+
|
|
707
|
+
def emit_schemas(self) -> dict:
|
|
708
|
+
b = self.b
|
|
709
|
+
n = 0
|
|
710
|
+
for c in self.prog.classes.values():
|
|
711
|
+
k = self.schemas.kind(c)
|
|
712
|
+
if not k:
|
|
713
|
+
continue
|
|
714
|
+
fs = self.schemas.fields(c) or []
|
|
715
|
+
b.nodes[c.id].attrs.update({"schema_kind": k, "schema_fields": fs})
|
|
716
|
+
n += 1
|
|
717
|
+
for fd in fs:
|
|
718
|
+
fid = b.add_node("field", f"{c.qual}.{fd['name']}", name=fd["name"], fqn=f"{c.qual}.{fd['name']}", file=fd["file"],
|
|
719
|
+
line=fd["line"], lang="python", attrs={k2: v for k2, v in fd.items() if k2 not in ("file", "line")})
|
|
720
|
+
b.add_edge(c.id, fid, "CONTAINS", fd["file"], fd["line"], EXACT)
|
|
721
|
+
if fd.get("ref"):
|
|
722
|
+
b.add_edge(c.id, f"class:{fd['ref']}", "USES_SCHEMA", fd["file"], fd["line"], EXACT, role="nested", field=fd["name"])
|
|
723
|
+
if fd.get("from_model"):
|
|
724
|
+
tbl = self.models.table(fd["from_model"])
|
|
725
|
+
mf = self.models.field(fd["from_model"], fd["name"])
|
|
726
|
+
if tbl and mf and mf.get("column"):
|
|
727
|
+
b.add_edge(fid, f"column:{tbl}.{mf['column']}", "REFERS_TO", fd["file"], fd["line"], RESOLVED)
|
|
728
|
+
return {"schema_classes": n}
|