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.
@@ -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