codeterrain 0.3.0__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.
- codeterrain/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
codeterrain/languages.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Language packs: installed distributions (``codeterrain-terraform`` and the like) that add the
|
|
2
|
+
definitions and references of another language to ``terrain def`` and ``terrain refs``.
|
|
3
|
+
|
|
4
|
+
A pack registers one entry point in the ``codeterrain.languages`` group, named after its language,
|
|
5
|
+
pointing at an object with these attributes:
|
|
6
|
+
|
|
7
|
+
- ``interface``: the version of this contract it implements (``INTERFACE``);
|
|
8
|
+
- ``suffixes`` and ``read(source: str) -> dict``, for ``def`` and ``refs``: the file suffixes it
|
|
9
|
+
reads (``(".tf",)``) and the facts of one file's text, ``{"defs": [...], "refs": [...]}``, each
|
|
10
|
+
definition ``{"name", "kind", "line", "doc"}`` and each reference ``{"name", "line", "scope"}``
|
|
11
|
+
(``scope`` is the name of the definition the reference sits in, or ``""``);
|
|
12
|
+
- ``call_suffixes`` and ``read_calls(source: str, suffix: str) -> dict``, for ``calls`` and
|
|
13
|
+
``callers``: the front-end file suffixes it reads in place of the built-in reader, and the HTTP
|
|
14
|
+
calls of one file, ``{"calls": [...], "error": str | None}`` in the shape
|
|
15
|
+
``calls_static.CALL_FACT_TYPES`` describes.
|
|
16
|
+
|
|
17
|
+
A pack offers either pair or both. ``read`` and ``read_calls`` are pure functions of the text:
|
|
18
|
+
they read no other file and run nothing. The core walks
|
|
19
|
+
the repository, caches the facts of each file by content, checks every field the pack returns and
|
|
20
|
+
answers the queries, so a pack never sees the cache or the repository. A pack is code that runs in
|
|
21
|
+
this process, so the import path is first cleared of every entry a repository being read controls
|
|
22
|
+
(``executables.drop_distrusted_imports``), and a pack whose distribution or module still lies inside
|
|
23
|
+
one is refused before it is imported.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import importlib
|
|
30
|
+
import importlib.metadata
|
|
31
|
+
import importlib.util
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Callable, Iterable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from codeterrain import code_static
|
|
39
|
+
from codeterrain.cache import Cache, ViewRecord
|
|
40
|
+
from codeterrain.executables import distrusted, drop_distrusted_imports
|
|
41
|
+
|
|
42
|
+
#: The version of the pack contract this core implements.
|
|
43
|
+
INTERFACE = 1
|
|
44
|
+
GROUP = "codeterrain.languages"
|
|
45
|
+
_NAME = re.compile(r"[a-z][a-z0-9_]{0,31}\Z")
|
|
46
|
+
_SUFFIX = re.compile(r"\.[A-Za-z0-9_.-]{1,16}\Z")
|
|
47
|
+
|
|
48
|
+
MAX_FILE_BYTES = code_static.MAX_FILE_BYTES
|
|
49
|
+
MAX_FILE_FACTS = code_static.MAX_FILE_FACTS
|
|
50
|
+
#: Longest name, kind, scope or doc text kept from a pack.
|
|
51
|
+
MAX_TEXT = 1_000
|
|
52
|
+
MAX_MATCHES = code_static.MAX_MATCHES
|
|
53
|
+
|
|
54
|
+
DEF_TYPES = {"name": str, "kind": str, "line": "int", "doc": str}
|
|
55
|
+
REF_TYPES = {"name": str, "line": "int", "scope": str}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Language:
|
|
60
|
+
name: str
|
|
61
|
+
version: str
|
|
62
|
+
suffixes: tuple[str, ...]
|
|
63
|
+
read: Callable[[str], Any] | None
|
|
64
|
+
call_suffixes: tuple[str, ...] = ()
|
|
65
|
+
read_calls: Callable[[str, str], Any] | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def view(self) -> str:
|
|
69
|
+
return f"lang-{self.name}"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def object_kind(self) -> str:
|
|
73
|
+
"""Facts are cached per pack version: a new version of a pack re-reads every file."""
|
|
74
|
+
tag = hashlib.sha256(f"{INTERFACE}\0{self.version}".encode()).hexdigest()[:12]
|
|
75
|
+
return f"lang-{self.name}-{tag}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _origins(module: str) -> list[Path] | None:
|
|
79
|
+
"""Every place the top-level package of ``module`` would be imported from (all the parts of
|
|
80
|
+
a namespace package), found without importing it; None when it cannot be found."""
|
|
81
|
+
try:
|
|
82
|
+
spec = importlib.util.find_spec(module.partition(".")[0])
|
|
83
|
+
except (ImportError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
if spec is None:
|
|
86
|
+
return None
|
|
87
|
+
places = [Path(p) for p in spec.submodule_search_locations or []]
|
|
88
|
+
if spec.origin and spec.origin not in ("built-in", "frozen", "namespace"):
|
|
89
|
+
places.append(Path(spec.origin))
|
|
90
|
+
return places or None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load(entry: importlib.metadata.EntryPoint, dist: importlib.metadata.Distribution) -> Any:
|
|
94
|
+
"""The object ``entry`` names; raises ``ValueError`` for a pack the repository could have
|
|
95
|
+
planted (its distribution, or the module the name would import, inside it)."""
|
|
96
|
+
located = Path(str(dist.locate_file("")))
|
|
97
|
+
if not located.is_absolute() or distrusted(located):
|
|
98
|
+
raise ValueError("installed inside the repository; not loaded")
|
|
99
|
+
origins = _origins(entry.module)
|
|
100
|
+
if origins is None:
|
|
101
|
+
raise ValueError(f"module {entry.module} not found")
|
|
102
|
+
if any(distrusted(origin) for origin in origins):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"module {entry.module} resolves inside the repository; not loaded"
|
|
105
|
+
)
|
|
106
|
+
return entry.load()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _language(name: str, version: str, found: Any) -> Language:
|
|
110
|
+
interface = getattr(found, "interface", None)
|
|
111
|
+
if interface != INTERFACE:
|
|
112
|
+
raise ValueError(f"implements interface {interface!r}; this codeterrain reads {INTERFACE}")
|
|
113
|
+
pairs = []
|
|
114
|
+
for suffixes_name, read_name in (("suffixes", "read"), ("call_suffixes", "read_calls")):
|
|
115
|
+
suffixes = getattr(found, suffixes_name, None)
|
|
116
|
+
read = getattr(found, read_name, None)
|
|
117
|
+
if suffixes is None and read is None:
|
|
118
|
+
pairs.append(((), None))
|
|
119
|
+
continue
|
|
120
|
+
if (
|
|
121
|
+
not isinstance(suffixes, tuple | list) or not suffixes
|
|
122
|
+
or not all(isinstance(s, str) and _SUFFIX.match(s) for s in suffixes)
|
|
123
|
+
):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"{suffixes_name} must be a non-empty list of file suffixes such as '.tf'"
|
|
126
|
+
)
|
|
127
|
+
if not callable(read):
|
|
128
|
+
raise ValueError(f"has {suffixes_name} but no {read_name} function")
|
|
129
|
+
pairs.append((tuple(suffixes), read))
|
|
130
|
+
if all(read is None for _, read in pairs):
|
|
131
|
+
raise ValueError("has no read function")
|
|
132
|
+
(suffixes, read), (call_suffixes, read_calls) = pairs
|
|
133
|
+
return Language(name, version, suffixes, read, call_suffixes, read_calls)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def discover(
|
|
137
|
+
path: list[str] | None = None,
|
|
138
|
+
) -> tuple[list[Language], list[str]]:
|
|
139
|
+
"""The installed language packs, and a line for each one that was refused. The import path
|
|
140
|
+
is first cleared of what the repositories being read control, so neither a pack nor anything
|
|
141
|
+
it imports later can be resolved inside one."""
|
|
142
|
+
drop_distrusted_imports()
|
|
143
|
+
found: dict[str, Language] = {}
|
|
144
|
+
problems: list[str] = []
|
|
145
|
+
for dist in importlib.metadata.distributions(**({"path": path} if path is not None else {})):
|
|
146
|
+
for entry in dist.entry_points:
|
|
147
|
+
if entry.group != GROUP or entry.name in found:
|
|
148
|
+
continue
|
|
149
|
+
label = f"language pack {entry.name!r} ({dist.metadata['Name']})"
|
|
150
|
+
if not _NAME.match(entry.name):
|
|
151
|
+
problems.append(f"{label}: the name must be lowercase letters, digits or _")
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
found[entry.name] = _language(
|
|
155
|
+
entry.name, dist.version or "", _load(entry, dist)
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc: # a broken pack must not stop the other answers
|
|
158
|
+
problems.append(f"{label}: {exc}")
|
|
159
|
+
return sorted(found.values(), key=lambda lang: lang.name), problems
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- facts ---------------------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _typed(row: Any, types: dict[str, Any]) -> bool:
|
|
166
|
+
if not isinstance(row, dict) or set(row) != set(types):
|
|
167
|
+
return False
|
|
168
|
+
for key, kind in types.items():
|
|
169
|
+
value = row[key]
|
|
170
|
+
if kind == "int":
|
|
171
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
172
|
+
return False
|
|
173
|
+
elif not isinstance(value, kind) or len(value) > MAX_TEXT:
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _valid_facts(facts: Any) -> bool:
|
|
179
|
+
return (
|
|
180
|
+
isinstance(facts, dict)
|
|
181
|
+
and set(facts) == {"defs", "refs", "error"}
|
|
182
|
+
and (facts["error"] is None or isinstance(facts["error"], str))
|
|
183
|
+
and isinstance(facts["defs"], list)
|
|
184
|
+
and isinstance(facts["refs"], list)
|
|
185
|
+
and len(facts["defs"]) + len(facts["refs"]) <= MAX_FILE_FACTS
|
|
186
|
+
and all(_typed(d, DEF_TYPES) for d in facts["defs"])
|
|
187
|
+
and all(_typed(r, REF_TYPES) for r in facts["refs"])
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _failed(error: str) -> dict[str, Any]:
|
|
192
|
+
return {"defs": [], "refs": [], "error": error}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_file(language: Language, data: bytes) -> dict[str, Any]:
|
|
196
|
+
"""The checked facts of one file: a pack that raises or returns anything malformed gives a
|
|
197
|
+
file error, never a crash or an unchecked field."""
|
|
198
|
+
if len(data) > MAX_FILE_BYTES:
|
|
199
|
+
return _failed(f"file larger than {MAX_FILE_BYTES} bytes; not indexed")
|
|
200
|
+
assert language.read is not None
|
|
201
|
+
try:
|
|
202
|
+
got = language.read(data.decode("utf-8", errors="replace"))
|
|
203
|
+
except Exception as exc: # the pack's own failure on one file
|
|
204
|
+
return _failed(f"{type(exc).__name__}: {str(exc)[:MAX_TEXT]}")
|
|
205
|
+
facts = {
|
|
206
|
+
"defs": got.get("defs") if isinstance(got, dict) else None,
|
|
207
|
+
"refs": got.get("refs") if isinstance(got, dict) else None,
|
|
208
|
+
"error": None,
|
|
209
|
+
}
|
|
210
|
+
if not _valid_facts(facts):
|
|
211
|
+
return _failed(f"language pack {language.name} returned malformed facts")
|
|
212
|
+
return facts
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _valid_view(data: Any, language: Language) -> bool:
|
|
216
|
+
return (
|
|
217
|
+
isinstance(data, dict)
|
|
218
|
+
and data.get("format") == language.object_kind
|
|
219
|
+
and isinstance(data.get("defs"), list)
|
|
220
|
+
and isinstance(data.get("refs"), list)
|
|
221
|
+
and isinstance(data.get("errors"), list)
|
|
222
|
+
and all(_located(d, DEF_TYPES) for d in data["defs"])
|
|
223
|
+
and all(_located(r, REF_TYPES) for r in data["refs"])
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _located(row: Any, types: dict[str, Any]) -> bool:
|
|
228
|
+
"""A view row: the fact with the ``file`` it was read from."""
|
|
229
|
+
return (
|
|
230
|
+
isinstance(row, dict) and isinstance(row.get("file"), str)
|
|
231
|
+
and _typed({k: v for k, v in row.items() if k != "file"}, types)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build(cache: Cache, language: Language, force: bool = False) -> ViewRecord:
|
|
236
|
+
"""The view of one language: its definitions and references in every file it reads,
|
|
237
|
+
rebuilt incrementally when a file changed."""
|
|
238
|
+
fingerprint = cache.scan(None, language.suffixes)
|
|
239
|
+
record = cache.read_view(language.view)
|
|
240
|
+
if (
|
|
241
|
+
record is not None and not force and record.fingerprint == fingerprint
|
|
242
|
+
and (
|
|
243
|
+
record := cache.served(
|
|
244
|
+
record, language.object_kind, lambda d: _valid_view(d, language)
|
|
245
|
+
)
|
|
246
|
+
) is not None
|
|
247
|
+
):
|
|
248
|
+
cache.flush()
|
|
249
|
+
return record
|
|
250
|
+
objects = cache.load_objects(language.object_kind)
|
|
251
|
+
added = False
|
|
252
|
+
for rel, sha in fingerprint.items():
|
|
253
|
+
if not _valid_facts(objects.get(sha)):
|
|
254
|
+
with open(cache.root / rel, "rb") as f:
|
|
255
|
+
# never more than the cap, whatever the file's size
|
|
256
|
+
objects[sha] = read_file(language, f.read(MAX_FILE_BYTES + 1))
|
|
257
|
+
added = True
|
|
258
|
+
if added:
|
|
259
|
+
cache.save_objects(language.object_kind, objects)
|
|
260
|
+
defs: list[dict[str, Any]] = []
|
|
261
|
+
refs: list[dict[str, Any]] = []
|
|
262
|
+
errors: list[dict[str, str]] = []
|
|
263
|
+
for rel in sorted(fingerprint):
|
|
264
|
+
facts = objects[fingerprint[rel]]
|
|
265
|
+
if facts["error"]:
|
|
266
|
+
errors.append({"file": rel, "error": facts["error"]})
|
|
267
|
+
defs.extend({**d, "file": rel} for d in facts["defs"])
|
|
268
|
+
refs.extend({**r, "file": rel} for r in facts["refs"])
|
|
269
|
+
record = cache.write_view(
|
|
270
|
+
language.view,
|
|
271
|
+
{"format": language.object_kind, "defs": defs, "refs": refs, "errors": errors},
|
|
272
|
+
fingerprint,
|
|
273
|
+
sign=True, # built from checked facts: a warm read need not check every row again
|
|
274
|
+
)
|
|
275
|
+
cache.flush()
|
|
276
|
+
return record
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- queries -------------------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def find(data: dict[str, Any], name: str, kind: str | None = None) -> list[dict[str, Any]]:
|
|
283
|
+
"""The definitions named ``name``, or whose last dotted part is ``name`` (``logs`` finds
|
|
284
|
+
``aws_s3_bucket.logs``), of ``kind`` if given; at most ``MAX_MATCHES``."""
|
|
285
|
+
defs = [d for d in data["defs"] if kind is None or d["kind"] == kind]
|
|
286
|
+
exact = [d for d in defs if d["name"] == name]
|
|
287
|
+
found = exact or [d for d in defs if d["name"].endswith(f".{name}")]
|
|
288
|
+
return found[:MAX_MATCHES]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def references(data: dict[str, Any], names: Iterable[str]) -> list[dict[str, Any]]:
|
|
292
|
+
wanted = set(names)
|
|
293
|
+
return [r for r in data["refs"] if r["name"] in wanted]
|
|
File without changes
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""FastAPI route plugin: the ``iter_routes`` hook, run inside the target's interpreter.
|
|
2
|
+
|
|
3
|
+
FastAPI 0.137 stopped flattening included routers into ``app.routes``; they stay as nested
|
|
4
|
+
containers and ``fastapi.routing._iter_routes_with_context`` yields each concrete route together
|
|
5
|
+
with its effective (prefix- and dependency-merged) context. That helper is private, so it is read
|
|
6
|
+
in exactly one function (``iter_route_contexts``) with the flat ``app.routes`` walk as the fallback.
|
|
7
|
+
|
|
8
|
+
A context carries the handler only for ``APIRoute``; websockets, plain Starlette routes and mounts
|
|
9
|
+
sit in ``context.starlette_route`` (already prefixed). Mounts are walked into their sub-app;
|
|
10
|
+
``Host`` routes are not walked and are reported through ``host_routes_seen``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import sys
|
|
17
|
+
import types
|
|
18
|
+
import typing
|
|
19
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from codeterrain.hookspecs import hookimpl
|
|
23
|
+
|
|
24
|
+
_HOSTS: list[str] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def forget_host_routes() -> None:
|
|
28
|
+
_HOSTS.clear()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def host_routes_seen() -> list[str]:
|
|
32
|
+
"""Host patterns met (and skipped) by the last ``iter_routes`` call."""
|
|
33
|
+
return list(_HOSTS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def iter_route_contexts(routes: Sequence[Any]) -> Iterator[tuple[Any, Any | None]]:
|
|
37
|
+
"""(route, effective context or None) for every concrete route in ``routes``.
|
|
38
|
+
|
|
39
|
+
Private API, isolated here: ``fastapi.routing._iter_routes_with_context`` (FastAPI >= 0.137).
|
|
40
|
+
Older versions already hold the merged routes flat in ``app.routes``.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
from fastapi import routing
|
|
44
|
+
|
|
45
|
+
walker = getattr(routing, "_iter_routes_with_context", None)
|
|
46
|
+
except ImportError:
|
|
47
|
+
walker = None
|
|
48
|
+
if walker is None:
|
|
49
|
+
for route in routes:
|
|
50
|
+
yield route, None
|
|
51
|
+
return
|
|
52
|
+
yield from walker(routes)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _callable_name(func: Any) -> str:
|
|
56
|
+
target = inspect.unwrap(func) if callable(func) else func
|
|
57
|
+
module = getattr(target, "__module__", "") or ""
|
|
58
|
+
name = getattr(target, "__qualname__", None) or type(target).__name__
|
|
59
|
+
return f"{module}.{name}" if module else name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _class_location(cls: type) -> tuple[str, int]:
|
|
63
|
+
try:
|
|
64
|
+
file = inspect.getsourcefile(cls) or inspect.getfile(cls)
|
|
65
|
+
return file, inspect.getsourcelines(cls)[1]
|
|
66
|
+
except (OSError, TypeError):
|
|
67
|
+
module = sys.modules.get(getattr(cls, "__module__", ""), None)
|
|
68
|
+
return getattr(module, "__file__", None) or "", 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _location(endpoint: Any) -> tuple[str, int]:
|
|
72
|
+
"""Absolute file and first line of the handler, decorators and wrappers looked through.
|
|
73
|
+
|
|
74
|
+
A class endpoint (``HTTPEndpoint``) reports the class statement."""
|
|
75
|
+
target = inspect.unwrap(endpoint)
|
|
76
|
+
if inspect.isclass(target):
|
|
77
|
+
return _class_location(target)
|
|
78
|
+
for candidate in (target, type(target).__call__):
|
|
79
|
+
code = getattr(candidate, "__code__", None)
|
|
80
|
+
if code is not None:
|
|
81
|
+
return code.co_filename, code.co_firstlineno
|
|
82
|
+
return "", 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_websocket(source: Any) -> bool:
|
|
86
|
+
from starlette.routing import WebSocketRoute
|
|
87
|
+
|
|
88
|
+
return isinstance(source, WebSocketRoute)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _methods(source: Any) -> list[str]:
|
|
92
|
+
"""HTTP methods sorted, ``WEBSOCKET`` for websockets, ``ANY`` when Starlette matches any method
|
|
93
|
+
(``methods`` None, or empty: FastAPI < 0.137 turns a class endpoint's None into ``[]``)."""
|
|
94
|
+
if not hasattr(source, "methods") or _is_websocket(source):
|
|
95
|
+
return ["WEBSOCKET"]
|
|
96
|
+
if not source.methods:
|
|
97
|
+
return ["ANY"]
|
|
98
|
+
found = sorted(source.methods)
|
|
99
|
+
if "GET" in found and "HEAD" in found:
|
|
100
|
+
found.remove("HEAD")
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _also_head(source: Any) -> bool:
|
|
105
|
+
"""True when the route answers HEAD next to GET (a Starlette ``Route`` adds it; a FastAPI
|
|
106
|
+
``APIRoute`` does not), which ``_methods`` folds into the GET record."""
|
|
107
|
+
methods = getattr(source, "methods", None) or ()
|
|
108
|
+
return "GET" in methods and "HEAD" in methods
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _type_name(tp: Any) -> str:
|
|
112
|
+
"""``Item``, ``list[Item]``, ``Optional[Item]``: how the annotation reads in source."""
|
|
113
|
+
if tp is None or tp is type(None):
|
|
114
|
+
return "None"
|
|
115
|
+
if tp is Ellipsis:
|
|
116
|
+
return "..."
|
|
117
|
+
origin, args = typing.get_origin(tp), typing.get_args(tp)
|
|
118
|
+
if origin is None:
|
|
119
|
+
return getattr(tp, "__name__", None) or str(tp)
|
|
120
|
+
if origin is typing.Annotated:
|
|
121
|
+
return _type_name(args[0])
|
|
122
|
+
# types.UnionType (X | Y) is 3.10+; the plugin also runs on a 3.9 target.
|
|
123
|
+
if origin is typing.Union or origin is getattr(types, "UnionType", typing.Union):
|
|
124
|
+
members = [a for a in args if a is not type(None)]
|
|
125
|
+
inner = _type_name(members[0]) if len(members) == 1 else (
|
|
126
|
+
"Union[" + ", ".join(_type_name(a) for a in members) + "]"
|
|
127
|
+
)
|
|
128
|
+
return f"Optional[{inner}]" if len(members) < len(args) else inner
|
|
129
|
+
if origin is typing.Literal:
|
|
130
|
+
return "Literal[" + ", ".join(repr(a) for a in args) + "]"
|
|
131
|
+
name = getattr(origin, "__name__", None) or str(origin)
|
|
132
|
+
return f"{name}[{', '.join(_type_name(a) for a in args)}]" if args else name
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _response_model(source: Any) -> str | None:
|
|
136
|
+
model = getattr(source, "response_model", None)
|
|
137
|
+
if model is None:
|
|
138
|
+
return None
|
|
139
|
+
return _type_name(model)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _dependencies(source: Any) -> list[str]:
|
|
143
|
+
dependant = getattr(source, "dependant", None)
|
|
144
|
+
names: list[str] = []
|
|
145
|
+
for dep in getattr(dependant, "dependencies", None) or []:
|
|
146
|
+
call = getattr(dep, "call", None)
|
|
147
|
+
if call is not None:
|
|
148
|
+
names.append(_callable_name(call))
|
|
149
|
+
return names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _records(routes: Iterable[Any], prefix: str) -> Iterator[dict[str, Any]]:
|
|
153
|
+
from starlette.routing import Host, Mount
|
|
154
|
+
|
|
155
|
+
for route, context in iter_route_contexts(list(routes)):
|
|
156
|
+
effective = getattr(context, "starlette_route", None)
|
|
157
|
+
source = context if getattr(context, "endpoint", None) is not None else None
|
|
158
|
+
source = source or effective or route
|
|
159
|
+
if isinstance(source, Host) or isinstance(route, Host):
|
|
160
|
+
_HOSTS.append(str(getattr(source, "host", "")))
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(source, Mount):
|
|
163
|
+
yield from _records(source.routes, prefix + source.path)
|
|
164
|
+
continue
|
|
165
|
+
endpoint = getattr(source, "endpoint", None)
|
|
166
|
+
path = getattr(source, "path", None)
|
|
167
|
+
if endpoint is None or path is None:
|
|
168
|
+
continue
|
|
169
|
+
file, line = _location(endpoint)
|
|
170
|
+
methods_of = source if source is context else route
|
|
171
|
+
for method in _methods(methods_of):
|
|
172
|
+
yield {
|
|
173
|
+
"method": method,
|
|
174
|
+
"also_head": method == "GET" and _also_head(methods_of),
|
|
175
|
+
"path": prefix + path,
|
|
176
|
+
"handler": _callable_name(endpoint),
|
|
177
|
+
"file": file,
|
|
178
|
+
"line": line,
|
|
179
|
+
"dependencies": _dependencies(source),
|
|
180
|
+
"response_model": _response_model(source),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@hookimpl
|
|
185
|
+
def iter_routes(app: Any) -> list[dict[str, Any]] | None:
|
|
186
|
+
try:
|
|
187
|
+
from starlette.applications import Starlette
|
|
188
|
+
except ImportError:
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(app, Starlette):
|
|
191
|
+
return None
|
|
192
|
+
forget_host_routes()
|
|
193
|
+
return list(_records(app.routes, ""))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Join extracted routes to an access manifest (a JSON file the repository already keeps).
|
|
2
|
+
|
|
3
|
+
Host side: reads the manifest, never imports the target. Rows are matched on (method, path) with
|
|
4
|
+
path converters ignored; the first matching row wins. A row that lists ``apps`` applies only to the
|
|
5
|
+
routes whose app module equals one of them (an empty list applies to none; a bare string is one
|
|
6
|
+
module); a row without ``apps`` applies to every app.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ManifestError(ValueError):
|
|
19
|
+
"""The route manifest is missing or does not have the configured shape."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class JoinResult:
|
|
24
|
+
rows: list[dict[str, Any]]
|
|
25
|
+
routes_without_row: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
rows_without_route: list[dict[str, Any]] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_rows(path: Path, keys: dict[str, str]) -> list[dict[str, Any]]:
|
|
30
|
+
try:
|
|
31
|
+
document = json.loads(path.read_bytes())
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
raise ManifestError(f"route manifest not readable: {path.as_posix()} ({exc})") from exc
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ManifestError(f"route manifest is not JSON: {path.as_posix()} ({exc})") from exc
|
|
36
|
+
if isinstance(document, list):
|
|
37
|
+
rows = document
|
|
38
|
+
elif isinstance(document, dict):
|
|
39
|
+
rows = document.get(keys["rows"])
|
|
40
|
+
else:
|
|
41
|
+
raise ManifestError("route manifest must be a JSON object or a list of rows")
|
|
42
|
+
if not isinstance(rows, list):
|
|
43
|
+
raise ManifestError(f"route manifest has no list under {keys['rows']!r}")
|
|
44
|
+
for key in ("method", "path", "access"):
|
|
45
|
+
if any(not isinstance(r, dict) or keys[key] not in r for r in rows):
|
|
46
|
+
raise ManifestError(f"route manifest rows must all carry {keys[key]!r} ({key})")
|
|
47
|
+
for key in ("method", "path"):
|
|
48
|
+
if any(not isinstance(r[keys[key]], str) for r in rows):
|
|
49
|
+
raise ManifestError(f"manifest {keys[key]!r} ({key}) must be a string in every row")
|
|
50
|
+
if any(not isinstance(r[keys["access"]], str) for r in rows):
|
|
51
|
+
raise ManifestError(f"manifest {keys['access']!r} (access) must be a string in every row")
|
|
52
|
+
for row in rows:
|
|
53
|
+
if keys["apps"] in row and not _app_list(row[keys["apps"]]):
|
|
54
|
+
raise ManifestError(
|
|
55
|
+
f"manifest {keys['apps']!r} (apps) must be a string or a list of strings"
|
|
56
|
+
)
|
|
57
|
+
return rows
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _app_list(value: Any) -> bool:
|
|
61
|
+
return isinstance(value, str) or (
|
|
62
|
+
isinstance(value, list) and all(isinstance(v, str) for v in value)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_CONVERTER = re.compile(r"\{([^}:]+):[^}]*\}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def route_key(method: str, path: str) -> tuple[str, str]:
|
|
70
|
+
"""(METHOD, path) with Starlette converters dropped: ``{slug:path}`` and ``{slug}`` are one."""
|
|
71
|
+
return method.upper(), _CONVERTER.sub(lambda m: "{" + m.group(1) + "}", path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _app_module(app: str) -> str:
|
|
75
|
+
return app.partition(":")[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _applies(row_apps: Any, app: str) -> bool:
|
|
79
|
+
if row_apps is None:
|
|
80
|
+
return True
|
|
81
|
+
modules = [row_apps] if isinstance(row_apps, str) else row_apps
|
|
82
|
+
return _app_module(app) in modules
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def join(
|
|
86
|
+
routes: list[dict[str, Any]], manifest_rows: list[dict[str, Any]], keys: dict[str, str]
|
|
87
|
+
) -> JoinResult:
|
|
88
|
+
index: dict[tuple[str, str], list[int]] = {}
|
|
89
|
+
for position, row in enumerate(manifest_rows):
|
|
90
|
+
key = route_key(str(row[keys["method"]]), row[keys["path"]])
|
|
91
|
+
index.setdefault(key, []).append(position)
|
|
92
|
+
used: set[int] = set()
|
|
93
|
+
result = JoinResult(rows=[])
|
|
94
|
+
for route in routes:
|
|
95
|
+
chosen = None
|
|
96
|
+
for position in index.get(route_key(route["method"], route["path"]), []):
|
|
97
|
+
if _applies(manifest_rows[position].get(keys["apps"]), route["app"]):
|
|
98
|
+
chosen = position
|
|
99
|
+
break
|
|
100
|
+
if chosen is None:
|
|
101
|
+
result.routes_without_row.append(route)
|
|
102
|
+
result.rows.append({**route, "access": None})
|
|
103
|
+
continue
|
|
104
|
+
used.add(chosen)
|
|
105
|
+
result.rows.append({**route, "access": manifest_rows[chosen][keys["access"]]})
|
|
106
|
+
result.rows_without_route = [r for i, r in enumerate(manifest_rows) if i not in used]
|
|
107
|
+
return result
|