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/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))