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/cache.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""The on-disk store: content-hash keyed, memoised on (mtime_ns, size), shared by every worktree.
|
|
2
|
+
|
|
3
|
+
Layout under the cache dir:
|
|
4
|
+
|
|
5
|
+
- ``objects-<kind>.json``: content sha -> parsed facts (path independent, so worktrees share it).
|
|
6
|
+
- ``stat-<root-id>.json``: per-checkout memo of (mtime_ns, size, sha) per file.
|
|
7
|
+
- ``views/<view>-<root-id>.json``: a rendered view, its build time, the file fingerprint it was
|
|
8
|
+
built from and (slow tier) the key of the config and environment it was built under; the slow
|
|
9
|
+
tier is served from here and stamped stale when the fingerprint moved.
|
|
10
|
+
|
|
11
|
+
Objects and views carry the format number and the codeterrain version that wrote them; a file with
|
|
12
|
+
another format or version, a missing field or unparseable JSON reads as absent and is rebuilt.
|
|
13
|
+
|
|
14
|
+
The cache lives outside the target: the git common dir, else a per-user cache directory.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import calendar
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable, Iterable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from codeterrain import __version__
|
|
33
|
+
from codeterrain.executables import resolve_executable
|
|
34
|
+
|
|
35
|
+
CACHE_ENV = "CODETERRAIN_CACHE_DIR"
|
|
36
|
+
FORMAT = 1
|
|
37
|
+
ON_WINDOWS = os.name == "nt"
|
|
38
|
+
REPLACE_ATTEMPTS = 5
|
|
39
|
+
KEY_BYTES = 32
|
|
40
|
+
GIT_ENV_VARS = frozenset(
|
|
41
|
+
{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"}
|
|
42
|
+
)
|
|
43
|
+
SKIP_DIRS = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".git",
|
|
46
|
+
".venv",
|
|
47
|
+
"node_modules",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
".tox",
|
|
50
|
+
".nox",
|
|
51
|
+
"build",
|
|
52
|
+
"dist",
|
|
53
|
+
"site-packages",
|
|
54
|
+
".mypy_cache",
|
|
55
|
+
".ruff_cache",
|
|
56
|
+
".pytest_cache",
|
|
57
|
+
".codeterrain",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CacheBusy(RuntimeError):
|
|
63
|
+
"""A cache file stayed locked by another process; try again in a moment."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def git_env() -> dict[str, str]:
|
|
67
|
+
"""The environment for a ``git`` subprocess, without variables that redirect the repository."""
|
|
68
|
+
return {k: v for k, v in os.environ.items() if k.upper() not in GIT_ENV_VARS}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def root_id(root: Path) -> str:
|
|
72
|
+
"""A stable short id of a checkout path (case-folded only where the filesystem is)."""
|
|
73
|
+
text = root.as_posix()
|
|
74
|
+
if ON_WINDOWS:
|
|
75
|
+
text = text.lower()
|
|
76
|
+
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:12]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def user_base_dir() -> Path:
|
|
80
|
+
"""``codeterrain`` in the per-user cache dir: ``%LOCALAPPDATA%`` on Windows,
|
|
81
|
+
``$XDG_CACHE_HOME`` (default ``~/.cache``) elsewhere."""
|
|
82
|
+
if ON_WINDOWS:
|
|
83
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
84
|
+
else:
|
|
85
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
86
|
+
return Path(base) / "codeterrain"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def user_cache_dir(root: Path) -> Path:
|
|
90
|
+
"""Per-user cache for a checkout git cannot place one in; never inside the checkout."""
|
|
91
|
+
return user_base_dir() / root_id(root)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def utc_now() -> str:
|
|
95
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def age_text(built_at: str, now: float | None = None) -> str:
|
|
99
|
+
"""Human age such as ``42s``, ``7m``, ``3h``, ``2d`` for an ISO ``built_at`` stamp."""
|
|
100
|
+
built = calendar.timegm(time.strptime(built_at, "%Y-%m-%dT%H:%M:%SZ"))
|
|
101
|
+
seconds = max(0, int((time.time() if now is None else now) - built))
|
|
102
|
+
if seconds < 60:
|
|
103
|
+
return f"{seconds}s"
|
|
104
|
+
if seconds < 3600:
|
|
105
|
+
return f"{seconds // 60}m"
|
|
106
|
+
if seconds < 86400:
|
|
107
|
+
return f"{seconds // 3600}h"
|
|
108
|
+
return f"{seconds // 86400}d"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _contains(outer: Path, inner: Path) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
inner.relative_to(outer)
|
|
114
|
+
except ValueError:
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_cache_dir(root: Path, override: str | os.PathLike[str] | None = None) -> Path:
|
|
120
|
+
"""``--cache-dir``, then ``$CODETERRAIN_CACHE_DIR``, then ``<git-common-dir>/codeterrain``,
|
|
121
|
+
then a per-user cache directory when git cannot place one, or when the repository git finds
|
|
122
|
+
does not contain the root (a parent directory's repository is not this checkout's).
|
|
123
|
+
Never inside the checkout."""
|
|
124
|
+
chosen = override or os.environ.get(CACHE_ENV)
|
|
125
|
+
if chosen:
|
|
126
|
+
return Path(os.path.realpath(chosen))
|
|
127
|
+
try:
|
|
128
|
+
out, top = (
|
|
129
|
+
subprocess.run(
|
|
130
|
+
[resolve_executable("git", untrusted=root), "-C", str(root), "rev-parse", flag],
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
check=True,
|
|
134
|
+
timeout=10,
|
|
135
|
+
env=git_env(),
|
|
136
|
+
).stdout.strip()
|
|
137
|
+
for flag in ("--git-common-dir", "--show-toplevel")
|
|
138
|
+
)
|
|
139
|
+
except (OSError, subprocess.SubprocessError):
|
|
140
|
+
return user_cache_dir(root)
|
|
141
|
+
if not _contains(Path(os.path.realpath(top)), root):
|
|
142
|
+
return user_cache_dir(root)
|
|
143
|
+
common = Path(out)
|
|
144
|
+
if not common.is_absolute():
|
|
145
|
+
common = root / common
|
|
146
|
+
return Path(os.path.realpath(common)) / "codeterrain"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
#: How many worktrees of one repository ``checkouts`` reads at most.
|
|
150
|
+
MAX_WORKTREES = 1_000
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Checkouts:
|
|
155
|
+
"""The git checkout holding a root: its top folder, whether it is a linked worktree (not
|
|
156
|
+
the main checkout), and the other checkouts of the same repository."""
|
|
157
|
+
|
|
158
|
+
top: Path
|
|
159
|
+
linked: bool
|
|
160
|
+
others: tuple[Path, ...]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_line(path: Path) -> str:
|
|
164
|
+
with open(path, encoding="utf-8") as f:
|
|
165
|
+
return f.readline(4096).strip()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def checkouts(root: Path) -> Checkouts | None:
|
|
169
|
+
"""The checkout ``root`` is in and the repository's other worktrees, read from git's own
|
|
170
|
+
files (``.git``, ``commondir``, ``worktrees/*/gitdir``) without running git, so it costs a
|
|
171
|
+
few reads; None outside a git checkout or when they cannot be read."""
|
|
172
|
+
try:
|
|
173
|
+
for top in (root, *root.parents):
|
|
174
|
+
dot = top / ".git"
|
|
175
|
+
if dot.is_dir():
|
|
176
|
+
common, linked = dot, False
|
|
177
|
+
break
|
|
178
|
+
if dot.is_file():
|
|
179
|
+
line = _read_line(dot)
|
|
180
|
+
if not line.startswith("gitdir:"):
|
|
181
|
+
return None
|
|
182
|
+
gitdir = Path(line.removeprefix("gitdir:").strip())
|
|
183
|
+
gitdir = gitdir if gitdir.is_absolute() else top / gitdir
|
|
184
|
+
pointer = gitdir / "commondir"
|
|
185
|
+
# Only a linked worktree's git dir has `commondir`; a main checkout made with
|
|
186
|
+
# `git init --separate-git-dir` has a `.git` file too, pointing at the common dir.
|
|
187
|
+
linked = pointer.is_file()
|
|
188
|
+
# `commondir` is relative (`../..`): resolved, so its folder name can be read.
|
|
189
|
+
common = Path(os.path.realpath(
|
|
190
|
+
gitdir / _read_line(pointer) if linked else gitdir
|
|
191
|
+
))
|
|
192
|
+
break
|
|
193
|
+
else:
|
|
194
|
+
return None
|
|
195
|
+
# The main checkout: the folder holding `.git`, or this one when it is the main checkout.
|
|
196
|
+
# From a linked worktree of a `--separate-git-dir` repository it cannot be known (git
|
|
197
|
+
# itself lists the git dir in its place), so it is left out.
|
|
198
|
+
if common.name == ".git":
|
|
199
|
+
tops = [common.parent]
|
|
200
|
+
else:
|
|
201
|
+
tops = [top] if not linked else []
|
|
202
|
+
listed = common / "worktrees"
|
|
203
|
+
if listed.is_dir():
|
|
204
|
+
for entry in sorted(listed.iterdir())[:MAX_WORKTREES]:
|
|
205
|
+
pointer = entry / "gitdir"
|
|
206
|
+
if pointer.is_file():
|
|
207
|
+
tops.append(Path(_read_line(pointer)).parent)
|
|
208
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
209
|
+
return None
|
|
210
|
+
here = os.path.realpath(top)
|
|
211
|
+
others = []
|
|
212
|
+
seen = {here}
|
|
213
|
+
for other in tops:
|
|
214
|
+
try:
|
|
215
|
+
# strict: one lookup both resolves the path and says it still exists
|
|
216
|
+
real = os.path.realpath(other, strict=True)
|
|
217
|
+
except OSError:
|
|
218
|
+
continue
|
|
219
|
+
if real not in seen and os.path.isdir(real):
|
|
220
|
+
seen.add(real)
|
|
221
|
+
others.append(Path(real))
|
|
222
|
+
return Checkouts(Path(here), linked, tuple(others))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _private_dir(path: Path) -> None:
|
|
226
|
+
"""Create ``path`` and any missing parents; on POSIX each one it creates is 0700 whatever the
|
|
227
|
+
umask (existing directories, such as ``.git``, are left alone)."""
|
|
228
|
+
if path.is_dir():
|
|
229
|
+
return
|
|
230
|
+
_private_dir(path.parent)
|
|
231
|
+
try:
|
|
232
|
+
path.mkdir()
|
|
233
|
+
except FileExistsError:
|
|
234
|
+
return
|
|
235
|
+
if not ON_WINDOWS:
|
|
236
|
+
os.chmod(path, 0o700)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _create_private(path: Path, data: bytes) -> None:
|
|
240
|
+
"""Write ``data`` to a new file that only the user can read (0600 on POSIX), never through a
|
|
241
|
+
symlink and never into a file that already exists."""
|
|
242
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
243
|
+
flags |= getattr(os, "O_BINARY", 0)
|
|
244
|
+
fd = os.open(path, flags, 0o600)
|
|
245
|
+
try:
|
|
246
|
+
if not ON_WINDOWS:
|
|
247
|
+
os.fchmod(fd, 0o600)
|
|
248
|
+
os.write(fd, data)
|
|
249
|
+
finally:
|
|
250
|
+
os.close(fd)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _atomic_write(path: Path, payload: Any) -> bytes:
|
|
254
|
+
"""Write ``payload`` as JSON in one replace; returns the bytes written."""
|
|
255
|
+
data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
256
|
+
_atomic_write_bytes(path, data)
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
261
|
+
_private_dir(path.parent)
|
|
262
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
263
|
+
try:
|
|
264
|
+
_create_private(tmp, data)
|
|
265
|
+
except FileExistsError:
|
|
266
|
+
# left behind by an earlier process with the same pid
|
|
267
|
+
tmp.unlink()
|
|
268
|
+
_create_private(tmp, data)
|
|
269
|
+
for attempt in range(REPLACE_ATTEMPTS):
|
|
270
|
+
try:
|
|
271
|
+
os.replace(tmp, path)
|
|
272
|
+
return
|
|
273
|
+
except PermissionError:
|
|
274
|
+
if attempt == REPLACE_ATTEMPTS - 1:
|
|
275
|
+
tmp.unlink(missing_ok=True)
|
|
276
|
+
raise CacheBusy(f"cache file is locked by another process: {path}") from None
|
|
277
|
+
time.sleep(0.05 * (attempt + 1))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _read_json(path: Path) -> Any | None:
|
|
281
|
+
try:
|
|
282
|
+
return json.loads(path.read_bytes())
|
|
283
|
+
except (OSError, ValueError, RecursionError):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _memo_entry(value: Any) -> bool:
|
|
288
|
+
return (
|
|
289
|
+
isinstance(value, list)
|
|
290
|
+
and len(value) == 3
|
|
291
|
+
and isinstance(value[0], int)
|
|
292
|
+
and isinstance(value[1], int)
|
|
293
|
+
and isinstance(value[2], str)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _stamped(raw: Any) -> bool:
|
|
298
|
+
return (
|
|
299
|
+
isinstance(raw, dict)
|
|
300
|
+
and raw.get("format") == FORMAT
|
|
301
|
+
and raw.get("codeterrain") == __version__
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _sha256_file(path: Path) -> str:
|
|
306
|
+
"""Hashed in chunks: a huge file in the checkout costs time, not memory."""
|
|
307
|
+
with open(path, "rb") as f:
|
|
308
|
+
return hashlib.file_digest(f, "sha256").hexdigest()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class ViewRecord:
|
|
313
|
+
view: str
|
|
314
|
+
built_at: str
|
|
315
|
+
fingerprint: dict[str, str]
|
|
316
|
+
data: Any
|
|
317
|
+
key: str | None = None
|
|
318
|
+
#: The file carries a valid MAC under the per-user key: it is what this core wrote, after
|
|
319
|
+
#: checking it, so a reader need not check it again.
|
|
320
|
+
verified: bool = False
|
|
321
|
+
|
|
322
|
+
def signed_as(self, kind: str) -> bool:
|
|
323
|
+
"""Signed by this core and of the format ``kind``: its rows were checked before it was
|
|
324
|
+
written, so a warm answer need not check every one again."""
|
|
325
|
+
return (
|
|
326
|
+
self.verified and isinstance(self.data, dict) and self.data.get("format") == kind
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Cache:
|
|
331
|
+
def __init__(self, root: Path, cache_dir: str | os.PathLike[str] | None = None) -> None:
|
|
332
|
+
self.root = root
|
|
333
|
+
self.dir = find_cache_dir(root, cache_dir)
|
|
334
|
+
self.root_id = root_id(root)
|
|
335
|
+
self._stat_path = self.dir / f"stat-{self.root_id}.json"
|
|
336
|
+
self._stat_memo: dict[str, list[Any]] | None = None
|
|
337
|
+
self._stat_dirty = False
|
|
338
|
+
#: per walk start: the suffixes its listing has served, and the listing
|
|
339
|
+
self._listings: dict[str, tuple[set[str], list[tuple[str, os.DirEntry[str]]]]] = {}
|
|
340
|
+
|
|
341
|
+
def _memo(self) -> dict[str, list[Any]]:
|
|
342
|
+
if self._stat_memo is None:
|
|
343
|
+
loaded = _read_json(self._stat_path)
|
|
344
|
+
self._stat_memo = loaded if isinstance(loaded, dict) else {}
|
|
345
|
+
for rel in [r for r, v in self._stat_memo.items() if not _memo_entry(v)]:
|
|
346
|
+
del self._stat_memo[rel]
|
|
347
|
+
return self._stat_memo
|
|
348
|
+
|
|
349
|
+
def _walk(
|
|
350
|
+
self, start: Path, suffixes: tuple[str, ...]
|
|
351
|
+
) -> Iterable[tuple[str, os.stat_result]]:
|
|
352
|
+
for rel, entry in self._files(str(start), suffixes):
|
|
353
|
+
if entry.name.endswith(suffixes) and self._readable(entry):
|
|
354
|
+
yield rel, entry.stat()
|
|
355
|
+
|
|
356
|
+
def _files(
|
|
357
|
+
self, start: str, suffixes: tuple[str, ...]
|
|
358
|
+
) -> list[tuple[str, os.DirEntry[str]]]:
|
|
359
|
+
"""Every file under ``start`` outside the skipped folders. The code index and each
|
|
360
|
+
language pack scan the same tree for different suffixes, so one walk serves each suffix
|
|
361
|
+
once; a second scan for a suffix already served walks again, and sees what changed."""
|
|
362
|
+
listing = self._listings.get(start)
|
|
363
|
+
if listing is not None and not listing[0] & set(suffixes):
|
|
364
|
+
listing[0].update(suffixes)
|
|
365
|
+
return listing[1]
|
|
366
|
+
# Paths stay strings: a warm answer walks the whole tree, and building a Path and asking
|
|
367
|
+
# `relative_to` for every file was most of its cost.
|
|
368
|
+
prefix = os.path.join(str(self.root), "")
|
|
369
|
+
found: list[tuple[str, os.DirEntry[str]]] = []
|
|
370
|
+
stack = [start]
|
|
371
|
+
while stack:
|
|
372
|
+
current = stack.pop()
|
|
373
|
+
try:
|
|
374
|
+
entries = list(os.scandir(current))
|
|
375
|
+
except OSError:
|
|
376
|
+
continue
|
|
377
|
+
# A virtual environment is skipped whole. Its `pyvenv.cfg` shows in the listing
|
|
378
|
+
# already read, which costs nothing, where asking every folder for one did.
|
|
379
|
+
if current != start and any(e.name == "pyvenv.cfg" for e in entries):
|
|
380
|
+
continue
|
|
381
|
+
for entry in entries:
|
|
382
|
+
if entry.is_dir(follow_symlinks=False):
|
|
383
|
+
if (
|
|
384
|
+
entry.name in SKIP_DIRS
|
|
385
|
+
or entry.name.startswith(".")
|
|
386
|
+
or entry.name.endswith(".egg-info")
|
|
387
|
+
or entry.is_junction()
|
|
388
|
+
):
|
|
389
|
+
continue
|
|
390
|
+
stack.append(entry.path)
|
|
391
|
+
else:
|
|
392
|
+
found.append((self._relative(entry.path, prefix), entry))
|
|
393
|
+
self._listings[start] = (set(suffixes), found)
|
|
394
|
+
return found
|
|
395
|
+
|
|
396
|
+
def _relative(self, path: str, prefix: str) -> str:
|
|
397
|
+
"""``path`` relative to the root, in POSIX form, as ``Path.relative_to().as_posix()``."""
|
|
398
|
+
if not path.startswith(prefix):
|
|
399
|
+
return Path(path).relative_to(self.root).as_posix()
|
|
400
|
+
rel = path[len(prefix):]
|
|
401
|
+
return rel.replace(os.sep, "/") if os.sep != "/" else rel
|
|
402
|
+
|
|
403
|
+
def _readable(self, entry: os.DirEntry[str]) -> bool:
|
|
404
|
+
"""A regular file, or a symlink to one that stays inside the root; never a device, pipe
|
|
405
|
+
or a link out of the checkout."""
|
|
406
|
+
if not entry.is_symlink():
|
|
407
|
+
return entry.is_file(follow_symlinks=False)
|
|
408
|
+
target = Path(os.path.realpath(entry.path))
|
|
409
|
+
return _contains(self.root, target) and target.is_file()
|
|
410
|
+
|
|
411
|
+
def scan(
|
|
412
|
+
self, subpaths: Iterable[str] | None = None, suffixes: tuple[str, ...] = (".py",)
|
|
413
|
+
) -> dict[str, str]:
|
|
414
|
+
"""Relative POSIX path -> sha256 for every matching file under ``subpaths`` (None = all)."""
|
|
415
|
+
memo = self._memo()
|
|
416
|
+
listed = list(subpaths) if subpaths is not None else None
|
|
417
|
+
starts = [self.root / p for p in listed] if listed is not None else [self.root]
|
|
418
|
+
prefixes = (
|
|
419
|
+
tuple(Path(p).as_posix().rstrip("/") + "/" for p in listed)
|
|
420
|
+
if listed is not None
|
|
421
|
+
else None
|
|
422
|
+
)
|
|
423
|
+
seen: dict[str, str] = {}
|
|
424
|
+
for start in starts:
|
|
425
|
+
if not _contains(self.root, Path(os.path.realpath(start))):
|
|
426
|
+
continue # a link out of the checkout; config refuses it, this is the backstop
|
|
427
|
+
if start.is_file():
|
|
428
|
+
rel = start.relative_to(self.root).as_posix()
|
|
429
|
+
found: Iterable[tuple[str, os.stat_result]] = [(rel, start.stat())]
|
|
430
|
+
elif start.is_dir():
|
|
431
|
+
found = self._walk(start, suffixes)
|
|
432
|
+
else:
|
|
433
|
+
continue
|
|
434
|
+
for rel, st in found:
|
|
435
|
+
known = memo.get(rel)
|
|
436
|
+
if known is not None and known[0] == st.st_mtime_ns and known[1] == st.st_size:
|
|
437
|
+
seen[rel] = known[2]
|
|
438
|
+
continue
|
|
439
|
+
sha = _sha256_file(self.root / rel)
|
|
440
|
+
memo[rel] = [st.st_mtime_ns, st.st_size, sha]
|
|
441
|
+
self._stat_dirty = True
|
|
442
|
+
seen[rel] = sha
|
|
443
|
+
gone = [
|
|
444
|
+
rel
|
|
445
|
+
for rel in memo
|
|
446
|
+
if rel not in seen
|
|
447
|
+
and rel.endswith(suffixes)
|
|
448
|
+
and (prefixes is None or rel.startswith(prefixes))
|
|
449
|
+
]
|
|
450
|
+
for rel in gone:
|
|
451
|
+
del memo[rel]
|
|
452
|
+
self._stat_dirty = True
|
|
453
|
+
return seen
|
|
454
|
+
|
|
455
|
+
def hash_files(self, paths: Iterable[Path]) -> dict[str, str]:
|
|
456
|
+
"""sha256 of individual files (pyproject, the route manifest) keyed by relative path."""
|
|
457
|
+
out: dict[str, str] = {}
|
|
458
|
+
for path in paths:
|
|
459
|
+
if _contains(self.root, Path(os.path.realpath(path))) and path.is_file():
|
|
460
|
+
try:
|
|
461
|
+
rel = path.relative_to(self.root).as_posix()
|
|
462
|
+
except ValueError:
|
|
463
|
+
rel = path.as_posix()
|
|
464
|
+
out[rel] = _sha256_file(path)
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
def cached_probe(self, exe: Path, compute: Callable[[], Any]) -> Any:
|
|
468
|
+
"""``compute()`` once per (interpreter path, mtime, size), remembered in the stat memo.
|
|
469
|
+
|
|
470
|
+
The memo slot for the sha holds the JSON text of the result; a failing ``compute`` stores
|
|
471
|
+
nothing, so the next call tries again."""
|
|
472
|
+
try:
|
|
473
|
+
st = exe.stat()
|
|
474
|
+
except OSError:
|
|
475
|
+
return compute()
|
|
476
|
+
memo = self._memo()
|
|
477
|
+
name = f"probe:{exe.as_posix()}"
|
|
478
|
+
known = memo.get(name)
|
|
479
|
+
if known is not None and known[0] == st.st_mtime_ns and known[1] == st.st_size:
|
|
480
|
+
try:
|
|
481
|
+
return json.loads(known[2])
|
|
482
|
+
except ValueError:
|
|
483
|
+
pass
|
|
484
|
+
value = compute()
|
|
485
|
+
memo[name] = [st.st_mtime_ns, st.st_size, json.dumps(value, sort_keys=True)]
|
|
486
|
+
self._stat_dirty = True
|
|
487
|
+
return value
|
|
488
|
+
|
|
489
|
+
def secret_key(self) -> bytes:
|
|
490
|
+
"""A random 32-byte per-user key (0600 on POSIX), created once; it keys the digests of
|
|
491
|
+
``pass_env`` values so they cannot be brute-forced from a copied view file. It lives in
|
|
492
|
+
the per-user dir (``user_base_dir``), never in the checkout's cache, so a copied ``.git``
|
|
493
|
+
does not carry it."""
|
|
494
|
+
base = user_base_dir()
|
|
495
|
+
path = base / "key"
|
|
496
|
+
try:
|
|
497
|
+
key = path.read_bytes()
|
|
498
|
+
except FileNotFoundError:
|
|
499
|
+
key = b""
|
|
500
|
+
if len(key) == KEY_BYTES:
|
|
501
|
+
return key
|
|
502
|
+
_private_dir(base)
|
|
503
|
+
if key:
|
|
504
|
+
path.unlink(missing_ok=True) # truncated or foreign: replace it
|
|
505
|
+
try:
|
|
506
|
+
_create_private(path, secrets.token_bytes(KEY_BYTES))
|
|
507
|
+
except FileExistsError:
|
|
508
|
+
pass # another process created it first; use theirs
|
|
509
|
+
return path.read_bytes()
|
|
510
|
+
|
|
511
|
+
def flush(self) -> None:
|
|
512
|
+
if self._stat_dirty and self._stat_memo is not None:
|
|
513
|
+
_atomic_write(self._stat_path, self._stat_memo)
|
|
514
|
+
self._stat_dirty = False
|
|
515
|
+
|
|
516
|
+
def load_objects(self, kind: str) -> dict[str, Any]:
|
|
517
|
+
loaded = _read_json(self.dir / f"objects-{kind}.json")
|
|
518
|
+
if not _stamped(loaded) or not isinstance(loaded.get("objects"), dict):
|
|
519
|
+
return {}
|
|
520
|
+
return loaded["objects"]
|
|
521
|
+
|
|
522
|
+
def save_objects(self, kind: str, objects: dict[str, Any]) -> None:
|
|
523
|
+
_atomic_write(
|
|
524
|
+
self.dir / f"objects-{kind}.json",
|
|
525
|
+
{"format": FORMAT, "codeterrain": __version__, "objects": objects},
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def _view_path(self, view: str) -> Path:
|
|
529
|
+
return self.dir / "views" / f"{view}-{self.root_id}.json"
|
|
530
|
+
|
|
531
|
+
def _view_mac(self, data: bytes) -> str | None:
|
|
532
|
+
"""The MAC of a view file under the per-user key; None when the key cannot be read or
|
|
533
|
+
created (a read-only or missing user cache dir): the view is then unsigned and checked."""
|
|
534
|
+
try:
|
|
535
|
+
key = self.secret_key()
|
|
536
|
+
except OSError:
|
|
537
|
+
return None
|
|
538
|
+
return hmac.new(key, data, hashlib.sha256).hexdigest()
|
|
539
|
+
|
|
540
|
+
def read_view(self, view: str) -> ViewRecord | None:
|
|
541
|
+
path = self._view_path(view)
|
|
542
|
+
try:
|
|
543
|
+
data = path.read_bytes()
|
|
544
|
+
raw = json.loads(data)
|
|
545
|
+
except (OSError, ValueError, RecursionError):
|
|
546
|
+
return None
|
|
547
|
+
try:
|
|
548
|
+
mac = path.with_suffix(".mac").read_text(encoding="ascii").strip()
|
|
549
|
+
except (OSError, ValueError):
|
|
550
|
+
mac = ""
|
|
551
|
+
expected = self._view_mac(data) if mac else None
|
|
552
|
+
verified = expected is not None and hmac.compare_digest(mac, expected)
|
|
553
|
+
if not _stamped(raw) or "data" not in raw:
|
|
554
|
+
return None
|
|
555
|
+
built_at, fingerprint, key = raw.get("built_at"), raw.get("fingerprint"), raw.get("key")
|
|
556
|
+
if not isinstance(built_at, str) or not isinstance(fingerprint, dict):
|
|
557
|
+
return None
|
|
558
|
+
if key is not None and not isinstance(key, str):
|
|
559
|
+
return None
|
|
560
|
+
try:
|
|
561
|
+
age_text(built_at)
|
|
562
|
+
except ValueError:
|
|
563
|
+
return None
|
|
564
|
+
return ViewRecord(view, built_at, fingerprint, raw["data"], key, verified)
|
|
565
|
+
|
|
566
|
+
def write_view(
|
|
567
|
+
self,
|
|
568
|
+
view: str,
|
|
569
|
+
data: Any,
|
|
570
|
+
fingerprint: dict[str, str],
|
|
571
|
+
key: str | None = None,
|
|
572
|
+
sign: bool = False,
|
|
573
|
+
built_at: str | None = None,
|
|
574
|
+
) -> ViewRecord:
|
|
575
|
+
"""Write a view; ``sign`` (a view built from checked facts, or checked against its
|
|
576
|
+
schema) adds a MAC under the per-user key so a reader can skip the check. ``built_at``
|
|
577
|
+
keeps the time of a view written again unchanged."""
|
|
578
|
+
record = ViewRecord(view, built_at or utc_now(), fingerprint, data, key)
|
|
579
|
+
path = self._view_path(view)
|
|
580
|
+
path.with_suffix(".mac").unlink(missing_ok=True) # never a MAC of the previous file
|
|
581
|
+
written = _atomic_write(
|
|
582
|
+
path,
|
|
583
|
+
{
|
|
584
|
+
"format": FORMAT,
|
|
585
|
+
"codeterrain": __version__,
|
|
586
|
+
"view": view,
|
|
587
|
+
"built_at": record.built_at,
|
|
588
|
+
"fingerprint": fingerprint,
|
|
589
|
+
"key": key,
|
|
590
|
+
"data": data,
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
mac = self._view_mac(written) if sign else None
|
|
594
|
+
if mac is None:
|
|
595
|
+
return record
|
|
596
|
+
_atomic_write_bytes(path.with_suffix(".mac"), mac.encode("ascii"))
|
|
597
|
+
return ViewRecord(view, record.built_at, fingerprint, data, key, verified=True)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def served(
|
|
601
|
+
self, record: ViewRecord, kind: str, valid: Callable[[Any], bool]
|
|
602
|
+
) -> ViewRecord | None:
|
|
603
|
+
"""``record`` when it can be answered from as it is: signed and of the format ``kind``,
|
|
604
|
+
or else checked by ``valid``, and then written again signed so the next read skips the
|
|
605
|
+
check (a view written before the static views were signed). None when it fails."""
|
|
606
|
+
if record.signed_as(kind):
|
|
607
|
+
return record
|
|
608
|
+
if not valid(record.data):
|
|
609
|
+
return None
|
|
610
|
+
return self.write_view(
|
|
611
|
+
record.view, record.data, record.fingerprint, record.key, sign=True,
|
|
612
|
+
built_at=record.built_at,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def changed_files(recorded: dict[str, str], current: dict[str, str]) -> int:
|
|
617
|
+
"""How many files differ between two fingerprints (changed, added or removed)."""
|
|
618
|
+
return sum(1 for k in recorded.keys() | current.keys() if recorded.get(k) != current.get(k))
|