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/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|