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,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