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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Front-end calls joined to backend routes: which route a call hits, and who calls a route.
|
|
2
|
+
|
|
3
|
+
Paths are compared segment by segment after the query string and a trailing ``/`` are dropped. A
|
|
4
|
+
route parameter (``{item_id}``, ``{slug:path}``) matches any segment (``:path`` the rest of the
|
|
5
|
+
path), and one inside a segment (``{name}.json``) any text there; a ``{}`` in a call path (an
|
|
6
|
+
expression the scanner could not read) matches any text inside its segment. When several routes
|
|
7
|
+
match, the most literal match wins, then the first route in registration order, which is the order
|
|
8
|
+
the application tries them.
|
|
9
|
+
|
|
10
|
+
The join reads working copies, so ``repo_state`` reports for each input checkout whether it is
|
|
11
|
+
behind its upstream (as of its last fetch; nothing is fetched) or holds uncommitted changes: a stale
|
|
12
|
+
or dirty checkout can show a gap the deployed code does not have.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from codeterrain import calls_static
|
|
26
|
+
from codeterrain.cache import Cache, git_env
|
|
27
|
+
from codeterrain.config import ClientRepo, ConfigError, client_config
|
|
28
|
+
from codeterrain.executables import resolve_executable
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from codeterrain.languages import Language
|
|
32
|
+
|
|
33
|
+
_PARAM = re.compile(r"^\{[^{}:]+(?::([^{}]+))?\}$")
|
|
34
|
+
_INNER_PARAM = re.compile(r"\{[^{}:]+(?::[^{}]+)?\}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def segments(path: str) -> list[str]:
|
|
38
|
+
"""The segments of ``path`` without its query and fragment. Only the leading and a trailing
|
|
39
|
+
``/`` are dropped: ``/v1//items`` is not ``/v1/items`` to the application."""
|
|
40
|
+
path = re.split(r"[?#]", path, maxsplit=1)[0]
|
|
41
|
+
parts = path.split("/")
|
|
42
|
+
if parts and not parts[0]:
|
|
43
|
+
parts = parts[1:]
|
|
44
|
+
if parts and not parts[-1]:
|
|
45
|
+
parts = parts[:-1]
|
|
46
|
+
return parts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: A path split for matching: its segments and whether one is a ``{x:path}`` parameter.
|
|
50
|
+
Split = tuple[tuple[str, ...], bool]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _split(path: str) -> Split | None:
|
|
54
|
+
"""``segments(path)`` and whether one of them is a ``{x:path}`` parameter; None for a path
|
|
55
|
+
over ``MAX_PATH`` characters or ``MAX_SEGMENTS`` segments, which matches nothing. Splits are
|
|
56
|
+
made once per join and not kept after it: call paths come from front-end checkouts."""
|
|
57
|
+
if len(path) > MAX_PATH:
|
|
58
|
+
return None
|
|
59
|
+
parts = tuple(segments(path))
|
|
60
|
+
if len(parts) > MAX_SEGMENTS:
|
|
61
|
+
return None
|
|
62
|
+
return parts, any(_is_rest(part) for part in parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _glob(literals: list[str], text: str, non_empty: bool) -> bool:
|
|
66
|
+
"""Whether ``text`` is ``literals`` with a wildcard between each pair (``non_empty``: each
|
|
67
|
+
wildcard takes at least one character). Each literal is taken at its leftmost place after the
|
|
68
|
+
previous one, which is enough for wildcards that take any text, so no input backtracks: the
|
|
69
|
+
cost is linear in ``text`` per literal, where a ``.*a.*a...`` regex is exponential."""
|
|
70
|
+
if len(literals) == 1:
|
|
71
|
+
return text == literals[0]
|
|
72
|
+
gap = 1 if non_empty else 0
|
|
73
|
+
first, last = literals[0], literals[-1]
|
|
74
|
+
end = len(text) - len(last)
|
|
75
|
+
if not text.startswith(first) or end < len(first) or text[end:] != last:
|
|
76
|
+
return False
|
|
77
|
+
pos = len(first)
|
|
78
|
+
for literal in literals[1:-1]:
|
|
79
|
+
found = text.find(literal, pos + gap, end)
|
|
80
|
+
if found < 0:
|
|
81
|
+
return False
|
|
82
|
+
pos = found + len(literal)
|
|
83
|
+
return end - pos >= gap
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: Two-sided wildcard segments longer than this are compared the one-sided way (the call's pattern
|
|
87
|
+
#: against the route's template text): the overlap test below costs the product of the lengths.
|
|
88
|
+
MAX_OVERLAP = 64
|
|
89
|
+
_STAR, _ONE = object(), object()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _elements(literals: list[str], non_empty: bool) -> list[Any]:
|
|
93
|
+
"""``literals`` as characters with a wildcard between each pair: ``_STAR`` (any text), or
|
|
94
|
+
``_ONE`` then ``_STAR`` for one character or more."""
|
|
95
|
+
out: list[Any] = list(literals[0])
|
|
96
|
+
for literal in literals[1:]:
|
|
97
|
+
out += [_ONE, _STAR] if non_empty else [_STAR]
|
|
98
|
+
out += list(literal)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _patterns_overlap(call: list[str], route: list[str], template: str) -> bool:
|
|
103
|
+
"""Whether some text matches both the call segment's pattern (``{}``: any text) and the route
|
|
104
|
+
segment's (a parameter: one character or more), as ``report-{}.json`` and ``{name}.json`` do.
|
|
105
|
+
A table over the two patterns' positions, each step consuming from one side or both."""
|
|
106
|
+
a, b = _elements(call, non_empty=False), _elements(route, non_empty=True)
|
|
107
|
+
if len(a) > MAX_OVERLAP or len(b) > MAX_OVERLAP:
|
|
108
|
+
return _glob(call, template, non_empty=False)
|
|
109
|
+
seen = [[False] * (len(b) + 1) for _ in range(len(a) + 1)]
|
|
110
|
+
seen[0][0] = True
|
|
111
|
+
for i in range(len(a) + 1):
|
|
112
|
+
for j in range(len(b) + 1):
|
|
113
|
+
if not seen[i][j]:
|
|
114
|
+
continue
|
|
115
|
+
x = a[i] if i < len(a) else None
|
|
116
|
+
y = b[j] if j < len(b) else None
|
|
117
|
+
if x is _STAR:
|
|
118
|
+
seen[i + 1][j] = True # the call's wildcard takes nothing more
|
|
119
|
+
if y is not None:
|
|
120
|
+
seen[i][j + 1] = True # ... or takes what the route's next element spells
|
|
121
|
+
if y is _STAR:
|
|
122
|
+
seen[i][j + 1] = True
|
|
123
|
+
if x is not None and x is not _STAR:
|
|
124
|
+
seen[i + 1][j] = True
|
|
125
|
+
if x is not None and y is not None and x is not _STAR and y is not _STAR and (
|
|
126
|
+
x is _ONE or y is _ONE or x == y
|
|
127
|
+
):
|
|
128
|
+
seen[i + 1][j + 1] = True
|
|
129
|
+
return seen[len(a)][len(b)]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _call_literals(segment: str) -> list[str] | None:
|
|
133
|
+
"""The literal text around each ``{}`` of a call segment, None for a literal segment."""
|
|
134
|
+
return segment.split("{}") if "{}" in segment else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _route_literals(segment: str) -> list[str] | None:
|
|
138
|
+
"""The literal text around each parameter inside a route segment (``{name}.json``), None for
|
|
139
|
+
a literal one. The parameter takes what Starlette's default convertor does: one or more
|
|
140
|
+
characters (never a ``/``, which a segment cannot hold)."""
|
|
141
|
+
if not _INNER_PARAM.search(segment):
|
|
142
|
+
return None
|
|
143
|
+
return _INNER_PARAM.split(segment)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Paths longer than these match nothing: no real route is this long, and the match below costs
|
|
147
|
+
#: segments of the call times segments of the route, each pair at most the length of the two.
|
|
148
|
+
MAX_SEGMENTS = 256
|
|
149
|
+
MAX_PATH = 2048
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_rest(part: str) -> bool:
|
|
153
|
+
param = _PARAM.match(part)
|
|
154
|
+
return bool(param and param.group(1) == "path")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def score(call_path: str, route_path: str) -> int | None:
|
|
158
|
+
"""How literally ``call_path`` matches ``route_path`` (higher is closer), None for no match."""
|
|
159
|
+
call, route = _split(call_path), _split(route_path)
|
|
160
|
+
return None if call is None or route is None else _score(call[0], route)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _score(call: tuple[str, ...], split: Split) -> int | None:
|
|
164
|
+
route, rest = split
|
|
165
|
+
if len(call) != len(route) and not rest:
|
|
166
|
+
return None # without a `{x:path}`, each route segment takes exactly one call segment
|
|
167
|
+
# best[r][c]: the score of call[c:] against route[r:], filled from the end, so a route made of
|
|
168
|
+
# many `{x:path}` parameters costs len(call) * len(route) steps, not one per way to split.
|
|
169
|
+
n = len(call)
|
|
170
|
+
best: list[list[int | None]] = [[None] * (n + 1) for _ in range(len(route) + 1)]
|
|
171
|
+
best[len(route)][n] = 0
|
|
172
|
+
for r in range(len(route) - 1, -1, -1):
|
|
173
|
+
part = route[r]
|
|
174
|
+
param = _PARAM.match(part)
|
|
175
|
+
if param and param.group(1) == "path":
|
|
176
|
+
# One or more segments, then whatever the route still requires after them.
|
|
177
|
+
tail: int | None = None
|
|
178
|
+
for c in range(n - 1, -1, -1):
|
|
179
|
+
after = best[r + 1][c + 1]
|
|
180
|
+
if after is not None and (tail is None or after > tail):
|
|
181
|
+
tail = after
|
|
182
|
+
best[r][c] = None if tail is None else 1 + tail
|
|
183
|
+
continue
|
|
184
|
+
for c in range(n - 1, -1, -1):
|
|
185
|
+
after = best[r + 1][c + 1]
|
|
186
|
+
if after is None:
|
|
187
|
+
continue
|
|
188
|
+
here = _segment_score(call[c], part, param is not None)
|
|
189
|
+
if here is not None:
|
|
190
|
+
best[r][c] = here + after
|
|
191
|
+
return best[0][0]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _segment_score(mine: str, part: str, param: bool) -> int | None:
|
|
195
|
+
"""How literally the call segment ``mine`` matches the route segment ``part``; ``param`` when
|
|
196
|
+
``part`` is a whole-segment parameter."""
|
|
197
|
+
if param:
|
|
198
|
+
if not mine:
|
|
199
|
+
return None # a parameter takes at least one character
|
|
200
|
+
return 3 if mine == "{}" else 2
|
|
201
|
+
pattern = _call_literals(mine)
|
|
202
|
+
inner = _route_literals(part)
|
|
203
|
+
if inner is not None:
|
|
204
|
+
if pattern is None:
|
|
205
|
+
return 2 if _glob(inner, mine, non_empty=True) else None
|
|
206
|
+
if _INNER_PARAM.sub("{}", part) == mine:
|
|
207
|
+
return 3
|
|
208
|
+
return 1 if _patterns_overlap(pattern, inner, part) else None
|
|
209
|
+
if pattern is None:
|
|
210
|
+
return 3 if mine == part else None
|
|
211
|
+
return 1 if _glob(pattern, part, non_empty=False) else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _method_matches(method: str, route: dict[str, Any]) -> bool:
|
|
215
|
+
if route["method"] == "WEBSOCKET":
|
|
216
|
+
return False # every call the scanner finds is an HTTP request
|
|
217
|
+
if method == "?" or route["method"] in (method, "ANY"):
|
|
218
|
+
return True
|
|
219
|
+
return method == "HEAD" and route["method"] == "GET" and bool(route.get("also_head"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def best_route(call: dict[str, Any], routes: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
223
|
+
"""The route ``call`` hits; ``routes`` in registration order. Unresolved calls hit none; a call
|
|
224
|
+
whose method is unknown (``?``) matches any method, and so does a route registered for any
|
|
225
|
+
method (``ANY``, a class-based endpoint); a HEAD call hits a GET route that answers HEAD."""
|
|
226
|
+
return _best(call, _prepare(routes))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _prepare(routes: list[dict[str, Any]]) -> list[tuple[dict[str, Any], Split | None]]:
|
|
230
|
+
"""Each route with its path split once, for scoring many calls against it."""
|
|
231
|
+
return [(route, _split(route["path"])) for route in routes]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _best(
|
|
235
|
+
call: dict[str, Any], prepared: list[tuple[dict[str, Any], Split | None]]
|
|
236
|
+
) -> dict[str, Any] | None:
|
|
237
|
+
if not call["resolved"]:
|
|
238
|
+
return None
|
|
239
|
+
mine = _split(call["path"])
|
|
240
|
+
if mine is None:
|
|
241
|
+
return None
|
|
242
|
+
best: tuple[int, dict[str, Any]] | None = None
|
|
243
|
+
for route, split in prepared:
|
|
244
|
+
if split is None or not _method_matches(call["method"], route):
|
|
245
|
+
continue
|
|
246
|
+
found = _score(mine[0], split)
|
|
247
|
+
if found is not None and (best is None or found > best[0]):
|
|
248
|
+
best = (found, route)
|
|
249
|
+
return best[1] if best else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
#: ``git status`` with the repository's file-system monitor switched off: ``core.fsmonitor`` in a
|
|
253
|
+
#: checkout's own ``.git/config`` names a command git would run. No optional locks, so nothing is
|
|
254
|
+
#: written to the checkout's index.
|
|
255
|
+
_STATUS = ["--no-optional-locks", "-c", "core.fsmonitor=false", "status", "--porcelain=v2",
|
|
256
|
+
"--branch"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
#: Where the deployed code usually is: the remote's default branch, then the common names.
|
|
260
|
+
_DEFAULT_REFS = ("origin/HEAD", "origin/main", "origin/master")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
264
|
+
try:
|
|
265
|
+
return subprocess.run(
|
|
266
|
+
[resolve_executable("git", untrusted=root), "-C", str(root), *args],
|
|
267
|
+
capture_output=True,
|
|
268
|
+
text=True,
|
|
269
|
+
encoding="utf-8",
|
|
270
|
+
errors="replace",
|
|
271
|
+
check=True,
|
|
272
|
+
timeout=30,
|
|
273
|
+
env=git_env(),
|
|
274
|
+
).stdout
|
|
275
|
+
except (OSError, subprocess.SubprocessError):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _behind_default(root: Path) -> tuple[str, int] | None:
|
|
280
|
+
"""(``origin/main``, commits on it that HEAD lacks), from the first of ``_DEFAULT_REFS``
|
|
281
|
+
that exists."""
|
|
282
|
+
for ref in _DEFAULT_REFS:
|
|
283
|
+
name = _git(root, "rev-parse", "--abbrev-ref", "--verify", "--quiet", ref)
|
|
284
|
+
if name and name.strip():
|
|
285
|
+
count = _git(root, "rev-list", "--count", f"HEAD..{name.strip()}")
|
|
286
|
+
return (name.strip(), int(count)) if count and count.strip().isdigit() else None
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def repo_state(root: Path) -> dict[str, Any] | None:
|
|
291
|
+
"""``{"branch", "upstream", "ahead", "behind", "default", "behind_default", "dirty"}`` for a
|
|
292
|
+
git checkout, None when git is missing or ``root`` is not one. ``behind`` counts commits on the
|
|
293
|
+
upstream and ``behind_default`` commits on the remote's default branch (``origin/HEAD``, else
|
|
294
|
+
``origin/main`` or ``origin/master``) that the checkout lacks, as of the last fetch; ``dirty``
|
|
295
|
+
counts changed and untracked paths. A feature branch level with its own upstream can still be
|
|
296
|
+
far behind what is deployed, which is why both are kept."""
|
|
297
|
+
if not root.is_dir():
|
|
298
|
+
return None
|
|
299
|
+
out = _git(root, *_STATUS)
|
|
300
|
+
if out is None:
|
|
301
|
+
return None
|
|
302
|
+
state: dict[str, Any] = {
|
|
303
|
+
"branch": None, "upstream": None, "ahead": 0, "behind": 0, "default": None,
|
|
304
|
+
"behind_default": 0, "dirty": 0,
|
|
305
|
+
}
|
|
306
|
+
for line in out.splitlines():
|
|
307
|
+
if line.startswith("# branch.head "):
|
|
308
|
+
state["branch"] = line[len("# branch.head "):]
|
|
309
|
+
elif line.startswith("# branch.upstream "):
|
|
310
|
+
state["upstream"] = line[len("# branch.upstream "):]
|
|
311
|
+
elif line.startswith("# branch.ab "):
|
|
312
|
+
ahead, behind = line[len("# branch.ab "):].split()
|
|
313
|
+
state["ahead"], state["behind"] = abs(int(ahead)), abs(int(behind))
|
|
314
|
+
elif line and not line.startswith("#"):
|
|
315
|
+
state["dirty"] += 1
|
|
316
|
+
default = _behind_default(root)
|
|
317
|
+
if default is not None:
|
|
318
|
+
state["default"], state["behind_default"] = default
|
|
319
|
+
return state
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def state_warning(name: str, state: dict[str, Any] | None) -> str | None:
|
|
323
|
+
"""One line when a checkout may not show what is deployed, else None."""
|
|
324
|
+
if not state:
|
|
325
|
+
return None
|
|
326
|
+
parts = []
|
|
327
|
+
if state["behind"]:
|
|
328
|
+
parts.append(f"{state['behind']} commits behind {state['upstream']} (as of its last fetch)")
|
|
329
|
+
if state["behind_default"] and state["default"] != state["upstream"]:
|
|
330
|
+
parts.append(
|
|
331
|
+
f"on {state['branch']}, {state['behind_default']} commits behind {state['default']}"
|
|
332
|
+
)
|
|
333
|
+
if state["dirty"]:
|
|
334
|
+
parts.append(f"{state['dirty']} uncommitted changes")
|
|
335
|
+
if not parts:
|
|
336
|
+
return None
|
|
337
|
+
return f"{name}: {' and '.join(parts)}; a gap may be the checkout, not the code"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass
|
|
341
|
+
class Client:
|
|
342
|
+
root: Path
|
|
343
|
+
name: str
|
|
344
|
+
calls: list[dict[str, Any]] = field(default_factory=list)
|
|
345
|
+
error: str | None = None
|
|
346
|
+
state: dict[str, Any] | None = None
|
|
347
|
+
#: Files of this front end that could not be read: their calls are missing from the join.
|
|
348
|
+
errors: list[dict[str, str]] = field(default_factory=list)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def load_clients(
|
|
352
|
+
repos: tuple[ClientRepo, ...], cache_dir: str | None, fresh: bool = False,
|
|
353
|
+
readers: Sequence[Language] = (),
|
|
354
|
+
) -> list[Client]:
|
|
355
|
+
"""Each front end's calls view, built with its own configuration (``client_paths``,
|
|
356
|
+
``path_prefix``, overridden by its ``client_repos`` entry) and cached under its own root id;
|
|
357
|
+
``path_prefix`` is put in front of every resolved path. A missing or misconfigured front end
|
|
358
|
+
is reported, not fatal. ``readers``: the language packs that read calls."""
|
|
359
|
+
clients: list[Client] = []
|
|
360
|
+
for repo in repos:
|
|
361
|
+
root = repo.root
|
|
362
|
+
client = Client(root, root.name)
|
|
363
|
+
clients.append(client)
|
|
364
|
+
if not root.is_dir():
|
|
365
|
+
client.error = "not a directory"
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
config = client_config(repo)
|
|
369
|
+
except ConfigError as exc:
|
|
370
|
+
client.error = str(exc)
|
|
371
|
+
continue
|
|
372
|
+
record, _ = calls_static.build(
|
|
373
|
+
Cache(root, cache_dir), config, force=fresh, readers=readers
|
|
374
|
+
)
|
|
375
|
+
client.errors = record.data["errors"]
|
|
376
|
+
client.calls = [
|
|
377
|
+
{**c, "path": config.path_prefix + c["path"] if c["resolved"] else c["path"]}
|
|
378
|
+
for c in record.data["calls"]
|
|
379
|
+
]
|
|
380
|
+
return clients
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def load_with_states(
|
|
384
|
+
repos: tuple[ClientRepo, ...], backend: Path, cache_dir: str | None, fresh: bool = False,
|
|
385
|
+
readers: Sequence[Language] = (),
|
|
386
|
+
) -> tuple[list[Client], dict[str, Any] | None]:
|
|
387
|
+
"""``load_clients``, with ``repo_state`` of the backend and of every front end, and the
|
|
388
|
+
backend's state. ``git status`` is most of a warm ``terrain callers``, so the checkouts are
|
|
389
|
+
asked side by side, while the calls views load."""
|
|
390
|
+
roots = [backend, *(repo.root for repo in repos)]
|
|
391
|
+
with ThreadPoolExecutor(max_workers=len(roots)) as pool:
|
|
392
|
+
pending = [pool.submit(repo_state, root) for root in roots]
|
|
393
|
+
clients = load_clients(repos, cache_dir, fresh, readers)
|
|
394
|
+
backend_state, *states = [future.result() for future in pending]
|
|
395
|
+
for client, state in zip(clients, states, strict=True):
|
|
396
|
+
client.state = state
|
|
397
|
+
return clients, backend_state
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def join(clients: list[Client], routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
401
|
+
"""One row per call: the call (``repo`` set to the front end's name) and the route it hits."""
|
|
402
|
+
rows: list[dict[str, Any]] = []
|
|
403
|
+
prepared = _prepare(routes)
|
|
404
|
+
for client in clients:
|
|
405
|
+
for call in client.calls:
|
|
406
|
+
route = _best(call, prepared)
|
|
407
|
+
rows.append(
|
|
408
|
+
{
|
|
409
|
+
**call,
|
|
410
|
+
"repo": client.name,
|
|
411
|
+
"route": None
|
|
412
|
+
if route is None
|
|
413
|
+
else {k: route[k] for k in ("method", "path", "app", "handler")},
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
return rows
|