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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. 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}