diffcone 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.
diffcone/evidence.py ADDED
@@ -0,0 +1,738 @@
1
+ """Execution evidence: what each test executed at one commit, as planning data.
2
+
3
+ The recorder (``diffcone/collect.py``, a pytest plugin) writes raw per-process
4
+ records of code objects and repository paths. :func:`fold` maps those code
5
+ objects to symbols of diffcone's own index of the same commit and checks the
6
+ collection is usable; :func:`write_store` and :func:`load_store` keep the
7
+ result in ``.diffcone/evidence/<commit>-<environment>.sqlite``.
8
+
9
+ Nothing here runs project code, and the planner treats an :class:`Evidence`
10
+ the way it treats a manifest: data a runner integration produced. See
11
+ internal/evidence_design.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import array
17
+ import glob
18
+ import json
19
+ import os
20
+ import sqlite3
21
+ import struct
22
+ import subprocess
23
+ import tempfile
24
+ import time
25
+ import zlib
26
+ from bisect import bisect_right
27
+ from collections import defaultdict
28
+ from collections.abc import Iterable
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+
32
+ from diffcone.cache import make_own_dir
33
+ from diffcone.cython import symbol_id
34
+ from diffcone.model import MODULE, SourceIndex
35
+
36
+ STORE_FORMAT = 1
37
+ FLAG_SUBPROCESS = 1 # the test started a subprocess, whose execution is not seen
38
+ FLAG_UNSTABLE = 2 # the test's record differed between two collections
39
+ EVIDENCE_DIR = Path(".diffcone") / "evidence"
40
+ # An import run by a module outside the source roots: nothing static can say
41
+ # who observes what it built.
42
+ UNINDEXED_MODULE = "path:"
43
+
44
+
45
+ class EvidenceError(Exception):
46
+ """A collection that cannot be trusted, or a store that cannot be read."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class TestRecord:
51
+ __test__ = False # not a pytest class
52
+
53
+ symbols: frozenset[int]
54
+ paths: frozenset[int] # opened or stat'ed (indices into Evidence.paths)
55
+ dirs: frozenset[int] = frozenset() # listed
56
+ flags: int = 0
57
+
58
+
59
+ @dataclass
60
+ class Evidence:
61
+ commit: str
62
+ source_roots: list[str]
63
+ environment: dict
64
+ environment_hash: str
65
+ command: str
66
+ created: float
67
+ symbols: list[str]
68
+ paths: list[str]
69
+ tests: dict[str, TestRecord]
70
+ # Symbols executed outside every test window (imports, collection, hooks).
71
+ import_phase: frozenset[str] = frozenset()
72
+ # Symbol -> the modules whose import ran it (``path:<file>`` for a module
73
+ # outside the index).
74
+ import_by: dict[str, frozenset[str]] = field(default_factory=dict)
75
+ # Symbols executed outside every test window while no import was running
76
+ # (session hooks, collection, pytest_generate_tests).
77
+ hook_phase: frozenset[str] = frozenset()
78
+ # Paths project code opened or stat'ed outside every test window
79
+ # -> the modules being imported then ("" for none: a hook or collection;
80
+ # ``path:<file>`` for a module outside the index).
81
+ import_paths: dict[str, frozenset[str]] = field(default_factory=dict)
82
+ import_dirs: dict[str, frozenset[str]] = field(default_factory=dict) # listed, likewise
83
+ # Something outside every test window started a subprocess.
84
+ import_subprocess: bool = False
85
+ reverse_checked: bool = False
86
+ # The store this one was advanced from (``run --collect``), and the commit
87
+ # of the last full collection in its line (its own commit when it is one).
88
+ advanced_from: str | None = None
89
+ full_commit: str | None = None
90
+ # Every test pytest collected at the commit (parameters folded), before
91
+ # any deselection; None when not recorded (an older store, or advanced
92
+ # by a run that ran nothing).
93
+ collected: frozenset[str] | None = None
94
+ location: Path | None = None
95
+
96
+ def executed(self, record: TestRecord) -> set[str]:
97
+ return {self.symbols[i] for i in record.symbols}
98
+
99
+ def touched(self, record: TestRecord) -> set[str]:
100
+ """Paths the test opened or stat'ed."""
101
+ return {self.paths[i] for i in record.paths}
102
+
103
+ def listed(self, record: TestRecord) -> set[str]:
104
+ """Directories the test listed."""
105
+ return {self.paths[i] for i in record.dirs}
106
+
107
+
108
+ # --------------------------------------------------------------------------- folding
109
+
110
+
111
+ class _Owners:
112
+ """Code object (file, first line, qualname) -> the innermost symbol whose
113
+ definition contains that line; a module's own code maps to the module.
114
+ Nested functions, lambdas and comprehensions land in their enclosing
115
+ symbol, as coverage validation maps lines."""
116
+
117
+ def __init__(self, index: SourceIndex) -> None:
118
+ self.cython = index.cython
119
+ self.module_of_path: dict[str, str] = {}
120
+ spans: dict[str, list[tuple[int, int, str]]] = defaultdict(list)
121
+ for symbol in index.symbols.values():
122
+ if symbol.kind == MODULE:
123
+ self.module_of_path[symbol.path] = symbol.id
124
+ for start, end in symbol.line_ranges:
125
+ spans[symbol.path].append((start, end, symbol.id))
126
+ self.spans = {path: sorted(s) for path, s in spans.items()}
127
+ self.starts = {path: [s[0] for s in spans_] for path, spans_ in self.spans.items()}
128
+ self.memo: dict[tuple[str, int, str], str | None] = {}
129
+
130
+ def __call__(self, path: str, line: int, qualname: str) -> str | None:
131
+ key = (path, line, qualname)
132
+ if key in self.memo:
133
+ return self.memo[key]
134
+ cython = self.cython.get(path)
135
+ if cython is not None:
136
+ # A profiled Cython build: the function whose span holds the code
137
+ # object's first line (its first decorator); module-level code
138
+ # maps to no symbol and stays a path the test touched.
139
+ function = cython.function_at(line)
140
+ self.memo[key] = symbol_id(path, function.name) if function else None
141
+ return self.memo[key]
142
+ found = self.module_of_path.get(path)
143
+ if qualname != "<module>" and path in self.spans:
144
+ best: tuple[int, str] | None = None
145
+ spans = self.spans[path]
146
+ for i in range(bisect_right(self.starts[path], line) - 1, -1, -1):
147
+ start, end, sid = spans[i]
148
+ if end >= line and (best is None or end - start < best[0]):
149
+ best = (end - start, sid)
150
+ if best is not None:
151
+ found = best[1]
152
+ self.memo[key] = found
153
+ return found
154
+
155
+
156
+ @dataclass
157
+ class _Raw:
158
+ tests: dict[str, tuple[set[str], set[str], set[str], int]]
159
+ import_phase: set[str]
160
+ hook_phase: set[str]
161
+ import_by: dict[str, set[str]]
162
+ import_paths: dict[str, set[str]]
163
+ import_dirs: dict[str, set[str]]
164
+ import_subprocess: bool
165
+ environment: dict
166
+ environment_hash: str
167
+ collected: set[str] | None = None
168
+ # A shared fixture (by key) -> what its setup ran in any process, and
169
+ # the shared fixtures each test used.
170
+ fixtures: dict[str, tuple[set[str], set[str], set[str], int]] = field(default_factory=dict)
171
+ test_fixtures: dict[str, set[str]] = field(default_factory=dict)
172
+
173
+
174
+ def _read_raw(directory: Path, owners: _Owners, project_modules: set[str]) -> _Raw:
175
+ processes = sorted(glob.glob(str(directory / "process-*.json")))
176
+ if not processes:
177
+ raise EvidenceError(
178
+ "the suite wrote no evidence: was the plugin loaded (-p diffcone_collect), "
179
+ "and did pytest run at all?"
180
+ )
181
+ raw = _Raw(
182
+ {}, set(), set(), defaultdict(set), defaultdict(set), defaultdict(set), False, {}, ""
183
+ )
184
+ environments: list[dict] = []
185
+ seen_pids = set()
186
+ for process_file in processes:
187
+ with open(process_file, encoding="utf-8") as f:
188
+ data = json.load(f)
189
+ pid = str(data["pid"])
190
+ seen_pids.add(pid)
191
+ if data["errors"]:
192
+ raise EvidenceError(
193
+ "the recorder failed inside the suite, so the record may be incomplete: "
194
+ + "; ".join(data["errors"][:5])
195
+ )
196
+ shadowed = [
197
+ (name, file) for name, file in data["outside_modules"] if name in project_modules
198
+ ]
199
+ if shadowed:
200
+ name, file = shadowed[0]
201
+ raise EvidenceError(
202
+ f"the suite imported {name} from {file}, outside the checkout: it ran an "
203
+ f"installed copy of the project, not the code at this commit "
204
+ f"({len(shadowed)} module(s))"
205
+ )
206
+ environments.append(data["environment"])
207
+ raw.environment_hash = data["environment_hash"]
208
+ if data.get("collected") is not None:
209
+ raw.collected = (raw.collected or set()) | set(data["collected"])
210
+ symbol_of: list[str | None] = []
211
+ file_of: list[str | None] = []
212
+ for path, line, qualname in data["table"]:
213
+ symbol = owners(path, line, qualname)
214
+ symbol_of.append(symbol)
215
+ # Code from a repository file the index does not read (outside the
216
+ # source roots): recorded as a path the test touched.
217
+ file_of.append(None if symbol is not None else path)
218
+ for i in data["import_phase"]:
219
+ if symbol_of[i] is not None:
220
+ raw.import_phase.add(symbol_of[i])
221
+ elif file_of[i] is not None:
222
+ raw.import_paths[file_of[i]].add("")
223
+ for i in data["hook_phase"]:
224
+ if symbol_of[i] is not None:
225
+ raw.hook_phase.add(symbol_of[i])
226
+ for key, seen in (("import_paths", raw.import_paths), ("import_dirs", raw.import_dirs)):
227
+ for path, modules in data[key].items():
228
+ for module_path in modules:
229
+ module = owners.module_of_path.get(module_path) if module_path else ""
230
+ seen[path].add(module if module is not None else UNINDEXED_MODULE + module_path)
231
+ raw.import_subprocess |= bool(data["import_flags"] & FLAG_SUBPROCESS)
232
+ for key, modules in data["import_by"].items():
233
+ symbol = symbol_of[int(key)]
234
+ if symbol is None:
235
+ continue
236
+ for module_path in modules:
237
+ module = owners.module_of_path.get(module_path)
238
+ raw.import_by[symbol].add(module or UNINDEXED_MODULE + module_path)
239
+ for key, w in data.get("fixtures", {}).items():
240
+ symbols, paths, dirs, flags = raw.fixtures.setdefault(key, (set(), set(), set(), 0))
241
+ for c in w["codes"]:
242
+ if symbol_of[c] is not None:
243
+ symbols.add(symbol_of[c])
244
+ elif file_of[c] is not None:
245
+ paths.add(file_of[c])
246
+ paths.update(w["paths"])
247
+ dirs.update(w["dirs"])
248
+ raw.fixtures[key] = (symbols, paths, dirs, flags | w["flags"])
249
+ tests_file = directory / f"tests-{pid}.bin"
250
+ if data["wrote_tests"]:
251
+ if not tests_file.exists():
252
+ raise EvidenceError(f"process {pid} recorded no test file")
253
+ blob = tests_file.read_bytes()
254
+ i = 0
255
+ while i < len(blob):
256
+ try:
257
+ n, m = struct.unpack_from("<II", blob, i)
258
+ i += 8
259
+ name = blob[i : i + n].decode()
260
+ i += n
261
+ record = json.loads(zlib.decompress(blob[i : i + m]))
262
+ except (struct.error, zlib.error, UnicodeDecodeError, ValueError) as exc:
263
+ raise EvidenceError(
264
+ f"process {pid} wrote a truncated or corrupt test file ({exc})"
265
+ ) from exc
266
+ i += m
267
+ symbols, paths, dirs, flags = raw.tests.setdefault(name, (set(), set(), set(), 0))
268
+ for c in record["codes"]:
269
+ if symbol_of[c] is not None:
270
+ symbols.add(symbol_of[c])
271
+ elif file_of[c] is not None:
272
+ paths.add(file_of[c])
273
+ paths.update(record["paths"])
274
+ dirs.update(record["dirs"])
275
+ raw.tests[name] = (symbols, paths, dirs, flags | record["flags"])
276
+ raw.test_fixtures.setdefault(name, set()).update(record.get("fixtures", ()))
277
+ orphans = [
278
+ p
279
+ for p in glob.glob(str(directory / "tests-*.bin"))
280
+ if Path(p).stem.split("-", 1)[1] not in seen_pids
281
+ ]
282
+ if orphans:
283
+ raise EvidenceError(
284
+ f"{len(orphans)} test process(es) ended without finishing their record "
285
+ "(a crashed worker?); the evidence would be incomplete"
286
+ )
287
+ if any(env != environments[0] for env in environments):
288
+ raise EvidenceError("the test processes ran in different environments")
289
+ raw.environment = environments[0]
290
+ # A shared fixture set up in several processes (xdist workers) may have
291
+ # run its real work in one only (a file lock, a cache file the others
292
+ # read): every test using it is credited with what it ran anywhere.
293
+ for name, keys in raw.test_fixtures.items():
294
+ symbols, paths, dirs, flags = raw.tests[name]
295
+ for key in keys:
296
+ f_symbols, f_paths, f_dirs, f_flags = raw.fixtures.get(key, (set(), set(), set(), 0))
297
+ symbols |= f_symbols
298
+ paths |= f_paths
299
+ dirs |= f_dirs
300
+ flags |= f_flags
301
+ raw.tests[name] = (symbols, paths, dirs, flags)
302
+ return raw
303
+
304
+
305
+ def fold(
306
+ directories: list[Path],
307
+ index: SourceIndex,
308
+ *,
309
+ commit: str,
310
+ source_roots: list[str],
311
+ command: str,
312
+ project_modules: set[str],
313
+ ) -> Evidence:
314
+ """Turn one or two raw collections (the second in reverse order) into
315
+ evidence. A test whose record differs between the two is unstable: its
316
+ path depends on what ran before it, so it is always selected.
317
+ ``project_modules`` are the importable names of the indexed modules: one
318
+ of them loaded from outside the checkout means an installed copy ran."""
319
+ owners = _Owners(index)
320
+ raws = [_read_raw(d, owners, project_modules) for d in directories]
321
+ if len(raws) > 1 and raws[1].environment != raws[0].environment:
322
+ raise EvidenceError("the two collections ran in different environments")
323
+ unstable: set[str] = set()
324
+ tests: dict[str, tuple[set[str], set[str], set[str], int]] = {}
325
+ # Stability is judged on symbols and tracked data files. pytest stats
326
+ # package directories and ``__init__`` files lazily, during whichever
327
+ # test runs first, and that is not the test's own behaviour.
328
+ data_files = set(index.other_files)
329
+ for raw in raws:
330
+ for name, (symbols, paths, dirs, flags) in raw.tests.items():
331
+ if name in tests:
332
+ before = tests[name]
333
+ if before[0] != symbols or (before[1] ^ paths) & data_files:
334
+ unstable.add(name)
335
+ tests[name] = (
336
+ before[0] | symbols,
337
+ before[1] | paths,
338
+ before[2] | dirs,
339
+ before[3] | flags,
340
+ )
341
+ else:
342
+ if len(raws) > 1 and raw is not raws[0]:
343
+ unstable.add(name) # ran in one order only
344
+ tests[name] = (set(symbols), set(paths), set(dirs), flags)
345
+ if len(raws) > 1:
346
+ unstable |= set(raws[0].tests) - set(raws[1].tests)
347
+ symbol_table = sorted({s for symbols, _, _, _ in tests.values() for s in symbols})
348
+ path_table = sorted({p for _, paths, dirs, _ in tests.values() for p in paths | dirs})
349
+ sid = {s: i for i, s in enumerate(symbol_table)}
350
+ pid = {p: i for i, p in enumerate(path_table)}
351
+ shared: dict[frozenset[int], frozenset[int]] = {}
352
+
353
+ def intern(ids: frozenset[int]) -> frozenset[int]:
354
+ return shared.setdefault(ids, ids)
355
+
356
+ records = {
357
+ name: TestRecord(
358
+ intern(frozenset(sid[s] for s in symbols)),
359
+ intern(frozenset(pid[p] for p in paths)),
360
+ intern(frozenset(pid[p] for p in dirs)),
361
+ flags | (FLAG_UNSTABLE if name in unstable else 0),
362
+ )
363
+ for name, (symbols, paths, dirs, flags) in sorted(tests.items())
364
+ }
365
+ return Evidence(
366
+ commit=commit,
367
+ source_roots=list(source_roots),
368
+ environment=raws[0].environment,
369
+ environment_hash=raws[0].environment_hash,
370
+ command=command,
371
+ created=time.time(),
372
+ symbols=symbol_table,
373
+ paths=path_table,
374
+ tests=records,
375
+ import_phase=frozenset().union(*(r.import_phase for r in raws)),
376
+ hook_phase=frozenset().union(*(r.hook_phase for r in raws)),
377
+ import_by=_merged(r.import_by for r in raws),
378
+ import_paths=_merged(r.import_paths for r in raws),
379
+ import_dirs=_merged(r.import_dirs for r in raws),
380
+ import_subprocess=any(r.import_subprocess for r in raws),
381
+ reverse_checked=len(raws) > 1,
382
+ collected=(
383
+ frozenset().union(*(r.collected for r in raws if r.collected is not None))
384
+ if any(r.collected is not None for r in raws)
385
+ else None
386
+ ),
387
+ )
388
+
389
+
390
+ def advance(
391
+ previous: Evidence,
392
+ fresh: Evidence | None,
393
+ rerun: set[str],
394
+ commit: str,
395
+ alive: set[str] | None = None,
396
+ ) -> Evidence:
397
+ """The evidence for ``commit`` after a run of the tests ``rerun`` there,
398
+ from ``previous`` (evidence at an ancestor C whose plan selected
399
+ ``rerun``) and ``fresh`` (what that run recorded; None when nothing ran).
400
+
401
+ A test the plan did not select runs identically at C and at ``commit``,
402
+ so its record carries over. A rerun test takes its fresh record, keeping
403
+ an ``unstable`` flag a partial run cannot re-check; one that recorded
404
+ nothing is dropped and has no evidence from here on. Process-wide data
405
+ is the union of both: the run imported only what its tests needed, and a
406
+ stale entry only escalates more (roadmap item 6).
407
+
408
+ With ``alive`` (the tests that are targets at ``commit``) a carried record
409
+ of any other test, one deleted since C, is dropped. Dropping is always
410
+ safe: a test without a record is selected."""
411
+ if fresh is not None:
412
+ if fresh.environment_hash != previous.environment_hash:
413
+ raise EvidenceError("the run's environment differs from the recording's")
414
+ if sorted(fresh.source_roots) != sorted(previous.source_roots):
415
+ raise EvidenceError("the run's source roots differ from the recording's")
416
+ sources = [previous] + ([fresh] if fresh is not None else [])
417
+ tests: dict[str, tuple[Evidence, TestRecord, int]] = {
418
+ name: (previous, record, 0)
419
+ for name, record in previous.tests.items()
420
+ if name not in rerun and (alive is None or name in alive)
421
+ }
422
+ if fresh is not None:
423
+ for name, record in fresh.tests.items():
424
+ old = previous.tests.get(name)
425
+ tests[name] = (fresh, record, old.flags & FLAG_UNSTABLE if old else 0)
426
+ symbol_table = sorted({ev.symbols[i] for ev, r, _ in tests.values() for i in r.symbols})
427
+ path_table = sorted({ev.paths[i] for ev, r, _ in tests.values() for i in r.paths | r.dirs})
428
+ sid = {s: i for i, s in enumerate(symbol_table)}
429
+ pid = {p: i for i, p in enumerate(path_table)}
430
+ # Keyed by the table as well as the set: fold interns equal sets across
431
+ # symbols and paths, and records share sets, which the store keeps once.
432
+ shared: dict[tuple[int, int], frozenset[int]] = {}
433
+
434
+ def remap(ids: frozenset[int], table: list[str], new: dict[str, int]) -> frozenset[int]:
435
+ key = (id(table), id(ids))
436
+ if key not in shared:
437
+ shared[key] = frozenset(new[table[i]] for i in ids)
438
+ return shared[key]
439
+
440
+ records = {
441
+ name: TestRecord(
442
+ remap(r.symbols, ev.symbols, sid),
443
+ remap(r.paths, ev.paths, pid),
444
+ remap(r.dirs, ev.paths, pid),
445
+ r.flags | carried,
446
+ )
447
+ for name, (ev, r, carried) in sorted(tests.items())
448
+ }
449
+ return Evidence(
450
+ commit=commit,
451
+ source_roots=list(previous.source_roots),
452
+ environment=previous.environment,
453
+ environment_hash=previous.environment_hash,
454
+ command=previous.command,
455
+ created=time.time(),
456
+ symbols=symbol_table,
457
+ paths=path_table,
458
+ tests=records,
459
+ import_phase=frozenset().union(*(e.import_phase for e in sources)),
460
+ hook_phase=frozenset().union(*(e.hook_phase for e in sources)),
461
+ import_by=_merged(e.import_by for e in sources),
462
+ import_paths=_merged(e.import_paths for e in sources),
463
+ import_dirs=_merged(e.import_dirs for e in sources),
464
+ import_subprocess=any(e.import_subprocess for e in sources),
465
+ reverse_checked=previous.reverse_checked,
466
+ advanced_from=previous.commit,
467
+ full_commit=previous.full_commit or previous.commit,
468
+ # The run collected the whole suite at head before selecting from
469
+ # it; with nothing run, head's collection is unknown.
470
+ collected=fresh.collected if fresh is not None else None,
471
+ )
472
+
473
+
474
+ def _merged(maps: Iterable[dict[str, set[str]]]) -> dict[str, frozenset[str]]:
475
+ out: dict[str, set[str]] = defaultdict(set)
476
+ for mapping in maps:
477
+ for key, values in mapping.items():
478
+ out[key] |= values
479
+ return {k: frozenset(v) for k, v in sorted(out.items())}
480
+
481
+
482
+ # --------------------------------------------------------------------------- store
483
+
484
+
485
+ def _pack(ids) -> bytes:
486
+ return zlib.compress(array.array("I", sorted(ids)).tobytes(), 6)
487
+
488
+
489
+ def _unpack(blob: bytes) -> frozenset[int]:
490
+ values = array.array("I")
491
+ values.frombytes(zlib.decompress(blob))
492
+ return frozenset(values)
493
+
494
+
495
+ def store_name(evidence: Evidence) -> str:
496
+ return f"{evidence.commit}-{evidence.environment_hash}.sqlite"
497
+
498
+
499
+ def write_store(evidence: Evidence, directory: Path) -> Path:
500
+ """Write atomically: a reader never sees half a store."""
501
+ make_own_dir(directory)
502
+ target = directory / store_name(evidence)
503
+ fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
504
+ os.close(fd)
505
+ try:
506
+ db = sqlite3.connect(tmp)
507
+ with db:
508
+ db.executescript(
509
+ """
510
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
511
+ CREATE TABLE symbols (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
512
+ CREATE TABLE paths (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
513
+ CREATE TABLE sets (id INTEGER PRIMARY KEY, data BLOB NOT NULL);
514
+ CREATE TABLE tests (
515
+ target TEXT PRIMARY KEY, symbols INTEGER NOT NULL,
516
+ paths INTEGER NOT NULL, dirs INTEGER NOT NULL, flags INTEGER NOT NULL);
517
+ CREATE TABLE import_by (symbol TEXT NOT NULL, module TEXT NOT NULL);
518
+ """
519
+ )
520
+ meta = {
521
+ "format": STORE_FORMAT,
522
+ "commit": evidence.commit,
523
+ "source_roots": evidence.source_roots,
524
+ "environment": evidence.environment,
525
+ "environment_hash": evidence.environment_hash,
526
+ "command": evidence.command,
527
+ "created": evidence.created,
528
+ "import_phase": sorted(evidence.import_phase),
529
+ "hook_phase": sorted(evidence.hook_phase),
530
+ "import_paths": {p: sorted(m) for p, m in evidence.import_paths.items()},
531
+ "import_dirs": {p: sorted(m) for p, m in evidence.import_dirs.items()},
532
+ "import_subprocess": evidence.import_subprocess,
533
+ "reverse_checked": evidence.reverse_checked,
534
+ "advanced_from": evidence.advanced_from,
535
+ "full_commit": evidence.full_commit or evidence.commit,
536
+ "collected": sorted(evidence.collected) if evidence.collected is not None else None,
537
+ }
538
+ db.executemany(
539
+ "INSERT INTO meta VALUES (?, ?)", [(k, json.dumps(v)) for k, v in meta.items()]
540
+ )
541
+ db.executemany("INSERT INTO symbols VALUES (?, ?)", enumerate(evidence.symbols))
542
+ db.executemany("INSERT INTO paths VALUES (?, ?)", enumerate(evidence.paths))
543
+ set_ids: dict[int, int] = {}
544
+ rows = []
545
+ for name, record in evidence.tests.items():
546
+ ids = []
547
+ for s in (record.symbols, record.paths, record.dirs):
548
+ if id(s) not in set_ids:
549
+ set_ids[id(s)] = len(set_ids)
550
+ db.execute("INSERT INTO sets VALUES (?, ?)", (set_ids[id(s)], _pack(s)))
551
+ ids.append(set_ids[id(s)])
552
+ rows.append((name, *ids, record.flags))
553
+ db.executemany("INSERT INTO tests VALUES (?, ?, ?, ?, ?)", rows)
554
+ db.executemany(
555
+ "INSERT INTO import_by VALUES (?, ?)",
556
+ [(s, m) for s, modules in evidence.import_by.items() for m in sorted(modules)],
557
+ )
558
+ db.close()
559
+ os.replace(tmp, target)
560
+ finally:
561
+ if os.path.exists(tmp):
562
+ os.unlink(tmp)
563
+ evidence.location = target
564
+ return target
565
+
566
+
567
+ def _meta(db: sqlite3.Connection) -> dict:
568
+ return {k: json.loads(v) for k, v in db.execute("SELECT key, value FROM meta")}
569
+
570
+
571
+ def load_store(path: Path) -> Evidence:
572
+ try:
573
+ db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
574
+ except sqlite3.Error as exc:
575
+ raise EvidenceError(f"cannot open evidence recording {path}: {exc}") from exc
576
+ try:
577
+ meta = _meta(db)
578
+ if meta.get("format") != STORE_FORMAT:
579
+ raise EvidenceError(
580
+ f"evidence recording {path} has format {meta.get('format')}, this diffcone "
581
+ f"reads {STORE_FORMAT}; collect it again"
582
+ )
583
+ symbols = [n for _, n in db.execute("SELECT id, name FROM symbols ORDER BY id")]
584
+ paths = [p for _, p in db.execute("SELECT id, path FROM paths ORDER BY id")]
585
+ sets = {i: _unpack(blob) for i, blob in db.execute("SELECT id, data FROM sets")}
586
+ tests = {
587
+ name: TestRecord(sets[s], sets[p], sets[d], flags)
588
+ for name, s, p, d, flags in db.execute(
589
+ "SELECT target, symbols, paths, dirs, flags FROM tests"
590
+ )
591
+ }
592
+ import_by: dict[str, set[str]] = defaultdict(set)
593
+ for symbol, module in db.execute("SELECT symbol, module FROM import_by"):
594
+ import_by[symbol].add(module)
595
+ evidence = Evidence(
596
+ commit=meta["commit"],
597
+ source_roots=meta["source_roots"],
598
+ environment=meta["environment"],
599
+ environment_hash=meta["environment_hash"],
600
+ command=meta["command"],
601
+ created=meta["created"],
602
+ symbols=symbols,
603
+ paths=paths,
604
+ tests=tests,
605
+ import_phase=frozenset(meta["import_phase"]),
606
+ hook_phase=frozenset(meta["hook_phase"]),
607
+ import_by={s: frozenset(m) for s, m in import_by.items()},
608
+ import_paths={p: frozenset(m) for p, m in meta["import_paths"].items()},
609
+ import_dirs={p: frozenset(m) for p, m in meta["import_dirs"].items()},
610
+ import_subprocess=meta["import_subprocess"],
611
+ reverse_checked=meta["reverse_checked"],
612
+ advanced_from=meta.get("advanced_from"),
613
+ full_commit=meta.get("full_commit") or meta["commit"],
614
+ collected=frozenset(meta["collected"]) if meta.get("collected") is not None else None,
615
+ location=path,
616
+ )
617
+ except (sqlite3.Error, KeyError, TypeError, ValueError, AttributeError, zlib.error) as exc:
618
+ raise EvidenceError(f"cannot read evidence recording {path}: {exc}") from exc
619
+ finally:
620
+ db.close()
621
+ return evidence
622
+
623
+
624
+ @dataclass(frozen=True)
625
+ class StoreInfo:
626
+ path: Path
627
+ commit: str
628
+ environment_hash: str
629
+ source_roots: tuple[str, ...]
630
+ created: float
631
+ tests: int
632
+ python: str
633
+ advanced_from: str | None = None
634
+ full_commit: str | None = None
635
+
636
+
637
+ def find_store(repo: Path, spec: str, source_roots: list[str], reference: str) -> Path:
638
+ """The store ``spec`` names: a path, or ``auto`` for the store (with these
639
+ source roots) whose commit is the nearest ancestor of ``reference``, the
640
+ commit being planned towards; the newest one on a tie. Evidence from any
641
+ ancestor is sound (changes are planned from its commit), and a nearer one
642
+ has fewer changes to plan."""
643
+ if spec != "auto":
644
+ path = Path(spec)
645
+ if not path.exists():
646
+ raise EvidenceError(f"no evidence recording at {spec}")
647
+ return path
648
+ best: tuple[int, float, Path] | None = None
649
+ missing: list[str] = []
650
+ for store in list_stores(repo):
651
+ if sorted(store.source_roots) != sorted(source_roots):
652
+ continue
653
+ if not has_commit(repo, store.commit):
654
+ missing.append(store.commit)
655
+ continue
656
+ if subprocess.run(
657
+ ["git", "merge-base", "--is-ancestor", store.commit, reference],
658
+ cwd=repo,
659
+ capture_output=True,
660
+ ).returncode:
661
+ continue
662
+ count = subprocess.run(
663
+ ["git", "rev-list", "--count", f"{store.commit}..{reference}"],
664
+ cwd=repo,
665
+ capture_output=True,
666
+ text=True,
667
+ )
668
+ distance = int(count.stdout.strip() or 0)
669
+ key = (distance, -store.created, store.path)
670
+ if best is None or key < best:
671
+ best = key
672
+ if best is None:
673
+ hints = []
674
+ if missing:
675
+ hints.append(
676
+ f"the recording(s) at {', '.join(c[:12] for c in missing)} are for commits this "
677
+ f"checkout does not have; fetch them (git fetch --depth=1 origin {missing[0]})"
678
+ )
679
+ shallow = subprocess.run(
680
+ ["git", "rev-parse", "--is-shallow-repository"],
681
+ cwd=repo,
682
+ capture_output=True,
683
+ text=True,
684
+ )
685
+ if shallow.stdout.strip() == "true":
686
+ hints.append(
687
+ "this clone is shallow, so whether a recording's commit is an ancestor cannot "
688
+ "be told: fetch the history, or pass the recording's path to --evidence"
689
+ )
690
+ raise EvidenceError(
691
+ f"no evidence recording with source roots {source_roots} at an ancestor of "
692
+ f"{reference[:12]}; record one with `diffcone collect`"
693
+ + "".join(f"; {h}" for h in hints)
694
+ )
695
+ return best[2]
696
+
697
+
698
+ def has_commit(repo: Path, commit: str) -> bool:
699
+ """Whether the repository holds ``commit``'s object."""
700
+ return (
701
+ subprocess.run(
702
+ ["git", "cat-file", "-e", f"{commit}^{{commit}}"], cwd=repo, capture_output=True
703
+ ).returncode
704
+ == 0
705
+ )
706
+
707
+
708
+ def list_stores(repo: Path) -> list[StoreInfo]:
709
+ """Every readable store of the repository, newest first."""
710
+ found = []
711
+ for path in sorted((repo / EVIDENCE_DIR).glob("*.sqlite")):
712
+ try:
713
+ db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
714
+ try:
715
+ meta = _meta(db)
716
+ (count,) = db.execute("SELECT COUNT(*) FROM tests").fetchone()
717
+ finally:
718
+ db.close()
719
+ except (sqlite3.Error, ValueError):
720
+ continue
721
+ if meta.get("format") != STORE_FORMAT:
722
+ continue
723
+ try:
724
+ info = StoreInfo(
725
+ path,
726
+ meta["commit"],
727
+ meta["environment_hash"],
728
+ tuple(meta["source_roots"]),
729
+ meta["created"],
730
+ count,
731
+ meta["environment"]["python"].split()[0],
732
+ meta.get("advanced_from"),
733
+ meta.get("full_commit") or meta["commit"],
734
+ )
735
+ except (KeyError, TypeError, AttributeError):
736
+ continue # a malformed store is skipped, not fatal to every plan
737
+ found.append(info)
738
+ return sorted(found, key=lambda s: -s.created)