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,1334 @@
1
+ """FastAPI / Starlette and Flask routes (framework plugins on the Python plugin; stdlib `ast`, no code runs).
2
+
3
+ Applications and routers are module- or function-level assignments of `FastAPI(...)`, `APIRouter(prefix=...)`,
4
+ `Starlette(routes=[...])`, `starlette.routing.Router(...)`, `Flask(__name__)` and `Blueprint(name, __name__,
5
+ url_prefix=...)` (subclasses too). Routes:
6
+ * FastAPI / Starlette: `@x.get/post/put/patch/delete/head/options/trace(path)`, `@x.api_route(path, methods=)`,
7
+ `@x.websocket(path)`, `@x.route(path, methods=)`, `x.add_api_route / add_route / add_websocket_route(path, fn)`,
8
+ `Route(path, fn, methods=)` / `WebSocketRoute` / `Mount(path, routes=[...] | app=...)` lists passed as
9
+ `routes=`; mounting through `x.include_router(child, prefix=)`, `x.mount(path, child)`;
10
+ * Flask: `@x.route(rule, methods=, endpoint=)`, `@x.get/post/...(rule)`, `x.add_url_rule(rule, endpoint,
11
+ view_func, methods=)` (a `View.as_view("name")` view_func routes to its get / post / ... methods), blueprints
12
+ mounted with `register_blueprint(bp, url_prefix=)` (nested blueprints too).
13
+ Path, prefix and endpoint strings are evaluated statically (pyweb/values.py: literals, f-strings, module constants,
14
+ settings class defaults such as `settings.API_V1_STR`); a part that cannot be evaluated becomes `{?}`. Flask
15
+ converters `<int:id>` and Starlette `{id:int}` become `{id}`. Route names: FastAPI `name=` or the function name,
16
+ Flask `<blueprint>.<endpoint>` (for `url_for`). Each route is an http_route entry point (`route:<METHOD> <uri>`) with
17
+ ROUTES_TO to its handler; FastAPI `Depends(...)` / `Security(...)` dependencies (parameters, `Annotated` aliases,
18
+ `dependencies=[...]` on the decorator, router, app or include) and handler decorators (`@login_required`) are
19
+ recorded as attrs.access for `cg routes`, with what each dependency checks (dep_checks: statuses raised, security
20
+ schemes, reads, nested dependencies). Test-client requests then link to these routes (python/tests.py).
21
+
22
+ Objects not assigned in place: a function parameter used as an app / router / blueprint is a param object, bound to
23
+ the objects passed at call sites (`register(app)`) or returned by the pytest fixture of that name; calls to a
24
+ function that returns an object (`create_app()`) resolve to it. Also read: fastapi-utils `@cbv` / `InferringRouter`,
25
+ classy-fastapi `Routable`, flask-restful / flask-restx `Api` / `Namespace` resources, `MethodView.methods`,
26
+ endpoint-only `add_url_rule`, `@x.endpoint`, `x.view_functions[...] = f`, werkzeug `url_map.add(Rule | Submount)`,
27
+ Flask's built-in static route, Starlette `Host` / `x.host()` (a `host` attribute, not a path prefix).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import ast
32
+ import re
33
+ from collections import defaultdict
34
+
35
+ from ...core.model import EXACT, RESOLVED
36
+ from ...core.plugin import FrameworkPlugin, GraphBuilder, Project
37
+ from ...core.paths import rules as path_rules
38
+ from ... import presets
39
+ from ..python.plugin import ClassInfo, Ctx, FuncInfo, PyProgram, dotted, kwarg, walk_body
40
+ from .values import UNKNOWN, str_value
41
+
42
+ VERBS = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
43
+ VIEW_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
44
+ # constructor (last name) -> (framework, kind); matched on the imported name (fastapi.*, starlette.*, flask.*)
45
+ FACTORIES = {"FastAPI": ("fastapi", "app"), "APIRouter": ("fastapi", "router"), "Starlette": ("starlette", "app"),
46
+ "Router": ("starlette", "router"), "Flask": ("flask", "app"), "Blueprint": ("flask", "blueprint"),
47
+ "InferringRouter": ("fastapi", "router"), # fastapi-utils / fastapi-restful
48
+ "Api": ("flask", "api"), "Namespace": ("flask", "namespace")} # flask-restful / flask-restx
49
+ FACTORY_PKGS = ("fastapi", "starlette", "flask", "fastapi_utils", "fastapi_restful", "flask_restful", "flask_restx")
50
+ # registration calls that only exist on one framework's objects (used to tell what a parameter `app` is)
51
+ FLASK_ONLY = {"add_url_rule", "register_blueprint", "endpoint", "add_resource", "add_namespace"}
52
+ FASTAPI_ONLY = {"include_router", "add_api_route", "api_route", "add_api_websocket_route", "websocket"}
53
+ REG_CALLS = {"add_url_rule", "register_blueprint", "add_resource", "add_namespace", "include_router", "add_api_route",
54
+ "add_route", "add_websocket_route", "add_api_websocket_route", "mount", "host"}
55
+ ROUTE_DECOS = set(VERBS) | {"route", "api_route", "websocket", "websocket_route", "endpoint"}
56
+ # FastAPI security schemes: a dependency on one of these rejects requests without credentials
57
+ SECURITY_SCHEMES = {"OAuth2PasswordBearer", "OAuth2AuthorizationCodeBearer", "OAuth2", "HTTPBearer", "HTTPBasic",
58
+ "HTTPDigest", "APIKeyHeader", "APIKeyCookie", "APIKeyQuery", "OpenIdConnect"}
59
+ REJECT_STATUS = {401, 403}
60
+ PKG = {"fastapi": ("fastapi", "starlette"), "flask": ("flask",)}
61
+ DEP_FUNCS = {"Depends", "Security"}
62
+ SKIP_DECOS = {"staticmethod", "classmethod", "property", "wraps", "cache", "lru_cache"}
63
+ MANIFESTS = ("requirements.txt", "requirements.in", "requirements/base.txt", "requirements/prod.txt", "pyproject.toml",
64
+ "setup.py", "setup.cfg", "Pipfile")
65
+ TEST_FILE = re.compile(r"(test_.*|.*_test|conftest)\.py$")
66
+
67
+
68
+ def _mentions(project: Project, pkgs: tuple[str, ...]) -> bool:
69
+ """A dependency manifest (root or two levels down) or an import names one of `pkgs`."""
70
+ dep = re.compile(r"(?im)^\s*[\"']?(" + "|".join(pkgs) + r")\b|[\"'](" + "|".join(pkgs) + r")[\[<>=~!\"' ]")
71
+ imp = re.compile(r"(?m)^\s*(from|import)\s+(" + "|".join(pkgs) + r")\b")
72
+ root = project.root
73
+ rules = path_rules(project, "python")
74
+ test_dirs = set(presets.values("python", "test_dirs", default=[]))
75
+ for pat in ("", "*/", "*/*/"):
76
+ for name in MANIFESTS:
77
+ for p in root.glob(pat + name):
78
+ if any(part in rules.names for part in p.relative_to(root).parts[:-1]):
79
+ continue
80
+ try:
81
+ if dep.search(p.read_text(errors="replace")):
82
+ return True
83
+ except OSError:
84
+ continue
85
+ n = 0
86
+ stack = [root]
87
+ while stack and n < 3000:
88
+ d = stack.pop()
89
+ try:
90
+ entries = list(d.iterdir())
91
+ except OSError:
92
+ continue
93
+ for p in entries:
94
+ if p.is_dir():
95
+ r = p.relative_to(root).as_posix()
96
+ if not rules.skip(r.rpartition("/")[0], p.name, dot=True) and (p.name not in test_dirs or rules.on_include_path(r)):
97
+ stack.append(p)
98
+ elif p.suffix == ".py" and not TEST_FILE.match(p.name): # test fixtures quote `from flask import ...`
99
+ n += 1
100
+ try:
101
+ with open(p, encoding="utf-8", errors="replace") as fh:
102
+ if imp.search(fh.read(20000)):
103
+ return True
104
+ except OSError:
105
+ continue
106
+ return False
107
+
108
+
109
+ def flask_path(rule: str) -> str:
110
+ """`<int:id>` -> `{id}`; `<path:rest>` -> `{rest*}` (one or more segments, #59)."""
111
+ rule = re.sub(r"<path:(\w+)>", r"{\1*}", rule)
112
+ return re.sub(r"<(?:[^:<>]+:)?(\w+)>", r"{\1}", rule)
113
+
114
+
115
+ def starlette_path(p: str) -> str:
116
+ """`{id:int}` -> `{id}`; `{rest:path}` -> `{rest*}` (one or more segments, #59)."""
117
+ p = re.sub(r"\{(\w+):path\}", r"{\1*}", p)
118
+ return re.sub(r"\{(\w+):[^{}]+\}", r"{\1}", p)
119
+
120
+
121
+ def join(*parts: str) -> str:
122
+ out = ""
123
+ for p in parts:
124
+ if not p:
125
+ continue
126
+ out = out.rstrip("/") + "/" + p.lstrip("/") if out else p
127
+ out = out or "/"
128
+ return out if out.startswith("/") else "/" + out
129
+
130
+
131
+ class Obj:
132
+ """An application or router object."""
133
+
134
+ def __init__(self, key, fw, kind, call, mod, func, line):
135
+ self.key, self.fw, self.kind, self.call, self.mod, self.func, self.line = key, fw, kind, call, mod, func, line
136
+ self.prefix = "" # APIRouter(prefix=) / Blueprint(url_prefix=)
137
+ self.name = None # blueprint name
138
+ self.deps: list[dict] = []
139
+ self.children: list[dict] = [] # {obj, prefix (None: child's own), deps, line, file, host}
140
+ self.parents = 0
141
+ self.host = None # Starlette Host(...) / Flask host= (host matching)
142
+ self.subdomain = None # Flask Blueprint(subdomain=)
143
+ self.param = None # parameter name, for an object received as a parameter / fixture
144
+
145
+
146
+ class Routes:
147
+ def __init__(self, prog: PyProgram, frameworks: set[str]):
148
+ self.prog = prog
149
+ self.fws = frameworks
150
+ self.objs: dict = {}
151
+ self.ops: list[dict] = []
152
+ self.unresolved: list[str] = []
153
+ self.endpoints: dict = {} # (obj key, endpoint name) -> handler, from @x.endpoint / view_functions[...]
154
+ self.stats = defaultdict(int)
155
+ self._ret_seen: set = set()
156
+
157
+ # ------------------------------------------------------------------ objects
158
+ def _factory(self, call, ctx):
159
+ if not isinstance(call, ast.Call):
160
+ return None
161
+ try:
162
+ t = self.prog.infer(call.func, ctx)
163
+ except RecursionError:
164
+ return None
165
+ names = []
166
+ if t and t[0] == "ext":
167
+ names = [t[1]]
168
+ elif t and t[0] == "type":
169
+ names = [t[1].qual] + list(self.prog.lineage(t[1]))
170
+ elif t is None and isinstance(call.func, ast.Name) and ctx.func is not None:
171
+ # a class defined in the same function (`class MyFlask(flask.Flask): ...; app = MyFlask(__name__)`)
172
+ for sub in walk_body(ctx.func.node):
173
+ if isinstance(sub, ast.ClassDef) and sub.name == call.func.id:
174
+ for b in sub.bases:
175
+ try:
176
+ bt = self.prog.infer(b, ctx)
177
+ except RecursionError:
178
+ bt = None
179
+ if bt and bt[0] == "ext":
180
+ names.append(bt[1])
181
+ elif bt and bt[0] == "type":
182
+ names += [bt[1].qual] + list(self.prog.lineage(bt[1]))
183
+ break
184
+ for n in names:
185
+ last, top = n.rsplit(".", 1)[-1], n.split(".", 1)[0]
186
+ f = FACTORIES.get(last)
187
+ if f and top in FACTORY_PKGS and f[0] in self.fws:
188
+ return f
189
+ return None
190
+
191
+ def _scopes(self):
192
+ """(module, function or None, the nodes of that scope): module level without def / class bodies, then each
193
+ def's own body."""
194
+ for m in self.prog.modules.values():
195
+ yield m, None, list(_module_level(m.tree))
196
+ for f in self.prog.funcs.values():
197
+ yield f.module, f, list(walk_body(f.node))
198
+
199
+ def collect(self):
200
+ prog = self.prog
201
+ pending_ns = []
202
+ for m, f, stmts in self._scopes():
203
+ ctx = Ctx(m, f, f.cls if f else None)
204
+ for st in stmts:
205
+ if not isinstance(st, (ast.Assign, ast.AnnAssign)) or st.value is None:
206
+ continue
207
+ tgt = st.targets[0] if isinstance(st, ast.Assign) else st.target
208
+ if not isinstance(tgt, ast.Name):
209
+ continue
210
+ key = (m.name, f.qual if f else "", tgt.id)
211
+ fk = self._factory(st.value, ctx)
212
+ if not fk:
213
+ pending_ns.append((key, st, ctx))
214
+ continue
215
+ o = self.objs[key] = Obj(key, fk[0], fk[1], st.value, m, f, st.lineno)
216
+ c = st.value
217
+ if fk[1] == "namespace": # flask-restx Namespace(name, path=): default path /<name>
218
+ nm = str_value(prog, c.args[0] if c.args else kwarg(c, "name"), ctx)
219
+ pe = kwarg(c, "path")
220
+ o.prefix = (str_value(prog, pe, ctx) or UNKNOWN) if pe is not None else ("/" + nm if nm else UNKNOWN)
221
+ elif fk[0] == "flask":
222
+ o.prefix = str_value(prog, kwarg(c, "url_prefix") or kwarg(c, "prefix"), ctx) or ""
223
+ o.subdomain = str_value(prog, kwarg(c, "subdomain"), ctx)
224
+ if fk[1] == "blueprint":
225
+ o.name = str_value(prog, c.args[0] if c.args else kwarg(c, "name"), ctx)
226
+ else:
227
+ pe = kwarg(c, "prefix")
228
+ o.prefix = (str_value(prog, pe, ctx) or UNKNOWN) if pe is not None else ""
229
+ o.deps = self.deps_of(kwarg(c, "dependencies"), ctx, "router dependencies")
230
+ for key, st, ctx in pending_ns:
231
+ self._namespace(key, st, ctx)
232
+ self._param_objects()
233
+ self._routables()
234
+ for o in list(self.objs.values()): # flask-restful / restx `Api(app)` / `Api(bp)`
235
+ if o.kind == "api" and o.call is not None and (o.call.args or kwarg(o.call, "app") is not None):
236
+ parent = self.obj_of(o.call.args[0] if o.call.args else kwarg(o.call, "app"),
237
+ Ctx(o.mod, o.func, o.func.cls if o.func else None))
238
+ if parent is not None and parent is not o:
239
+ self._attach(parent, o, None, o.line, o.mod.file)
240
+ # mounts and calls
241
+ pfuncs = {k[1].rsplit(".", 1)[-1] for k, o in self.objs.items() if o.param}
242
+ for m, f, stmts in self._scopes():
243
+ ctx = Ctx(m, f, f.cls if f else None)
244
+ extra, skip = _unroll_loops(stmts)
245
+ for st in stmts + extra:
246
+ if id(st) in skip:
247
+ continue
248
+ if isinstance(st, ast.Call) and isinstance(st.func, ast.Attribute):
249
+ self._call(st, ctx)
250
+ if isinstance(st, ast.Call) and pfuncs and (dotted(st.func) or "").rsplit(".", 1)[-1] in pfuncs:
251
+ self._param_alias(st, ctx)
252
+ if isinstance(st, ast.Assign) and isinstance(st.targets[0], ast.Subscript):
253
+ self._view_functions(st, ctx)
254
+ self._fixture_aliases()
255
+ self._static()
256
+ for o in list(self.objs.values()):
257
+ rl = kwarg(o.call, "routes") if o.call is not None else None
258
+ if rl is not None:
259
+ self._route_list(o, rl, Ctx(o.mod, o.func, o.func.cls if o.func else None), "")
260
+ # decorators (flask-restx `@ns.route("/x")` decorates a Resource class)
261
+ for f in list(prog.funcs.values()) + list(prog.classes.values()):
262
+ for d in f.decorators:
263
+ if isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute):
264
+ self._decorator(f, d)
265
+ if isinstance(f, ClassInfo):
266
+ continue
267
+ # a view defined inside an app factory (`def create_app(): ... @app.route("/hello") def hello()`): nested
268
+ # defs collapse into the enclosing def, which becomes the handler
269
+ for sub in walk_body(f.node):
270
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
271
+ for d in sub.decorator_list:
272
+ if isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute):
273
+ self._decorator(f, d, nested=sub.name, node=sub)
274
+
275
+ def obj_of(self, e, ctx, depth=0):
276
+ """The application / router object an expression names (local, module-level, imported, `mod.router`), also
277
+ through a call to a function that returns one (`app = create_app()`, `app.mount("/x", make_sub())`)."""
278
+ if e is None or depth > 4:
279
+ return None
280
+ if isinstance(e, ast.Name):
281
+ if ctx.func is not None and (ctx.mod.name, ctx.func.qual, e.id) in self.objs:
282
+ return self.objs[(ctx.mod.name, ctx.func.qual, e.id)]
283
+ if ctx.func is not None:
284
+ lv = self.prog.local_vars(ctx).get(e.id)
285
+ if lv:
286
+ for v, _a, k in lv:
287
+ if k == "assign" and isinstance(v, ast.Call):
288
+ o = self.obj_of(v, ctx, depth + 1)
289
+ if o is not None:
290
+ return o
291
+ return None
292
+ if (ctx.mod.name, "", e.id) in self.objs:
293
+ return self.objs[(ctx.mod.name, "", e.id)]
294
+ r = self.prog.resolve_name(ctx.mod, e.id)
295
+ if r and r[0] == "var":
296
+ o = self.objs.get((r[1].name, "", r[2]))
297
+ if o is not None:
298
+ return o
299
+ for v, _ln, _a in r[1].vars.get(r[2], ())[:2]:
300
+ if isinstance(v, ast.Call):
301
+ o = self.obj_of(v, Ctx(r[1], None, None), depth + 1)
302
+ if o is not None:
303
+ return o
304
+ return None
305
+ if isinstance(e, ast.Call):
306
+ try:
307
+ t = self.prog.infer(e.func, ctx)
308
+ except RecursionError:
309
+ return None
310
+ if t and t[0] in ("func", "bound") and isinstance(t[1], FuncInfo):
311
+ return self.returned_obj(t[1], depth + 1)
312
+ return None
313
+ if isinstance(e, ast.Attribute):
314
+ try:
315
+ t = self.prog.infer(e.value, ctx)
316
+ except RecursionError:
317
+ return None
318
+ if t and t[0] == "mod":
319
+ return self.objs.get((t[1].name, "", e.attr))
320
+ if t and t[0] == "inst" and e.attr == "router": # classy-fastapi `Items().router`
321
+ return self.objs.get((t[1].module.name, "", t[1].qual + ".router"))
322
+ return None
323
+
324
+ def returned_obj(self, f: FuncInfo, depth=0):
325
+ """The application / router object a function returns or yields (app factories, pytest fixtures)."""
326
+ if depth > 4 or f.qual in self._ret_seen:
327
+ return None
328
+ self._ret_seen.add(f.qual)
329
+ try:
330
+ ctx = Ctx(f.module, f, f.cls)
331
+ for sub in walk_body(f.node):
332
+ if isinstance(sub, (ast.Return, ast.Yield)) and isinstance(sub.value, (ast.Name, ast.Call)):
333
+ o = self.obj_of(sub.value, ctx, depth + 1)
334
+ if o is not None:
335
+ return o
336
+ return None
337
+ finally:
338
+ self._ret_seen.discard(f.qual)
339
+
340
+ def _attach(self, parent, child, prefix, line, file, mount=False, deps=(), **ext):
341
+ """Register `child` under `parent` (a blueprint may be registered twice, under other prefixes / names)."""
342
+ ext = {k: v for k, v in ext.items() if v}
343
+ if child is parent or any(c["obj"] is child and c["prefix"] == prefix and all(c.get(k) == v for k, v in ext.items())
344
+ for c in parent.children):
345
+ return
346
+ parent.children.append({"obj": child, "prefix": prefix, "line": line, "file": file, "deps": list(deps),
347
+ **({"mount": True} if mount else {}), **ext})
348
+ child.parents += 1
349
+
350
+ # ------------------------------------------------------------------ objects received as parameters
351
+ def _module_fw(self, m) -> str | None:
352
+ """'flask' / 'fastapi' from the framework a module imports, else the only one the project imports."""
353
+ def fws(tree):
354
+ out = set()
355
+ for n in ast.walk(tree):
356
+ names = [a.name for a in n.names] if isinstance(n, ast.Import) else \
357
+ [n.module] if isinstance(n, ast.ImportFrom) and n.module and not n.level else []
358
+ for x in names:
359
+ top = x.split(".")[0]
360
+ if top.startswith("flask"):
361
+ out.add("flask")
362
+ elif top in ("fastapi", "starlette") or top.startswith("fastapi_"):
363
+ out.add("fastapi")
364
+ return out
365
+ own = fws(m.tree)
366
+ if len(own) == 1:
367
+ return next(iter(own))
368
+ if own:
369
+ return None
370
+ if not hasattr(self, "_proj_fws"):
371
+ self._proj_fws = set()
372
+ for mm in self.prog.modules.values():
373
+ self._proj_fws |= fws(mm.tree)
374
+ return next(iter(self._proj_fws)) if len(self._proj_fws) == 1 else None
375
+
376
+ def _param_kind(self, f: FuncInfo, p, used: set):
377
+ ann = p.annotation
378
+ if isinstance(ann, ast.Constant) and isinstance(ann.value, str):
379
+ last = ann.value.rsplit(".", 1)[-1]
380
+ else:
381
+ last = (dotted(ann) or "").rsplit(".", 1)[-1] if ann is not None else ""
382
+ if last in FACTORIES:
383
+ fk = FACTORIES[last]
384
+ return fk if fk[0] in self.fws or (fk[0] == "starlette" and "fastapi" in self.fws) else None
385
+ if last not in ("", "Any", "object"):
386
+ return None # typed as something else
387
+ if used & FLASK_ONLY:
388
+ fw = "flask"
389
+ elif used & FASTAPI_ONLY:
390
+ fw = "fastapi"
391
+ else:
392
+ fw = self._module_fw(f.module)
393
+ if fw is None or fw not in self.fws:
394
+ return None
395
+ n = p.arg.lower()
396
+ if fw == "flask" and (n in ("bp", "blueprint") or n.endswith(("_bp", "_blueprint"))):
397
+ return (fw, "blueprint")
398
+ if fw == "flask" and used & {"add_resource", "add_namespace"}:
399
+ return (fw, "api")
400
+ return (fw, "router" if "router" in n else "app")
401
+
402
+ def _param_objects(self):
403
+ """`def register_routes(app): @app.get(...)`, `def init_app(app): app.add_url_rule(...)`, and tests that add
404
+ routes to the `app` fixture they receive: the parameter becomes an object of its own (an app unless its
405
+ name or annotation says router / blueprint); calls that pass a known object and pytest fixtures of the same
406
+ name attach it to that object (_param_alias, _fixture_aliases)."""
407
+ for f in list(self.prog.funcs.values()):
408
+ a = f.node.args
409
+ params = {p.arg: p for p in a.posonlyargs + a.args + a.kwonlyargs if p.arg not in ("self", "cls")}
410
+ if not params:
411
+ continue
412
+ uses = defaultdict(set)
413
+ for sub in walk_body(f.node):
414
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and \
415
+ isinstance(sub.func.value, ast.Name) and sub.func.value.id in params and sub.func.attr in REG_CALLS:
416
+ uses[sub.func.value.id].add(sub.func.attr)
417
+ elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
418
+ for d in sub.decorator_list:
419
+ if isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute) and \
420
+ isinstance(d.func.value, ast.Name) and d.func.value.id in params and \
421
+ d.func.attr in ROUTE_DECOS:
422
+ uses[d.func.value.id].add(d.func.attr)
423
+ for pn, used in uses.items():
424
+ key = (f.module.name, f.qual, pn)
425
+ if key in self.objs:
426
+ continue
427
+ fk = self._param_kind(f, params[pn], used)
428
+ if fk is None:
429
+ continue
430
+ o = self.objs[key] = Obj(key, fk[0], fk[1], None, f.module, f, f.line)
431
+ o.param = pn
432
+ self.stats["param_objects"] += 1
433
+
434
+ def _param_alias(self, c: ast.Call, ctx):
435
+ """`register_routes(app)` with a known `app`: the function's parameter object hangs under it."""
436
+ try:
437
+ t = self.prog.infer(c.func, ctx)
438
+ except RecursionError:
439
+ return
440
+ if not t or t[0] not in ("func", "bound") or not isinstance(t[1], FuncInfo):
441
+ return
442
+ fn = t[1]
443
+ pobjs = {k[2]: o for k, o in self.objs.items() if o.param and k[0] == fn.module.name and k[1] == fn.qual}
444
+ if not pobjs:
445
+ return
446
+ a = fn.node.args
447
+ names = [x.arg for x in a.posonlyargs + a.args]
448
+ if names and names[0] in ("self", "cls") and fn.cls is not None:
449
+ names = names[1:]
450
+ bind = dict(zip(names, c.args))
451
+ bind.update({k.arg: k.value for k in c.keywords if k.arg})
452
+ for pn, o in pobjs.items():
453
+ parent = self.obj_of(bind.get(pn), ctx)
454
+ if parent is not None and parent is not o and not parent.param:
455
+ self._attach(parent, o, "", c.lineno, ctx.mod.file)
456
+ self.stats["param_objects_bound"] += 1
457
+
458
+ def _fixture_aliases(self):
459
+ """A test's `app` parameter is the pytest fixture of that name (same module, else a conftest.py up the tree)
460
+ when that fixture returns a known object."""
461
+ fixtures = defaultdict(list)
462
+ for f in self.prog.funcs.values():
463
+ if f.cls is None and any((dotted(d.func if isinstance(d, ast.Call) else d) or "").endswith("fixture")
464
+ for d in f.decorators):
465
+ fixtures[f.name].append(f)
466
+ for o in list(self.objs.values()):
467
+ if not o.param or o.parents or o.param not in fixtures:
468
+ continue
469
+ fdir = o.mod.file.rpartition("/")[0]
470
+ best = None
471
+ for fx in fixtures[o.param]:
472
+ if fx.module is o.mod:
473
+ best = fx
474
+ break
475
+ d = fx.module.file.rpartition("/")[0]
476
+ if fx.module.file.endswith("conftest.py") and (fdir == d or fdir.startswith(d + "/") or not d):
477
+ if best is None or len(d) > len(best.module.file):
478
+ best = fx
479
+ parent = self.returned_obj(best) if best is not None else None
480
+ if parent is not None and parent is not o:
481
+ self._attach(parent, o, "", o.line, o.mod.file)
482
+ self.stats["param_objects_from_fixture"] += 1
483
+
484
+ def _namespace(self, key, st, ctx):
485
+ """flask-restx `ns = api.namespace("todos", path="/todos")`."""
486
+ c = st.value
487
+ if not (isinstance(c, ast.Call) and isinstance(c.func, ast.Attribute) and c.func.attr == "namespace"):
488
+ return
489
+ api = self.obj_of(c.func.value, ctx)
490
+ if api is None or api.kind != "api":
491
+ return
492
+ o = self.objs[key] = Obj(key, "flask", "namespace", c, ctx.mod, ctx.func, st.lineno)
493
+ nm = str_value(self.prog, c.args[0] if c.args else kwarg(c, "name"), ctx)
494
+ pe = kwarg(c, "path")
495
+ o.prefix = (str_value(self.prog, pe, ctx) or UNKNOWN) if pe is not None else ("/" + nm if nm else UNKNOWN)
496
+ self._attach(api, o, None, st.lineno, ctx.mod.file)
497
+
498
+ def _routables(self):
499
+ """classy-fastapi: methods of a `Routable` subclass decorated with `@get("/x")` / `@post(...)` form a router,
500
+ `Items().router`, included like any other (`app.include_router(items.router)`)."""
501
+ if "fastapi" not in self.fws:
502
+ return
503
+ for c in list(self.prog.classes.values()):
504
+ if not self.prog.subclass_of(c, "Routable"):
505
+ continue
506
+ key = (c.module.name, "", c.qual + ".router")
507
+ o = None
508
+ for fn in c.methods.values():
509
+ for d in fn.decorators:
510
+ if not (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id in VERBS + ("api_route",)):
511
+ continue
512
+ if o is None:
513
+ o = self.objs[key] = Obj(key, "fastapi", "router", None, c.module, None, c.line)
514
+ ctx = Ctx(c.module, None, None)
515
+ pe = d.args[0] if d.args else kwarg(d, "path")
516
+ meth = [d.func.id.upper()] if d.func.id in VERBS else self._methods(kwarg(d, "methods"), ctx)
517
+ self._op(o, str_value(self.prog, pe, ctx) if pe is not None else "", meth, fn, ctx, d.lineno,
518
+ f"@{d.func.id} (Routable)", str_value(self.prog, kwarg(d, "name"), ctx),
519
+ self.deps_of(kwarg(d, "dependencies"), ctx, "route dependencies"))
520
+
521
+ def _view_functions(self, st: ast.Assign, ctx):
522
+ """`app.view_functions["index"] = index`: the view of an endpoint registered without one."""
523
+ t = st.targets[0]
524
+ if not (isinstance(t.value, ast.Attribute) and t.value.attr == "view_functions"):
525
+ return
526
+ obj = self.obj_of(t.value.value, ctx)
527
+ nm = str_value(self.prog, t.slice, ctx)
528
+ if obj is not None and nm:
529
+ h = self.handler(st.value, ctx) or self._local_def(st.value, ctx)
530
+ if h is not None:
531
+ self.endpoints[(obj.key, nm)] = h
532
+
533
+ def _local_def(self, e, ctx):
534
+ """A def nested in the current function (it collapses into that function, which handles the route)."""
535
+ if ctx.func is not None and isinstance(e, ast.Name):
536
+ for sub in walk_body(ctx.func.node):
537
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.name == e.id:
538
+ return ctx.func
539
+ return None
540
+
541
+ def deps_of(self, e, ctx, via) -> list[dict]:
542
+ out = []
543
+ if isinstance(e, (ast.List, ast.Tuple)):
544
+ for x in e.elts:
545
+ ent = self.dep_entry(x, ctx, via)
546
+ if ent:
547
+ out.append(ent)
548
+ return out
549
+
550
+ @staticmethod
551
+ def dep_expr(x):
552
+ """`Depends(get_current_user)` / `Security(scheme, scopes=...)` -> the dependency expression."""
553
+ if isinstance(x, ast.Call) and (dotted(x.func) or "").rsplit(".", 1)[-1] in DEP_FUNCS:
554
+ return x.args[0] if x.args else kwarg(x, "dependency")
555
+ return None
556
+
557
+ def dep_name(self, x, ctx, depth=0):
558
+ """`Depends(get_current_user)` / `Security(scheme, scopes=...)` -> 'get_current_user'."""
559
+ a = self.dep_expr(x)
560
+ if isinstance(a, ast.Call): # Depends(RoleChecker("admin")): an instance, named by its class (#59)
561
+ a = a.func
562
+ return (dotted(a) or "").rsplit(".", 1)[-1] or None if a is not None else None
563
+
564
+ def dep_entry(self, x, ctx, via) -> dict | None:
565
+ n = self.dep_name(x, ctx)
566
+ if not n:
567
+ return None
568
+ ent = {"name": n, "via": via}
569
+ chk = self.dep_checks(self.dep_expr(x), ctx)
570
+ if chk:
571
+ ent["checks"] = chk
572
+ return ent
573
+
574
+ def ann_deps(self, ann, ctx, depth=0) -> list[tuple]:
575
+ """`Depends(...)` calls in a parameter annotation, with the context they live in: `Annotated[User,
576
+ Depends(f)]`, or a module alias of one (`CurrentUser = Annotated[User, Depends(get_current_user)]`)."""
577
+ if ann is None or depth > 3:
578
+ return []
579
+ if isinstance(ann, ast.Subscript) and (dotted(ann.value) or "").rsplit(".", 1)[-1] == "Annotated":
580
+ sl = ann.slice
581
+ elts = sl.elts if isinstance(sl, ast.Tuple) else [sl]
582
+ return [(x, ctx) for x in elts[1:] if self.dep_expr(x) is not None]
583
+ if isinstance(ann, (ast.Name, ast.Attribute)):
584
+ r = None
585
+ if isinstance(ann, ast.Name):
586
+ r = self.prog.resolve_name(ctx.mod, ann.id)
587
+ else:
588
+ try:
589
+ t = self.prog.infer(ann.value, ctx)
590
+ except RecursionError:
591
+ t = None
592
+ if t and t[0] == "mod":
593
+ r = ("var", t[1], ann.attr) if ann.attr in t[1].vars else None
594
+ if r and r[0] == "var":
595
+ for v, _ln, _a in r[1].vars.get(r[2], ())[:1]:
596
+ return self.ann_deps(v, Ctx(r[1], None, None), depth + 1)
597
+ return []
598
+
599
+ def _param_deps(self, fn: FuncInfo) -> list[tuple]:
600
+ """(parameter, Depends call, ctx) for every dependency a function declares in its parameters."""
601
+ a = fn.node.args
602
+ params = a.posonlyargs + a.args + a.kwonlyargs
603
+ defaults = [None] * (len(a.posonlyargs + a.args) - len(a.defaults)) + list(a.defaults) + list(a.kw_defaults)
604
+ ctx = Ctx(fn.module, fn, fn.cls)
605
+ out = []
606
+ for p, dv in zip(params, defaults):
607
+ calls = ([(dv, ctx)] if dv is not None and self.dep_expr(dv) is not None else []) + self.ann_deps(p.annotation, ctx)
608
+ out += [(p, x, cx) for x, cx in calls]
609
+ return out
610
+
611
+ def _value_of(self, e, ctx):
612
+ """The assigned value of a local or module-level name (one assignment)."""
613
+ if isinstance(e, ast.Name):
614
+ if ctx.func is not None:
615
+ lv = self.prog.local_vars(ctx).get(e.id)
616
+ if lv:
617
+ vals = [v for v, _a, k in lv if k == "assign" and v is not None]
618
+ return (vals[0], ctx) if len(vals) == 1 else (None, ctx)
619
+ r = self.prog.resolve_name(ctx.mod, e.id)
620
+ if r and r[0] == "var":
621
+ vals = [v for v, _ln, _a in r[1].vars.get(r[2], ()) if v is not None]
622
+ return (vals[0], Ctx(r[1], None, None)) if len(vals) == 1 else (None, ctx)
623
+ return None, ctx
624
+
625
+ @staticmethod
626
+ def _status(e) -> int | None:
627
+ if isinstance(e, ast.Constant) and isinstance(e.value, int):
628
+ return e.value
629
+ m = re.search(r"HTTP_(\d{3})", dotted(e) or "")
630
+ return int(m.group(1)) if m else None
631
+
632
+ def dep_checks(self, a, ctx, depth=0, seen=None) -> dict | None:
633
+ """What a dependency checks, read from its source: the HTTP statuses it raises (`HTTPException(401)`,
634
+ `status.HTTP_403_FORBIDDEN`, an exception held in a variable), the FastAPI security schemes it rests on
635
+ (`OAuth2PasswordBearer`, `HTTPBearer`, `APIKeyHeader` ...; these reject requests without credentials unless
636
+ `auto_error=False`), the headers / cookies / request it reads, and the dependencies nested in its own
637
+ parameters (followed, up to 4 levels). effect: 'rejects' (401 / 403 here or in a nested dependency),
638
+ 'raises' (other statuses only), 'reads' (no rejection found)."""
639
+ if a is None or depth > 4:
640
+ return None
641
+ seen = seen or set()
642
+ val, vctx = self._value_of(a, ctx)
643
+ if isinstance(val, ast.Call): # oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
644
+ last = (dotted(val.func) or "").rsplit(".", 1)[-1]
645
+ if last in SECURITY_SCHEMES:
646
+ ae = kwarg(val, "auto_error")
647
+ rej = not (isinstance(ae, ast.Constant) and ae.value is False)
648
+ return {"scheme": last, "effect": "rejects" if rej else "reads", **({"rejects": [401]} if rej else {})}
649
+ try:
650
+ t = self.prog.infer(a, ctx)
651
+ except RecursionError:
652
+ return None
653
+ fn = None
654
+ if t and t[0] in ("func", "bound") and isinstance(t[1], FuncInfo):
655
+ fn = t[1]
656
+ elif t and t[0] == "inst":
657
+ fn = self.prog.find_method(t[1], "__call__")
658
+ elif t and t[0] == "type":
659
+ fn = self.prog.find_method(t[1], "__call__") if isinstance(a, ast.Call) else self.prog.find_method(t[1], "__init__")
660
+ if fn is None or fn.qual in seen:
661
+ return None
662
+ seen = seen | {fn.qual}
663
+ fctx = Ctx(fn.module, fn, fn.cls)
664
+ statuses, raises, reads, nested = set(), [], [], []
665
+ rejects_via = None
666
+ for sub in walk_body(fn.node):
667
+ if isinstance(sub, ast.Raise) and sub.exc is not None:
668
+ exc = sub.exc
669
+ if isinstance(exc, ast.Name):
670
+ exc = self._value_of(exc, fctx)[0] or exc
671
+ if isinstance(exc, ast.Call):
672
+ nm = (dotted(exc.func) or "").rsplit(".", 1)[-1]
673
+ st = self._status(kwarg(exc, "status_code") or (exc.args[0] if exc.args else None))
674
+ if st is not None:
675
+ statuses.add(st)
676
+ elif nm:
677
+ raises.append(nm)
678
+ helper_st, helpers = self._helper_statuses(fn, fctx, depth, seen)
679
+ if helper_st:
680
+ statuses |= helper_st
681
+ for p, x, cx in self._param_deps(fn):
682
+ sub_e = self.dep_expr(x)
683
+ nn = (dotted(sub_e) or "").rsplit(".", 1)[-1] if sub_e is not None else ""
684
+ sc = self.dep_checks(sub_e, cx, depth + 1, seen)
685
+ if nn:
686
+ nested.append(nn)
687
+ if sc and sc.get("effect") == "rejects" and rejects_via is None:
688
+ rejects_via = nn
689
+ for n2 in (sc or {}).get("nested", []):
690
+ if n2 not in nested:
691
+ nested.append(n2)
692
+ a2 = fn.node.args
693
+ defaults = [None] * (len(a2.posonlyargs + a2.args) - len(a2.defaults)) + list(a2.defaults) + list(a2.kw_defaults)
694
+ for p, dv in zip(a2.posonlyargs + a2.args + a2.kwonlyargs, defaults):
695
+ kind = (dotted(dv.func) or "").rsplit(".", 1)[-1] if isinstance(dv, ast.Call) else ""
696
+ ann = (dotted(p.annotation) or "").rsplit(".", 1)[-1] if p.annotation is not None else ""
697
+ if kind in ("Header", "Cookie", "Query"):
698
+ reads.append(f"{kind.lower()} {p.arg}")
699
+ elif ann in ("Request", "HTTPConnection", "WebSocket"):
700
+ reads.append(f"{ann.lower()} {p.arg}")
701
+ out = {}
702
+ if statuses:
703
+ out["rejects" if statuses & REJECT_STATUS else "raises_status"] = sorted(statuses)
704
+ if statuses & REJECT_STATUS and statuses - REJECT_STATUS:
705
+ out["raises_status"] = sorted(statuses - REJECT_STATUS)
706
+ out["rejects"] = sorted(statuses & REJECT_STATUS)
707
+ if raises:
708
+ out["raises"] = sorted(set(raises))
709
+ if reads:
710
+ out["reads"] = reads
711
+ if nested:
712
+ out["nested"] = nested
713
+ if rejects_via:
714
+ out["rejects_via"] = rejects_via
715
+ if helpers:
716
+ out["checked_in"] = helpers
717
+ out["effect"] = "rejects" if (statuses & REJECT_STATUS or rejects_via) else ("raises" if statuses or raises else "reads")
718
+ self.stats["dependencies_evaluated"] += 1
719
+ return out
720
+
721
+ def middleware_entry(self, e, ctx) -> dict | None:
722
+ """An access entry for a middleware class: what its `dispatch` / `__call__` rejects (`raise
723
+ HTTPException(401)`, `return JSONResponse(..., status_code=401)`), statuses from helpers it calls included."""
724
+ name = (dotted(e) or "").rsplit(".", 1)[-1] if e is not None else ""
725
+ if not name:
726
+ return None
727
+ ent = {"name": name, "via": "middleware"}
728
+ try:
729
+ t = self.prog.infer(e, ctx)
730
+ except RecursionError:
731
+ t = None
732
+ if not (t and t[0] == "type") and not re.search(r"auth|login|session|permission|jwt|token", name, re.I):
733
+ return None # a library middleware that is not access control (CORS, GZip, HTTPS redirect)
734
+ if t and t[0] == "type":
735
+ statuses, helpers = set(), []
736
+ for mname in ("dispatch", "__call__"):
737
+ m = self.prog.find_method(t[1], mname)
738
+ if m is None or m.cls is not t[1]:
739
+ continue
740
+ for sub in walk_body(m.node):
741
+ if isinstance(sub, ast.Raise) and isinstance(sub.exc, ast.Call):
742
+ st = self._status(kwarg(sub.exc, "status_code") or (sub.exc.args[0] if sub.exc.args else None))
743
+ if st is not None:
744
+ statuses.add(st)
745
+ elif isinstance(sub, ast.Call) and kwarg(sub, "status_code") is not None:
746
+ st = self._status(kwarg(sub, "status_code"))
747
+ if st is not None:
748
+ statuses.add(st)
749
+ hs, hn = self._helper_statuses(m, Ctx(m.module, m, m.cls), 0, {m.qual})
750
+ statuses |= hs
751
+ helpers += hn
752
+ if statuses:
753
+ chk = {"effect": "rejects" if statuses & REJECT_STATUS else "raises"}
754
+ if statuses & REJECT_STATUS:
755
+ chk["rejects"] = sorted(statuses & REJECT_STATUS)
756
+ if statuses - REJECT_STATUS:
757
+ chk["raises_status"] = sorted(statuses - REJECT_STATUS)
758
+ if helpers:
759
+ chk["checked_in"] = helpers
760
+ ent["checks"] = chk
761
+ return ent
762
+
763
+ def _helper_statuses(self, fn: FuncInfo, fctx, depth: int, seen: set) -> tuple[set, list]:
764
+ """HTTP statuses raised by project functions the dependency calls (#59): `def get_user(...): return
765
+ check_token(token)` with `check_token` raising `HTTPException(401)`. Two levels of helpers."""
766
+ statuses, names = set(), []
767
+ if depth > 3:
768
+ return statuses, names
769
+ for sub in walk_body(fn.node):
770
+ if not isinstance(sub, ast.Call):
771
+ continue
772
+ try:
773
+ t = self.prog.infer(sub.func, fctx)
774
+ except RecursionError:
775
+ continue
776
+ h = t[1] if t and t[0] in ("func", "bound") and isinstance(t[1], FuncInfo) else None
777
+ if h is None or h.qual in seen or h.file is None:
778
+ continue
779
+ seen = seen | {h.qual}
780
+ hctx = Ctx(h.module, h, h.cls)
781
+ found = set()
782
+ for r in walk_body(h.node):
783
+ if isinstance(r, ast.Raise) and isinstance(r.exc, ast.Call):
784
+ st = self._status(kwarg(r.exc, "status_code") or (r.exc.args[0] if r.exc.args else None))
785
+ if st is not None:
786
+ found.add(st)
787
+ deeper, dn = self._helper_statuses(h, hctx, depth + 1, seen)
788
+ found |= deeper
789
+ if found:
790
+ statuses |= found
791
+ names.append(h.name)
792
+ names += [x for x in dn if x not in names]
793
+ return statuses, names
794
+
795
+ def handler_deps(self, f: FuncInfo) -> list[dict]:
796
+ out = []
797
+ for p, x, cx in self._param_deps(f):
798
+ n = self.dep_name(x, cx)
799
+ if n and not any(o["name"] == n for o in out):
800
+ ent = self.dep_entry(x, cx, f"parameter {p.arg}")
801
+ if ent:
802
+ out.append(ent)
803
+ ctx = Ctx(f.module, f, f.cls)
804
+ for d in f.decorators:
805
+ fn = d.func if isinstance(d, ast.Call) else d
806
+ nm = (dotted(fn) or "").rsplit(".", 1)[-1]
807
+ if isinstance(fn, ast.Attribute) and (fn.attr in ROUTE_DECOS or self.obj_of(fn.value, ctx) is not None):
808
+ continue # the route decorator itself
809
+ if nm and nm not in SKIP_DECOS and not any(o["name"] == nm for o in out):
810
+ out.append({"name": nm, "via": "decorator"})
811
+ return out
812
+
813
+ def handler(self, e, ctx):
814
+ """A view function, or (class, [methods]) for `View.as_view(...)` / an HTTPEndpoint class."""
815
+ if e is None:
816
+ return None
817
+ if isinstance(e, ast.Call) and isinstance(e.func, ast.Attribute) and e.func.attr == "as_view":
818
+ e = e.func.value
819
+ try:
820
+ t = self.prog.infer(e, ctx)
821
+ except RecursionError:
822
+ return None
823
+ if t and t[0] == "func":
824
+ return t[1]
825
+ if t and t[0] == "bound":
826
+ return t[1]
827
+ if t and t[0] == "type":
828
+ return t[1]
829
+ return None
830
+
831
+ def _op(self, obj, path, methods, h, ctx, line, how, name=None, extra_deps=(), ws=False, **ext):
832
+ if path is None:
833
+ self.unresolved.append(f"{ctx.mod.file}:{line} {how}")
834
+ path = UNKNOWN
835
+ path = flask_path(path) if obj.fw == "flask" else starlette_path(path)
836
+ self.ops.append({"obj": obj, "path": path, "methods": methods, "handler": h, "file": ctx.mod.file, "line": line,
837
+ "how": how, "name": name, "deps": list(extra_deps), "ws": ws,
838
+ **{k: v for k, v in ext.items() if v}})
839
+
840
+ CLASSFUL = {"index": ("GET", False), "get": ("GET", True), "post": ("POST", False), "put": ("PUT", True),
841
+ "patch": ("PATCH", True), "delete": ("DELETE", True)}
842
+
843
+ def _classful(self, c: ast.Call, ctx) -> bool:
844
+ """Flask-Classful `QuotesView.register(app)` (#59): `route_base` (default: the class name without `View`,
845
+ lower case) + `route_prefix`; index / get / post / put / patch / delete by their HTTP method, any other
846
+ public method as `GET /base/name/`, its parameters as path parts; `@route("/x", methods=[...])` overrides."""
847
+ try:
848
+ t = self.prog.infer(c.func.value, ctx)
849
+ except RecursionError:
850
+ return False
851
+ if not (t and t[0] == "type" and self.prog.subclass_of(t[1], "FlaskView")):
852
+ return False
853
+ obj = self.obj_of(c.args[0], ctx)
854
+ if obj is None or obj.fw != "flask":
855
+ return False
856
+ cls = t[1]
857
+ cctx = Ctx(cls.module, None, cls)
858
+
859
+ def cattr(name):
860
+ v = cls.attrs.get(name)
861
+ return str_value(self.prog, v[0], cctx) if v and v[0] is not None else None
862
+ base = str_value(self.prog, kwarg(c, "route_base"), ctx) or cattr("route_base")
863
+ if base is None:
864
+ nm = cls.name[:-4] if cls.name.endswith("View") and len(cls.name) > 4 else cls.name
865
+ base = "/" + nm.lower()
866
+ prefix = str_value(self.prog, kwarg(c, "route_prefix"), ctx) or cattr("route_prefix") or ""
867
+ base = join(prefix, base)
868
+ for mname, f in cls.methods.items():
869
+ if mname.startswith("_") or mname in ("before_request", "after_request", "register"):
870
+ continue
871
+ if any(mname.startswith(p) for p in ("before_", "after_")):
872
+ continue
873
+ params = [a.arg for a in f.node.args.args[1:]]
874
+ custom = None
875
+ for d in f.decorators:
876
+ if isinstance(d, ast.Call) and (dotted(d.func) or "").rsplit(".", 1)[-1] == "route" and d.args:
877
+ custom = (str_value(self.prog, d.args[0], Ctx(cls.module, f, cls)), self._methods(kwarg(d, "methods"), ctx))
878
+ if custom is not None:
879
+ path, methods = join(base, custom[0] or UNKNOWN), custom[1]
880
+ elif mname in self.CLASSFUL:
881
+ verb, with_id = self.CLASSFUL[mname]
882
+ path, methods = join(base, *[f"<{p}>" for p in params]) + "/", [verb]
883
+ else:
884
+ path, methods = join(base, mname, *[f"<{p}>" for p in params]) + "/", ["GET"]
885
+ self._op(obj, path, methods, f, ctx, c.lineno, "FlaskView.register", f"{cls.name}:{mname}")
886
+ self.stats["classful_routes"] += 1
887
+ return True
888
+
889
+ def _flask_ext(self, c, ctx) -> dict:
890
+ """Flask `subdomain=` / `host=` (host matching) / `defaults=` of a rule."""
891
+ out = {}
892
+ for k in ("subdomain", "host"):
893
+ v = kwarg(c, k)
894
+ if v is not None:
895
+ out[k] = str_value(self.prog, v, ctx) or UNKNOWN
896
+ dv = kwarg(c, "defaults")
897
+ if isinstance(dv, ast.Dict):
898
+ out["defaults"] = sorted(str_value(self.prog, k, ctx) or "?" for k in dv.keys if k is not None)
899
+ return out
900
+
901
+ def _methods(self, e, ctx, default=("GET",)):
902
+ if e is None:
903
+ return list(default)
904
+ if isinstance(e, (ast.List, ast.Tuple, ast.Set)):
905
+ out = [str_value(self.prog, x, ctx) for x in e.elts]
906
+ out = [x.upper() for x in out if x]
907
+ return out or list(default)
908
+ return list(default)
909
+
910
+ def _decorator(self, f, d: ast.Call, nested: str | None = None, node=None):
911
+ is_cls = isinstance(f, ClassInfo)
912
+ ctx = Ctx(f.module, None, None) if is_cls else Ctx(f.module, f, f.cls)
913
+ if nested: # resolves in the enclosing def's scope (its local `app`)
914
+ octx = ctx
915
+ obj = self.obj_of(d.func.value, ctx)
916
+ else:
917
+ octx = Ctx(f.module, None, None)
918
+ obj = self.obj_of(d.func.value, octx)
919
+ if obj is None:
920
+ return
921
+ attr = d.func.attr
922
+ pe = d.args[0] if d.args else kwarg(d, "path") or kwarg(d, "rule")
923
+ path = str_value(self.prog, pe, octx) if pe is not None else ""
924
+ deps = self.deps_of(kwarg(d, "dependencies"), octx, "route dependencies")
925
+ nm = str_value(self.prog, kwarg(d, "name"), octx) or str_value(self.prog, kwarg(d, "endpoint"), octx) or nested
926
+ ext = self._flask_ext(d, octx) if obj.fw == "flask" else {}
927
+ if node is not None: # the nested view's own parameters (Depends) and decorators count as its access
928
+ ext["deps_fn"] = FuncInfo(node.name, f"{f.qual}.{node.name}", f.module, node,
929
+ decorators=list(node.decorator_list))
930
+ if attr == "endpoint": # Flask `@app.endpoint("index")`: the view of an endpoint name
931
+ ep = str_value(self.prog, d.args[0] if d.args else None, octx)
932
+ if ep:
933
+ self.endpoints[(obj.key, ep)] = f
934
+ return
935
+ if is_cls and obj.kind in ("api", "namespace") and attr == "route":
936
+ # flask-restx `@ns.route("/a", "/b")` on a Resource class: one rule per path, methods from the class
937
+ for pe2 in d.args or [kwarg(d, "path")]:
938
+ self._op(obj, str_value(self.prog, pe2, octx) if pe2 is not None else "",
939
+ self._methods(kwarg(d, "methods"), octx, default=()), f, octx, d.lineno, "@route (Resource)",
940
+ str_value(self.prog, kwarg(d, "endpoint"), octx) or f.name.lower(), deps, **ext)
941
+ return
942
+ if attr in VERBS:
943
+ self._op(obj, path, [attr.upper()], f, octx, d.lineno, f"@{attr}", nm, deps, **ext)
944
+ elif attr in ("route", "api_route"):
945
+ self._op(obj, path, self._methods(kwarg(d, "methods"), octx, default=() if is_cls else ("GET",)), f, octx,
946
+ d.lineno, f"@{attr}", nm, deps, **ext)
947
+ elif attr in ("websocket", "websocket_route"):
948
+ self._op(obj, path, ["WS"], f, octx, d.lineno, f"@{attr}", nm, deps, ws=True)
949
+
950
+ def _call(self, c: ast.Call, ctx):
951
+ attr = c.func.attr
952
+ if attr == "add" and isinstance(c.func.value, ast.Attribute) and c.func.value.attr == "url_map":
953
+ obj = self.obj_of(c.func.value.value, ctx) # werkzeug `app.url_map.add(Rule(...) | Submount(...))`
954
+ if obj is not None and obj.fw == "flask" and c.args:
955
+ self._werkzeug_rule(obj, c.args[0], ctx, "")
956
+ return
957
+ if attr == "register" and c.args:
958
+ if self._classful(c, ctx):
959
+ return
960
+ if attr not in ("include_router", "register_blueprint", "mount", "add_api_route", "add_route", "add_url_rule",
961
+ "add_websocket_route", "add_api_websocket_route", "host", "add_resource", "add_namespace",
962
+ "init_app", "add_middleware"):
963
+ return
964
+ obj = self.obj_of(c.func.value, ctx)
965
+ if obj is None:
966
+ return
967
+ prog = self.prog
968
+ if attr == "add_middleware": # Starlette / FastAPI `app.add_middleware(AuthMiddleware)` (#59)
969
+ ent = self.middleware_entry(c.args[0] if c.args else kwarg(c, "middleware_class"), ctx)
970
+ if ent and not any(d["name"] == ent["name"] for d in obj.deps):
971
+ obj.deps.append(ent)
972
+ return
973
+ if attr in ("include_router", "register_blueprint"):
974
+ child = self.obj_of(c.args[0], ctx) if c.args else None
975
+ if child is None:
976
+ return
977
+ pk = kwarg(c, "prefix") if attr == "include_router" else kwarg(c, "url_prefix")
978
+ pre = (str_value(prog, pk, ctx) or UNKNOWN) if pk is not None else None
979
+ sd = kwarg(c, "subdomain")
980
+ self._attach(obj, child, pre, c.lineno, ctx.mod.file,
981
+ deps=self.deps_of(kwarg(c, "dependencies"), ctx, "include dependencies"),
982
+ subdomain=(str_value(prog, sd, ctx) or UNKNOWN) if sd is not None else None,
983
+ bp_name=str_value(prog, kwarg(c, "name"), ctx) if attr == "register_blueprint" else None)
984
+ elif attr == "mount":
985
+ pe = c.args[0] if c.args else kwarg(c, "path")
986
+ ae = c.args[1] if len(c.args) > 1 else kwarg(c, "app")
987
+ child = self.obj_of(ae, ctx) if ae is not None else None
988
+ if child is not None:
989
+ self._attach(obj, child, str_value(prog, pe, ctx) or UNKNOWN, c.lineno, ctx.mod.file, mount=True)
990
+ elif attr == "host": # Starlette `app.host("api.example.com", app=sub)`
991
+ he = c.args[0] if c.args else kwarg(c, "host")
992
+ ae = c.args[1] if len(c.args) > 1 else kwarg(c, "app")
993
+ child = self.obj_of(ae, ctx) if ae is not None else None
994
+ if child is not None:
995
+ self._attach(obj, child, "", c.lineno, ctx.mod.file, mount=True,
996
+ host=str_value(prog, he, ctx) or UNKNOWN)
997
+ elif attr == "init_app": # flask-restful `api.init_app(app)`
998
+ parent = self.obj_of(c.args[0] if c.args else kwarg(c, "app"), ctx)
999
+ if obj.kind == "api" and parent is not None:
1000
+ self._attach(parent, obj, None, c.lineno, ctx.mod.file)
1001
+ elif attr == "add_namespace": # flask-restx `api.add_namespace(ns, path="/x")`
1002
+ child = self.obj_of(c.args[0], ctx) if c.args else None
1003
+ if child is not None:
1004
+ pe = c.args[1] if len(c.args) > 1 else kwarg(c, "path")
1005
+ self._attach(obj, child, (str_value(prog, pe, ctx) or UNKNOWN) if pe is not None else None,
1006
+ c.lineno, ctx.mod.file)
1007
+ elif attr == "add_resource": # flask-restful `api.add_resource(Todo, "/todos/<id>", endpoint="todo")`
1008
+ if not c.args:
1009
+ return
1010
+ h = self.handler(c.args[0], ctx)
1011
+ ep = str_value(prog, kwarg(c, "endpoint"), ctx) or (h.name.lower() if isinstance(h, ClassInfo) else None)
1012
+ for pe in c.args[1:] or [kwarg(c, "urls")]:
1013
+ self._op(obj, str_value(prog, pe, ctx), [], h, ctx, c.lineno, "add_resource", ep)
1014
+ elif attr in ("add_api_route", "add_route", "add_websocket_route", "add_api_websocket_route"):
1015
+ pe = c.args[0] if c.args else kwarg(c, "path")
1016
+ he = c.args[1] if len(c.args) > 1 else kwarg(c, "endpoint") or kwarg(c, "route")
1017
+ h = self.handler(he, ctx) or self._local_def(he, ctx)
1018
+ ws = "websocket" in attr
1019
+ self._op(obj, str_value(prog, pe, ctx), ["WS"] if ws else self._methods(kwarg(c, "methods"), ctx), h, ctx,
1020
+ c.lineno, attr, str_value(prog, kwarg(c, "name"), ctx), ws=ws)
1021
+ elif attr == "add_url_rule":
1022
+ pe = c.args[0] if c.args else kwarg(c, "rule")
1023
+ ve = c.args[2] if len(c.args) > 2 else kwarg(c, "view_func")
1024
+ ee = c.args[1] if len(c.args) > 1 else kwarg(c, "endpoint")
1025
+ if ve is None:
1026
+ # an endpoint without a view function: its view comes from `@app.endpoint(name)`,
1027
+ # `app.view_functions[name] = f` or another rule with that endpoint (resolved in routes())
1028
+ nm = str_value(prog, ee, ctx)
1029
+ if nm is not None:
1030
+ self._op(obj, str_value(prog, pe, ctx), self._methods(kwarg(c, "methods"), ctx, default=()), None,
1031
+ ctx, c.lineno, "add_url_rule (endpoint)", nm, endpoint_only=True,
1032
+ **self._flask_ext(c, ctx))
1033
+ return
1034
+ h = self.handler(ve, ctx) or self._local_def(ve, ctx)
1035
+ nm = str_value(prog, ee, ctx)
1036
+ if nm is None and isinstance(ve, ast.Call) and ve.args:
1037
+ nm = str_value(prog, ve.args[0], ctx) # View.as_view("name")
1038
+ if nm is None and isinstance(ve, ast.Name):
1039
+ nm = ve.id # Flask's default endpoint: the view function's name
1040
+ self._op(obj, str_value(prog, pe, ctx), self._methods(kwarg(c, "methods"), ctx, default=()), h, ctx,
1041
+ c.lineno, "add_url_rule", nm, **self._flask_ext(c, ctx))
1042
+
1043
+ def _werkzeug_rule(self, obj, e, ctx, prefix, depth=0):
1044
+ """werkzeug `Rule(path, endpoint=, methods=)` / `Submount(prefix, [rules])` added to a Flask url_map: the
1045
+ endpoint's view comes from `@app.endpoint` / `view_functions[...]`, as for add_url_rule without view_func."""
1046
+ if not isinstance(e, ast.Call) or depth > 4:
1047
+ return
1048
+ kind = (dotted(e.func) or "").rsplit(".", 1)[-1]
1049
+ p = str_value(self.prog, e.args[0] if e.args else kwarg(e, "string"), ctx)
1050
+ if kind == "Submount" and len(e.args) > 1 and isinstance(e.args[1], (ast.List, ast.Tuple)):
1051
+ for x in e.args[1].elts:
1052
+ self._werkzeug_rule(obj, x, ctx, join(prefix, p or UNKNOWN), depth + 1)
1053
+ elif kind == "Rule":
1054
+ ep = str_value(self.prog, kwarg(e, "endpoint"), ctx)
1055
+ if ep:
1056
+ self._op(obj, join(prefix, p) if p is not None else None,
1057
+ self._methods(kwarg(e, "methods"), ctx, default=()), None, ctx, e.lineno,
1058
+ "url_map.add(Rule)", ep, endpoint_only=True)
1059
+
1060
+ def _static(self):
1061
+ """Flask's built-in static route: `GET /static/<path:filename>` on every app (`static_url_path=`, none with
1062
+ `static_folder=None`), and on blueprints created with `static_folder=` (under their prefix)."""
1063
+ for o in list(self.objs.values()):
1064
+ if o.fw != "flask" or o.call is None or o.kind not in ("app", "blueprint"):
1065
+ continue
1066
+ sf = kwarg(o.call, "static_folder")
1067
+ if o.kind == "app" and isinstance(sf, ast.Constant) and sf.value is None:
1068
+ continue
1069
+ if o.kind == "blueprint" and sf is None:
1070
+ continue
1071
+ ctx = Ctx(o.mod, o.func, o.func.cls if o.func else None)
1072
+ su = kwarg(o.call, "static_url_path")
1073
+ base = str_value(self.prog, su, ctx) if su is not None else "/static"
1074
+ self._op(o, join(base or UNKNOWN, "<path:filename>"), ["GET"], None, ctx, o.line, "static", "static",
1075
+ static=True)
1076
+ self.stats["static_routes"] += 1
1077
+
1078
+ def _route_list(self, obj, e, ctx, prefix, depth=0, host=None):
1079
+ """Starlette `routes=[Route(...), WebSocketRoute(...), Mount(path, routes=[...] | app=x),
1080
+ Host("api.example.com", routes=[...] | app=x)]`."""
1081
+ if depth > 4:
1082
+ return
1083
+ if isinstance(e, ast.Name):
1084
+ r = self.prog.resolve_name(ctx.mod, e.id)
1085
+ if ctx.func is not None:
1086
+ lv = self.prog.local_vars(ctx).get(e.id)
1087
+ if lv:
1088
+ e = next((v for v, _a, k in lv if k == "assign" and v is not None), None)
1089
+ return self._route_list(obj, e, ctx, prefix, depth + 1, host)
1090
+ if r and r[0] == "var":
1091
+ v = next((v for v, _ln, _a in r[1].vars.get(r[2], ()) if v is not None), None)
1092
+ return self._route_list(obj, v, Ctx(r[1], None, None), prefix, depth + 1, host)
1093
+ return
1094
+ if not isinstance(e, (ast.List, ast.Tuple)):
1095
+ return
1096
+ for x in e.elts:
1097
+ if not isinstance(x, ast.Call):
1098
+ continue
1099
+ kind = (dotted(x.func) or "").rsplit(".", 1)[-1]
1100
+ pe = x.args[0] if x.args else kwarg(x, "path")
1101
+ p = str_value(self.prog, pe, ctx)
1102
+ if kind in ("Route", "APIRoute", "WebSocketRoute", "APIWebSocketRoute"):
1103
+ he = x.args[1] if len(x.args) > 1 else kwarg(x, "endpoint")
1104
+ ws = "WebSocket" in kind
1105
+ self._op(obj, join(prefix, p) if p is not None else None, ["WS"] if ws else self._methods(kwarg(x, "methods"), ctx),
1106
+ self.handler(he, ctx), ctx, x.lineno, kind, str_value(self.prog, kwarg(x, "name"), ctx), ws=ws,
1107
+ host=host)
1108
+ elif kind in ("Mount", "Host"):
1109
+ if kind == "Host": # the first argument is a host name, not a path
1110
+ h2 = str_value(self.prog, x.args[0] if x.args else kwarg(x, "host"), ctx) or UNKNOWN
1111
+ sub_prefix, sub_host = prefix, h2
1112
+ else:
1113
+ sub_prefix, sub_host = join(prefix, p or UNKNOWN), host
1114
+ sub = kwarg(x, "routes")
1115
+ if sub is not None:
1116
+ self._route_list(obj, sub, ctx, sub_prefix, depth + 1, sub_host)
1117
+ else:
1118
+ ae = x.args[1] if len(x.args) > 1 else kwarg(x, "app")
1119
+ child = self.obj_of(ae, ctx) if ae is not None else None
1120
+ if child is not None:
1121
+ self._attach(obj, child, sub_prefix, x.lineno, ctx.mod.file, mount=True, host=sub_host)
1122
+
1123
+ # ------------------------------------------------------------------ routes
1124
+ def placements(self):
1125
+ """obj key -> [(full prefix incl. the object's own, deps, chain, mounted, blueprint-name chain)], walking from
1126
+ the roots (objects nothing mounts). FastAPI: parent prefix + include prefix + the router's own prefix; Flask:
1127
+ `register_blueprint(url_prefix=)` replaces the blueprint's own url_prefix."""
1128
+ out = defaultdict(list)
1129
+
1130
+ def visit(o, full, deps, chain, names, depth, ext, over, bp_name=None):
1131
+ if depth > 12 or o.key in chain:
1132
+ return
1133
+ names = names + ([bp_name or o.name] if (bp_name or o.name) else [])
1134
+ deps = deps + o.deps
1135
+ chain = chain + [o.key]
1136
+ ext = {**ext, **{k: v for k, v in (("host", o.host), ("subdomain", o.subdomain)) if v}, **over}
1137
+ out[o.key].append((full, deps, chain, True, names, ext))
1138
+ for c in o.children:
1139
+ ch, cp = c["obj"], c["prefix"]
1140
+ if ch.fw == "flask" and not c.get("mount"):
1141
+ sub = join(full, cp if cp is not None else ch.prefix)
1142
+ else:
1143
+ sub = join(full, cp or "", ch.prefix)
1144
+ visit(ch, sub, deps + c["deps"], chain, names, depth + 1, ext,
1145
+ {k: c[k] for k in ("host", "subdomain") if c.get(k)}, c.get("bp_name"))
1146
+
1147
+ for o in self.objs.values():
1148
+ if o.parents == 0:
1149
+ visit(o, join(o.prefix), [], [], [], 0, {}, {})
1150
+ for o in self.objs.values():
1151
+ if o.key not in out: # only reachable through a cycle
1152
+ out[o.key].append((join(o.prefix), o.deps, [o.key], False, [o.name] if o.name else [], {}))
1153
+ return out
1154
+
1155
+ def _declared_methods(self, c: ClassInfo) -> list[str]:
1156
+ """`methods = ["GET", "POST"]` on a Flask View / MethodView class or a local base class."""
1157
+ for k in [c] + [b[1] for b in self.prog.mro(c) if b[0] == "type"]:
1158
+ if "methods" in k.attrs:
1159
+ v = k.attrs["methods"][0]
1160
+ if isinstance(v, (ast.List, ast.Tuple, ast.Set)):
1161
+ return [x.upper() for x in (str_value(self.prog, e, Ctx(k.module, None, k)) for e in v.elts) if x]
1162
+ return []
1163
+ return []
1164
+
1165
+ def routes(self) -> list[dict]:
1166
+ pl = self.placements()
1167
+ rows = []
1168
+ named = {}
1169
+ for op in self.ops:
1170
+ if op["handler"] is not None and op["name"]:
1171
+ named.setdefault((op["obj"].key, op["name"]), op)
1172
+ for op in self.ops:
1173
+ o = op["obj"]
1174
+ h = op["handler"]
1175
+ methods = op["methods"]
1176
+ if op.get("endpoint_only"): # add_url_rule(rule, endpoint="x") without a view_func
1177
+ h = self.endpoints.get((o.key, op["name"]))
1178
+ other = named.get((o.key, op["name"]))
1179
+ if h is None and other is not None:
1180
+ h = other["handler"]
1181
+ methods = methods or other["methods"]
1182
+ self.stats["endpoint_rules"] += 1
1183
+ if h is not None:
1184
+ self.stats["endpoint_rules_resolved"] += 1
1185
+ if isinstance(h, ClassInfo): # View.as_view() / HTTPEndpoint: one route per HTTP method it defines
1186
+ defined = [m.upper() for m in VIEW_METHODS if self.prog.find_method(h, m)]
1187
+ allowed = methods or self._declared_methods(h) # MethodView `methods = [...]` override
1188
+ if defined:
1189
+ want = [m for m in defined if m in allowed] if allowed else defined
1190
+ handlers = [(m, self.prog.find_method(h, m.lower())) for m in want]
1191
+ else: # a plain View: dispatch_request serves every allowed method
1192
+ disp = self.prog.find_method(h, "dispatch_request") or h
1193
+ handlers = [(m, disp) for m in (allowed or ["GET"])]
1194
+ handlers = handlers or [(m, h) for m in (methods or ["GET"])]
1195
+ else:
1196
+ handlers = [(m, h) for m in (methods or ["GET"])]
1197
+ for full, deps, chain, mounted, names, ext in pl[o.key]:
1198
+ uri = join(full, op["path"])
1199
+ ep = op["name"] or (h.name if isinstance(h, (FuncInfo, ClassInfo)) else None)
1200
+ name = ".".join(names + [ep]) if o.fw == "flask" and ep else ep
1201
+ dfn = op.get("deps_fn") or h
1202
+ fdeps = deps + op["deps"] + (self.handler_deps(dfn) if isinstance(dfn, FuncInfo) else [])
1203
+ for m, hh in handlers:
1204
+ rows.append({"method": m, "uri": uri, "handler": hh, "file": op["file"], "line": op["line"],
1205
+ "framework": o.fw, "name": name, "mounted": mounted and o.kind == "app" or
1206
+ mounted and len(chain) > 1 and self.objs[chain[0]].kind == "app",
1207
+ "how": op["how"], "access": _dedupe(fdeps),
1208
+ "chain": [".".join(x for x in k if x) for k in chain],
1209
+ "host": op.get("host") or ext.get("host"),
1210
+ "subdomain": op.get("subdomain") or ext.get("subdomain"),
1211
+ "defaults": op.get("defaults"), "endpoint_only": op.get("endpoint_only"),
1212
+ "static": op.get("static")})
1213
+ return rows
1214
+
1215
+
1216
+ LOOP_CALLS = {"add_url_rule", "add_api_route", "add_route", "add_websocket_route", "register", "include_router",
1217
+ "register_blueprint", "add_resource"}
1218
+
1219
+
1220
+ def _unroll_loops(stmts) -> tuple[list, set]:
1221
+ """Registration calls inside `for name, view in [("a", AView), ("b", BView)]: app.add_url_rule(f"/{name}",
1222
+ view_func=view.as_view(name))` (#59): one copy of each call per literal element, the loop names replaced by the
1223
+ element's parts. Returns the copies and the ids of the originals they replace (at most 30 elements a loop)."""
1224
+ import copy
1225
+ extra, skip = [], set()
1226
+ for loop in stmts:
1227
+ if not isinstance(loop, ast.For) or not isinstance(loop.iter, (ast.List, ast.Tuple)) or len(loop.iter.elts) > 30:
1228
+ continue
1229
+ tgt = loop.target
1230
+ names = [tgt.id] if isinstance(tgt, ast.Name) else \
1231
+ [x.id for x in tgt.elts if isinstance(x, ast.Name)] if isinstance(tgt, ast.Tuple) else []
1232
+ if not names or (isinstance(tgt, ast.Tuple) and len(names) != len(tgt.elts)):
1233
+ continue
1234
+ calls = [c for st in loop.body for c in ast.walk(st) if isinstance(c, ast.Call)
1235
+ and isinstance(c.func, ast.Attribute) and c.func.attr in LOOP_CALLS]
1236
+ if not calls:
1237
+ continue
1238
+ binds = []
1239
+ for el in loop.iter.elts:
1240
+ if isinstance(tgt, ast.Name):
1241
+ binds.append({names[0]: el})
1242
+ elif isinstance(el, (ast.Tuple, ast.List)) and len(el.elts) == len(names):
1243
+ binds.append(dict(zip(names, el.elts)))
1244
+ if len(binds) != len(loop.iter.elts):
1245
+ continue
1246
+
1247
+ class Sub(ast.NodeTransformer):
1248
+ def __init__(self, b):
1249
+ self.b = b
1250
+
1251
+ def visit_Name(self, n):
1252
+ return copy.deepcopy(self.b[n.id]) if n.id in self.b and isinstance(n.ctx, ast.Load) else n
1253
+ for c in calls:
1254
+ skip.add(id(c))
1255
+ for b in binds:
1256
+ extra.append(Sub(b).visit(copy.deepcopy(c)))
1257
+ return extra, skip
1258
+
1259
+
1260
+ def _module_level(tree):
1261
+ stack = list(ast.iter_child_nodes(tree))
1262
+ while stack:
1263
+ n = stack.pop()
1264
+ yield n
1265
+ if not isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
1266
+ stack.extend(ast.iter_child_nodes(n))
1267
+
1268
+
1269
+ def _dedupe(xs):
1270
+ out = []
1271
+ for x in xs:
1272
+ if not any(o["name"] == x["name"] for o in out):
1273
+ out.append(x)
1274
+ return out
1275
+
1276
+
1277
+ def emit(b: GraphBuilder, rows: list[dict]) -> int:
1278
+ from ..django.plugin import mod_of
1279
+ n = 0
1280
+ for r in rows:
1281
+ uri = re.sub(r"/{2,}", "/", r["uri"])
1282
+ name = f"{r['method']} {uri}"
1283
+ # host-aware key (#59): a Starlette `Host` / Flask `host=` (host matching) / `subdomain=` route does not merge
1284
+ # with a same-path route on another host; the name stays `METHOD /path`, the host is in attrs
1285
+ hk = r.get("host") or (f"{r['subdomain']}.*" if r.get("subdomain") else None)
1286
+ key = f"{name} @{hk}" if hk else name
1287
+ h = r["handler"]
1288
+ attrs = {"uri": uri, "method": r["method"], "framework": r["framework"], "name": r["name"],
1289
+ "mounted": r["mounted"], "registration": r["how"], "router_chain": r["chain"] or None,
1290
+ "trailing_slash": uri.endswith("/") and uri != "/", "path_params": re.findall(r"\{(\w+)\*?\}", uri),
1291
+ "host": r.get("host"), "subdomain": r.get("subdomain"), "defaults": r.get("defaults"),
1292
+ "endpoint_alias": True if r.get("endpoint_only") else None, "static": r.get("static")}
1293
+ if r["access"]:
1294
+ attrs["access"] = r["access"]
1295
+ if isinstance(h, (FuncInfo, ClassInfo)):
1296
+ attrs["handler"] = h.id
1297
+ rid = b.add_node("route", key, name=name, file=r["file"], line=r["line"], module=mod_of(r["file"]), lang="python",
1298
+ entry_kind="websocket" if r["method"] == "WS" else ("http_route" if r["mounted"] else None),
1299
+ attrs={k: v for k, v in attrs.items() if v is not None})
1300
+ if isinstance(h, (FuncInfo, ClassInfo)):
1301
+ b.add_edge(rid, h.id, "ROUTES_TO", r["file"], r["line"], EXACT if r["how"].startswith("@") else RESOLVED)
1302
+ n += 1
1303
+ return n
1304
+
1305
+
1306
+ class _PyWebPlugin(FrameworkPlugin):
1307
+ language = "python"
1308
+ frameworks: set = set()
1309
+
1310
+ def detect(self, project: Project) -> bool:
1311
+ return _mentions(project, PKG[self.name])
1312
+
1313
+ def contribute(self, project: Project, builder: GraphBuilder, prog: PyProgram) -> dict:
1314
+ r = Routes(prog, self.frameworks)
1315
+ r.collect()
1316
+ rows = r.routes()
1317
+ n = emit(builder, rows)
1318
+ st = {"apps": sum(1 for o in r.objs.values() if o.kind == "app"),
1319
+ "routers": sum(1 for o in r.objs.values() if o.kind != "app"), "routes": n,
1320
+ "unmounted_routes": sum(1 for x in rows if not x["mounted"]), **r.stats}
1321
+ if r.unresolved:
1322
+ st["paths_unresolved"] = r.unresolved[:20]
1323
+ return st
1324
+
1325
+
1326
+ class FastAPIPlugin(_PyWebPlugin):
1327
+ """FastAPI and Starlette."""
1328
+ name = "fastapi"
1329
+ frameworks = {"fastapi", "starlette"}
1330
+
1331
+
1332
+ class FlaskPlugin(_PyWebPlugin):
1333
+ name = "flask"
1334
+ frameworks = {"flask"}