vecshift 0.1.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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,394 @@
1
+ """Compare the old and new vectors: search quality per language, and latency against accuracy.
2
+
3
+ The runner talks to the store through a small searcher interface and to the models through
4
+ the embedding contract, so it can be tested with fakes and reused for other stores. Every
5
+ search it makes is a read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal, Protocol
14
+
15
+ from vecshift.eval.metrics import ALL, K, Latency, Scores, by_slice, latency, overlap
16
+ from vecshift.eval.queries import EvalQuery
17
+
18
+ Side = Literal["old", "new"]
19
+ Hit = tuple[str, float]
20
+ """A row key and its distance from the query; smaller is closer."""
21
+ SIDES: tuple[Side, Side] = ("old", "new")
22
+ INDEX_RECALL_WARNING = 0.9
23
+
24
+
25
+ class Searcher(Protocol):
26
+ def search(
27
+ self, side: Side, vector: Sequence[float], k: int, setting: int | None = None
28
+ ) -> list[Hit]:
29
+ """Top ``k`` (key, distance) using the index, at a search setting (None: default)."""
30
+ ...
31
+
32
+ def exact(self, side: Side, vector: Sequence[float], k: int) -> list[Hit]:
33
+ """Top ``k`` (key, distance) by exact search, without the index."""
34
+ ...
35
+
36
+ def index(self, side: Side) -> tuple[str | None, int | None, list[int]]:
37
+ """(index method, current search setting, settings to sweep)."""
38
+ ...
39
+
40
+ def neighbours(self, side: Side, key: str, k: int) -> tuple[str | None, list[tuple[str, str]]]:
41
+ """A row's own group and its ``k`` nearest other rows with their groups."""
42
+ ...
43
+
44
+ def vectors(self, side: Side, keys: Sequence[str]) -> list[list[float]]:
45
+ """Stored vectors of the given rows, to use as queries when no model can embed."""
46
+ ...
47
+
48
+
49
+ class Embedder(Protocol):
50
+ async def embed(
51
+ self, texts: Sequence[str], mode: Literal["document", "query"] = ...
52
+ ) -> list[list[float]]: ...
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class SweepPoint:
57
+ setting: int | None
58
+ """``hnsw.ef_search`` or ``ivfflat.probes``; None when there's no index."""
59
+ index_recall: float | None
60
+ """Share of the exact top 10 that the index found."""
61
+ latency: Latency | None
62
+ current: bool = False
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ return {
66
+ "setting": self.setting,
67
+ "index_recall": self.index_recall,
68
+ "latency": self.latency.to_dict() if self.latency else None,
69
+ "current": self.current,
70
+ }
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class SideReport:
75
+ side: Side
76
+ column: str
77
+ model: str | None
78
+ embedded: bool = False
79
+ """Whether queries could be embedded with this side's model."""
80
+ note: str | None = None
81
+ scores: dict[str, Scores] = field(default_factory=dict)
82
+ embed_latency: Latency | None = None
83
+ method: str | None = None
84
+ sweep: list[SweepPoint] = field(default_factory=list)
85
+ same_group_at_10: float | None = None
86
+ """Share of a row's nearest rows that come from the same document (or group)."""
87
+
88
+ @property
89
+ def current(self) -> SweepPoint | None:
90
+ return next((p for p in self.sweep if p.current), self.sweep[0] if self.sweep else None)
91
+
92
+ @property
93
+ def end_to_end_p95(self) -> float | None:
94
+ point = self.current
95
+ if not point or not point.latency:
96
+ return None
97
+ embed = self.embed_latency.p95_ms if self.embed_latency else 0.0
98
+ return embed + point.latency.p95_ms
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ return {
102
+ "column": self.column,
103
+ "model": self.model,
104
+ "embedded": self.embedded,
105
+ "note": self.note,
106
+ "scores": {k: v.to_dict() for k, v in self.scores.items()},
107
+ "embed_latency": self.embed_latency.to_dict() if self.embed_latency else None,
108
+ "index_method": self.method,
109
+ "sweep": [p.to_dict() for p in self.sweep],
110
+ "same_group_at_10": self.same_group_at_10,
111
+ }
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class Gates:
116
+ tolerance: float = 0.02
117
+ """How far recall@10 may fall (absolute) before the verdict is no-go."""
118
+ min_slice: int = 30
119
+ """Slices with fewer queries are reported but don't decide the verdict."""
120
+ max_p95_ms: float | None = None
121
+ max_slowdown_pct: float | None = None
122
+
123
+
124
+ @dataclass(slots=True)
125
+ class Verdict:
126
+ status: Literal["go", "no_go", "inconclusive"]
127
+ reasons: list[str] = field(default_factory=list)
128
+ """Why it isn't go (or, for go, what was checked)."""
129
+ warnings: list[str] = field(default_factory=list)
130
+
131
+ def to_dict(self) -> dict[str, Any]:
132
+ return {"status": self.status, "reasons": self.reasons, "warnings": self.warnings}
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class EvalReport:
137
+ mode: Literal["queries", "vectors"]
138
+ """``queries``: both models embedded the queries. ``vectors``: the old model couldn't,
139
+ so the old side is judged from its stored vectors alone."""
140
+ partial: bool
141
+ query_source: str
142
+ queries: int
143
+ old: SideReport
144
+ new: SideReport
145
+ result_overlap: float | None = None
146
+ neighbour_overlap: float | None = None
147
+ notes: list[str] = field(default_factory=list)
148
+ verdict: Verdict = field(default_factory=lambda: Verdict("inconclusive"))
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ return {
152
+ "mode": self.mode,
153
+ "partial": self.partial,
154
+ "query_source": self.query_source,
155
+ "queries": self.queries,
156
+ "old": self.old.to_dict(),
157
+ "new": self.new.to_dict(),
158
+ "result_overlap_at_10": self.result_overlap,
159
+ "neighbour_overlap_at_10": self.neighbour_overlap,
160
+ "notes": self.notes,
161
+ "verdict": self.verdict.to_dict(),
162
+ }
163
+
164
+
165
+ async def _embed(
166
+ embedder: Embedder, texts: list[str], samples: int
167
+ ) -> tuple[list[list[float]], Latency | None]:
168
+ vectors = await embedder.embed(texts, "query")
169
+ timings = []
170
+ for text in texts[:samples]:
171
+ started = time.perf_counter()
172
+ await embedder.embed([text], "query")
173
+ timings.append((time.perf_counter() - started) * 1000)
174
+ return vectors, latency(timings)
175
+
176
+
177
+ def _sweep(
178
+ searcher: Searcher, side: Side, vectors: list[list[float]], warmup: int = 5
179
+ ) -> tuple[str | None, list[SweepPoint]]:
180
+ method, current, settings = searcher.index(side)
181
+ for vector in vectors[:warmup]:
182
+ searcher.search(side, vector, K)
183
+ exact = [searcher.exact(side, v, K) for v in vectors]
184
+ if method is None:
185
+ scans = []
186
+ for vector in vectors:
187
+ started = time.perf_counter()
188
+ searcher.exact(side, vector, K)
189
+ scans.append((time.perf_counter() - started) * 1000)
190
+ return None, [SweepPoint(None, 1.0, latency(scans), current=True)]
191
+ found: dict[int, list[list[Hit]]] = {s: [] for s in settings}
192
+ timings: dict[int, list[float]] = {s: [] for s in settings}
193
+ # Each query runs at every setting in turn, so caches warming up during the run
194
+ # don't flatter whichever setting happens to be measured last.
195
+ for i, vector in enumerate(vectors):
196
+ order = settings[i % len(settings) :] + settings[: i % len(settings)]
197
+ for setting in order:
198
+ started = time.perf_counter()
199
+ found[setting].append(searcher.search(side, vector, K, setting))
200
+ timings[setting].append((time.perf_counter() - started) * 1000)
201
+ points = [
202
+ SweepPoint(s, index_recall(found[s], exact), latency(timings[s]), s == current)
203
+ for s in settings
204
+ ]
205
+ return method, points
206
+
207
+
208
+ def _keys(hits: Sequence[Hit]) -> list[str]:
209
+ return [key for key, _ in hits]
210
+
211
+
212
+ def index_recall(found: Sequence[Sequence[Hit]], exact: Sequence[Sequence[Hit]]) -> float:
213
+ """Share of the exact top 10 the index found, counting ties as found.
214
+
215
+ When several rows are exactly as close as the 10th, exact search and the index may
216
+ pick different ones; any row at least as close as the exact 10th is a correct answer.
217
+ """
218
+ if not exact:
219
+ return 1.0
220
+ total = 0.0
221
+ for got, truth in zip(found, exact, strict=True):
222
+ if not truth:
223
+ total += 1.0
224
+ continue
225
+ cutoff = truth[-1][1]
226
+ slack = 1e-6 * max(1.0, abs(cutoff))
227
+ hits = sum(1 for _, distance in got[:K] if distance <= cutoff + slack)
228
+ total += min(hits, len(truth)) / len(truth)
229
+ return total / len(exact)
230
+
231
+
232
+ def _neighbours(
233
+ searcher: Searcher, side: Side, keys: Sequence[str]
234
+ ) -> tuple[list[list[str]], float | None]:
235
+ lists, shares = [], []
236
+ for key in keys:
237
+ own, found = searcher.neighbours(side, key, K)
238
+ lists.append([k for k, _ in found])
239
+ if own is not None and found:
240
+ shares.append(sum(1 for _, group in found if group == own) / len(found))
241
+ return lists, (sum(shares) / len(shares) if shares else None)
242
+
243
+
244
+ def decide(report: EvalReport, gates: Gates) -> Verdict:
245
+ """Go, no-go, or inconclusive, with the reasons."""
246
+ verdict = Verdict("go")
247
+ old, new = report.old, report.new
248
+ evidence = False
249
+ if report.mode == "queries":
250
+ for name, before in old.scores.items():
251
+ after = new.scores.get(name)
252
+ if after is None:
253
+ continue
254
+ label = "overall" if name == ALL else name
255
+ if before.queries < gates.min_slice:
256
+ verdict.warnings.append(
257
+ f"Only {before.queries} {label} queries: shown, but too few to decide on."
258
+ )
259
+ continue
260
+ evidence = True
261
+ if after.recall_at_10 < before.recall_at_10 - gates.tolerance:
262
+ verdict.reasons.append(
263
+ f"Recall@10 {label} fell from {before.recall_at_10:.3f} to "
264
+ f"{after.recall_at_10:.3f}."
265
+ )
266
+ if old.same_group_at_10 is not None and new.same_group_at_10 is not None:
267
+ evidence = True
268
+ if new.same_group_at_10 < old.same_group_at_10 - gates.tolerance:
269
+ verdict.reasons.append(
270
+ "Fewer nearest rows come from the same document: "
271
+ f"{old.same_group_at_10:.3f} before, {new.same_group_at_10:.3f} after."
272
+ )
273
+ if report.mode == "vectors":
274
+ verdict.warnings.append(
275
+ "The old model wasn't available to embed queries, so the old side was judged from "
276
+ "its stored vectors only. That's a weaker check than comparing search results."
277
+ )
278
+
279
+ old_p95, new_p95 = old.end_to_end_p95, new.end_to_end_p95
280
+ if gates.max_p95_ms is not None and new_p95 is not None and new_p95 > gates.max_p95_ms:
281
+ verdict.reasons.append(
282
+ f"New p95 latency is {new_p95:.1f} ms, over the {gates.max_p95_ms:g} ms limit."
283
+ )
284
+ if gates.max_slowdown_pct is not None and old_p95 and new_p95 is not None:
285
+ limit = old_p95 * (1 + gates.max_slowdown_pct / 100)
286
+ if new_p95 > limit:
287
+ verdict.reasons.append(
288
+ f"New p95 latency is {new_p95:.1f} ms against {old_p95:.1f} ms before, more "
289
+ f"than {gates.max_slowdown_pct:g}% slower."
290
+ )
291
+ for side in (old, new):
292
+ point = side.current
293
+ if point and point.index_recall is not None and point.index_recall < INDEX_RECALL_WARNING:
294
+ verdict.warnings.append(
295
+ f"The {side.side} index finds {point.index_recall:.0%} of the exact top 10 at "
296
+ "its current setting; a higher setting trades speed for accuracy."
297
+ )
298
+
299
+ if verdict.reasons:
300
+ verdict.status = "no_go"
301
+ elif evidence:
302
+ decided = [
303
+ "overall" if name == ALL else name
304
+ for name, scores in old.scores.items()
305
+ if scores.queries >= gates.min_slice
306
+ ]
307
+ if report.mode == "queries" and decided:
308
+ verdict.reasons.append(
309
+ f"Recall@10 held within {gates.tolerance:g} or improved: {', '.join(decided)}."
310
+ )
311
+ if old.same_group_at_10 is not None and new.same_group_at_10 is not None:
312
+ verdict.reasons.append("Nearest rows from the same document held or improved.")
313
+ else:
314
+ verdict.status = "inconclusive"
315
+ verdict.reasons.append(
316
+ "Not enough evidence to decide: use more queries, give the old model "
317
+ "(source.model), or a way to group rows by document (--group-by)."
318
+ )
319
+ return verdict
320
+
321
+
322
+ async def run(
323
+ searcher: Searcher,
324
+ embedders: dict[Side, Embedder | None],
325
+ queries: Sequence[EvalQuery],
326
+ neighbour_keys: Sequence[str],
327
+ *,
328
+ old: SideReport,
329
+ new: SideReport,
330
+ query_source: str,
331
+ partial: bool,
332
+ gates: Gates | None = None,
333
+ sweep_queries: int = 30,
334
+ latency_samples: int = 20,
335
+ notes: Sequence[str] = (),
336
+ on_step: Callable[[str], None] = lambda s: None,
337
+ ) -> EvalReport:
338
+ """Evaluate both sides. ``embedders['old']`` is None when the old model is unavailable."""
339
+ sides = {"old": old, "new": new}
340
+ report = EvalReport(
341
+ mode="queries" if embedders.get("old") is not None else "vectors",
342
+ partial=partial,
343
+ query_source=query_source,
344
+ queries=len(queries),
345
+ old=old,
346
+ new=new,
347
+ notes=list(notes),
348
+ )
349
+ texts = [q.text for q in queries]
350
+ ranked: dict[Side, list[list[str]]] = {}
351
+ for name in SIDES:
352
+ embedder = embedders.get(name)
353
+ side = sides[name]
354
+ if embedder is None or not texts:
355
+ continue
356
+ on_step(f"Embedding {len(texts)} queries with the {name} model")
357
+ vectors, side.embed_latency = await _embed(embedder, texts, latency_samples)
358
+ side.embedded = True
359
+ on_step(f"Searching the {name} column")
360
+ if partial:
361
+ ranked[name] = [_keys(searcher.exact(name, v, K)) for v in vectors]
362
+ else:
363
+ ranked[name] = [_keys(searcher.search(name, v, K)) for v in vectors]
364
+ on_step(f"Measuring {name} search latency and index accuracy")
365
+ side.method, side.sweep = _sweep(searcher, name, vectors[:sweep_queries])
366
+ side.scores = by_slice(ranked[name], queries)
367
+ if len(ranked) == 2:
368
+ report.result_overlap = overlap(ranked["old"], ranked["new"])
369
+ for name in SIDES:
370
+ side = sides[name]
371
+ if side.sweep or partial or not neighbour_keys:
372
+ continue
373
+ # Search speed doesn't depend on the model, so stored vectors stand in for queries.
374
+ on_step(f"Measuring {name} search latency with stored vectors")
375
+ stored = searcher.vectors(name, neighbour_keys[:sweep_queries])
376
+ if stored:
377
+ side.method, side.sweep = _sweep(searcher, name, stored)
378
+ report.notes.append(
379
+ f"The {name} side's latency was measured with stored row vectors as queries, "
380
+ "since its model couldn't embed queries."
381
+ )
382
+
383
+ if neighbour_keys:
384
+ on_step("Comparing each sampled row's nearest rows")
385
+ old_lists, old.same_group_at_10 = _neighbours(searcher, "old", neighbour_keys)
386
+ new_lists, new.same_group_at_10 = _neighbours(searcher, "new", neighbour_keys)
387
+ report.neighbour_overlap = overlap(old_lists, new_lists)
388
+ if partial:
389
+ report.notes.append(
390
+ "The migration isn't finished, so both sides were searched exactly, over the rows "
391
+ "that have both vectors. Latency is measured once it's complete."
392
+ )
393
+ report.verdict = decide(report, gates or Gates())
394
+ return report
vecshift/html_kit.py ADDED
@@ -0,0 +1,143 @@
1
+ """Shared pieces for vecshift's self-contained HTML pages: the page shell, logo,
2
+ favicon, styles, script, escaping, and number formatting."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import hashlib
8
+ import math
9
+ import re
10
+ from datetime import UTC, datetime
11
+ from html import escape
12
+ from importlib.resources import files
13
+ from urllib.parse import quote
14
+
15
+ # The vecshift mark: points moving from the old embedding space into the new one.
16
+ # Generated by scripts/make_logo.py; colours come from the --mark-* tokens in report.css.
17
+ DOTS = ((6.0, 25.0, 2.6), (11.2, 20.0, 3.2), (17.6, 13.8, 4.0), (25.0, 7.0, 5.0))
18
+ LOGO = (
19
+ '<svg width="20" height="20" viewBox="0 0 32 32" aria-hidden="true">'
20
+ + "".join(
21
+ f'<circle cx="{x}" cy="{y}" r="{r}" fill="var(--mark-{i})"/>'
22
+ for i, (x, y, r) in enumerate(DOTS)
23
+ )
24
+ + "</svg>"
25
+ )
26
+ FAVICON = "data:image/svg+xml," + quote(
27
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>'
28
+ ".a{fill:#a9a8a1}.b{fill:#839ab5}.c{fill:#5b8bc7}.d{fill:#2a78d6}"
29
+ "@media (prefers-color-scheme:dark){.a{fill:#8a8983}.b{fill:#738ba5}"
30
+ ".c{fill:#5a8ac5}.d{fill:#3987e5}}</style>"
31
+ + "".join(
32
+ f'<circle class="{c}" cx="{x}" cy="{y}" r="{r}"/>'
33
+ for c, (x, y, r) in zip("abcd", DOTS, strict=True)
34
+ )
35
+ + "</svg>"
36
+ )
37
+
38
+
39
+ def e(value: object) -> str:
40
+ """Escape for HTML text and attribute values."""
41
+ return escape(str(value), quote=True)
42
+
43
+
44
+ def rich(text: str) -> str:
45
+ """Escape ``text`` and show its `backticked` names as code."""
46
+ return re.sub(r"`([^`]+)`", r"<code>\1</code>", e(text))
47
+
48
+
49
+ def compact(n: int) -> str:
50
+ """1,284 · 12.9K · 4.2M"""
51
+ if abs(n) < 10_000:
52
+ return f"{n:,}"
53
+ for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
54
+ if abs(n) >= size:
55
+ value = n / size
56
+ text = f"{value:.1f}".rstrip("0").rstrip(".") if value < 100 else f"{value:.0f}"
57
+ return f"{text}{suffix}"
58
+ return f"{n:,}" # pragma: no cover
59
+
60
+
61
+ def percent(part: int, whole: int) -> str:
62
+ if whole <= 0:
63
+ return "—"
64
+ value = 100 * part / whole
65
+ if 0 < value < 1:
66
+ return "<1%"
67
+ if 99 < value < 100:
68
+ return ">99%"
69
+ return f"{value:.0f}%"
70
+
71
+
72
+ def nice_ceiling(n: int) -> int:
73
+ if n <= 1:
74
+ return 1
75
+ magnitude = 10 ** math.floor(math.log10(n))
76
+ for step in (1, 2, 2.5, 5, 10):
77
+ if n <= step * magnitude:
78
+ return int(step * magnitude) if step * magnitude >= 1 else 1
79
+ return n # pragma: no cover
80
+
81
+
82
+ def asset(name: str) -> str:
83
+ return files("vecshift").joinpath("assets", name).read_text(encoding="utf-8")
84
+
85
+
86
+ def stamp(when: datetime | None = None) -> str:
87
+ moment = (when or datetime.now(UTC)).astimezone(UTC)
88
+ return f"{moment.day} {moment:%b %Y, %H:%M} UTC"
89
+
90
+
91
+ def content_security_policy(script: str) -> str:
92
+ """Allow only this page's own script (by hash) and inline styles, and no network at all.
93
+
94
+ Even if an escaping bug ever let data inject markup, the browser would refuse to run
95
+ any other script or send anything anywhere.
96
+ """
97
+ digest = base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()
98
+ return (
99
+ "default-src 'none'; img-src data:; style-src 'unsafe-inline'; "
100
+ f"script-src 'sha256-{digest}'; base-uri 'none'; form-action 'none'"
101
+ )
102
+
103
+
104
+ def page(
105
+ *,
106
+ title: str,
107
+ product: str,
108
+ heading: str,
109
+ meta: list[str],
110
+ body: str,
111
+ footer: str,
112
+ generator: str,
113
+ extra_css: str = "",
114
+ ) -> str:
115
+ """A complete, self-contained HTML document in vecshift's report style."""
116
+ meta_html = "".join(f"<span>{e(m)}</span>" for m in meta)
117
+ script = asset("report.js")
118
+ return f"""<!doctype html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8">
122
+ <meta http-equiv="Content-Security-Policy" content="{content_security_policy(script)}">
123
+ <meta name="referrer" content="no-referrer">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1">
125
+ <meta name="generator" content="{e(generator)}">
126
+ <link rel="icon" href="{FAVICON}">
127
+ <title>{e(title)}</title>
128
+ <style>{asset("report.css")}{extra_css}</style>
129
+ </head>
130
+ <body>
131
+ <main class="wrap">
132
+ <header>
133
+ <div class="brand">{LOGO}{e(product)}</div>
134
+ <h1>{e(heading)}</h1>
135
+ <p class="meta">{meta_html}</p>
136
+ </header>
137
+ {body}
138
+ <footer>{footer}</footer>
139
+ </main>
140
+ <script>{script}</script>
141
+ </body>
142
+ </html>
143
+ """
@@ -0,0 +1,5 @@
1
+ """Job specs: the declarative description of a migration, shared by every interface."""
2
+
3
+ from vecshift.jobs.spec import JobError, JobSpec, load_job, template
4
+
5
+ __all__ = ["JobError", "JobSpec", "load_job", "template"]