cg-code-graph 0.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,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"}
|