crapkit 0.2.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.
- crapkit/__init__.py +2 -0
- crapkit/__main__.py +5 -0
- crapkit/_pygdefer.py +86 -0
- crapkit/analyze.py +375 -0
- crapkit/cache.py +58 -0
- crapkit/churn.py +113 -0
- crapkit/churn_cache.py +108 -0
- crapkit/churn_log.py +286 -0
- crapkit/cli/__init__.py +316 -0
- crapkit/cli/_shared.py +130 -0
- crapkit/cli/admin.py +650 -0
- crapkit/cli/analyses.py +144 -0
- crapkit/cli/parser.py +384 -0
- crapkit/cli/queue.py +926 -0
- crapkit/cli/ratchet_cmds.py +172 -0
- crapkit/cli/reports.py +459 -0
- crapkit/cli/scoring.py +500 -0
- crapkit/cli/verifying.py +580 -0
- crapkit/config.py +289 -0
- crapkit/coupling.py +89 -0
- crapkit/coverage_istanbul.py +225 -0
- crapkit/coverage_py.py +87 -0
- crapkit/covstream.py +320 -0
- crapkit/diffparse.py +98 -0
- crapkit/digest.py +191 -0
- crapkit/discover.py +365 -0
- crapkit/doctor.py +308 -0
- crapkit/dup.py +179 -0
- crapkit/errors.py +18 -0
- crapkit/gitio.py +504 -0
- crapkit/hook.py +167 -0
- crapkit/junitparse.py +87 -0
- crapkit/lanes.py +373 -0
- crapkit/lizardcognitive.py +238 -0
- crapkit/mcp_server.py +167 -0
- crapkit/merge.py +77 -0
- crapkit/mutate.py +96 -0
- crapkit/mutate_pool.py +152 -0
- crapkit/override.py +94 -0
- crapkit/packet.py +343 -0
- crapkit/ratchet.py +236 -0
- crapkit/ratchet_report.py +135 -0
- crapkit/sarif.py +82 -0
- crapkit/sarifio.py +49 -0
- crapkit/scaffold.py +361 -0
- crapkit/score.py +255 -0
- crapkit/snapshot.py +51 -0
- crapkit/store.py +1066 -0
- crapkit/uncovered.py +131 -0
- crapkit/universe.py +157 -0
- crapkit/verify.py +194 -0
- crapkit/watch.py +112 -0
- crapkit/worklist.py +290 -0
- crapkit-0.2.0.dist-info/METADATA +802 -0
- crapkit-0.2.0.dist-info/RECORD +59 -0
- crapkit-0.2.0.dist-info/WHEEL +5 -0
- crapkit-0.2.0.dist-info/entry_points.txt +2 -0
- crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- crapkit-0.2.0.dist-info/top_level.txt +1 -0
crapkit/junitparse.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Parse JUnit XML into the set of failed test ids. Pure.
|
|
2
|
+
|
|
3
|
+
An id is classname::name. Failures and errors count; skips and passes do not.
|
|
4
|
+
stdlib ElementTree is deliberate: the XML comes from the repo's own test
|
|
5
|
+
runner, the same trust class as the lane commands themselves.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from .errors import ToolError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_failure(case: ET.Element) -> bool:
|
|
15
|
+
return case.find("failure") is not None or case.find("error") is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _case_id(case: ET.Element) -> str:
|
|
19
|
+
classname = case.get("classname") or case.get("file") or "?"
|
|
20
|
+
return f"{classname}::{case.get('name', '?')}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _root(xml_text: str) -> ET.Element:
|
|
24
|
+
try:
|
|
25
|
+
return ET.fromstring(xml_text)
|
|
26
|
+
except ET.ParseError as exc:
|
|
27
|
+
raise ToolError(f"unparseable junit report: {exc}") from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _walk(root: ET.Element) -> tuple[set[str], dict]:
|
|
31
|
+
"""One pass over the testcases: failed ids and the totals, together."""
|
|
32
|
+
failed = set()
|
|
33
|
+
tests = skipped = 0
|
|
34
|
+
for case in root.iter("testcase"):
|
|
35
|
+
tests += 1
|
|
36
|
+
if case.find("skipped") is not None:
|
|
37
|
+
skipped += 1
|
|
38
|
+
if _is_failure(case):
|
|
39
|
+
failed.add(_case_id(case))
|
|
40
|
+
return failed, {"tests": tests, "skipped": skipped}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def suite_summary(xml_text: str) -> tuple[set[str], dict]:
|
|
44
|
+
"""(failed ids, {tests, skipped}) from ONE DOM and ONE walk.
|
|
45
|
+
|
|
46
|
+
A lane needs both, and the two helpers below each parsed the same text, so
|
|
47
|
+
every lane built and threw away a second DOM of a report that runs to 5 MB.
|
|
48
|
+
Measured on a 2.8 MiB, 20,603-testcase report: 0.053s for the pair, 0.024s
|
|
49
|
+
here.
|
|
50
|
+
"""
|
|
51
|
+
failed, counts = _walk(_root(xml_text))
|
|
52
|
+
if counts["tests"] == 0:
|
|
53
|
+
raise ToolError("junit report contains zero testcases — the suite crashed before collecting, not a pass")
|
|
54
|
+
return failed, counts
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def suite_counts(xml_text: str) -> dict:
|
|
58
|
+
"""Total and skipped testcase counts — suite decay (fewer tests, more skips)
|
|
59
|
+
is invisible to a pass/fail check. Counts a zero-testcase report rather than
|
|
60
|
+
rejecting it; the failed-id readers are the ones that must refuse it."""
|
|
61
|
+
return _walk(_root(xml_text))[1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def failed_test_ids(xml_text: str) -> set[str]:
|
|
65
|
+
return suite_summary(xml_text)[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _seconds(raw: str | None) -> float:
|
|
69
|
+
"""A hand-edited or absent time attribute costs nothing, never a crash."""
|
|
70
|
+
try:
|
|
71
|
+
return float(raw)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return 0.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _sum_times(elements) -> float:
|
|
77
|
+
return sum(_seconds(e.get("time")) for e in elements)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def suite_seconds(xml_text: str) -> float:
|
|
81
|
+
"""Wall seconds the report claims, for costing a lane that did not run here.
|
|
82
|
+
|
|
83
|
+
Suite totals first; a runner that times only its cases is summed case by
|
|
84
|
+
case. Zero means the report carries no timing at all.
|
|
85
|
+
"""
|
|
86
|
+
root = _root(xml_text)
|
|
87
|
+
return _sum_times(root.iter("testsuite")) or _sum_times(root.iter("testcase"))
|
crapkit/lanes.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Lane runner shell: execute a configured coverage command, parse its artifact.
|
|
2
|
+
|
|
3
|
+
A lane command's exit code is recorded, not enforced: a suite with known
|
|
4
|
+
failures still writes a valid coverage artifact, and demanding green here
|
|
5
|
+
would make coverage unmeasurable on a brownfield repo. The artifact existing
|
|
6
|
+
and parsing is the contract.
|
|
7
|
+
|
|
8
|
+
Output STREAMS to .crapkit/lane-<name>.log while the command runs, so a long
|
|
9
|
+
lane is supervisable by tailing the file instead of staring at a silent
|
|
10
|
+
process. Timeouts and retries are lane config (timeout_seconds, retries).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import IO, NamedTuple
|
|
21
|
+
|
|
22
|
+
from .config import Lane
|
|
23
|
+
from .coverage_istanbul import FnCoverage
|
|
24
|
+
from .covstream import parse_coveragepy_file, parse_istanbul_file
|
|
25
|
+
from .errors import GitError, ToolError
|
|
26
|
+
from .gitio import GitFacts
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _in_container() -> bool:
|
|
30
|
+
return os.environ.get("CRAPKIT_INSIDE_CONTAINER") == "1" or Path("/.dockerenv").exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _refuse_container_python(lane: Lane) -> None:
|
|
34
|
+
if lane.parser == "coveragepy" and _in_container() and not lane.container_ok:
|
|
35
|
+
raise ToolError(
|
|
36
|
+
f"lane {lane.name!r} runs the python suite, which is host-only "
|
|
37
|
+
f"(container runs OOM); set container_ok = true only if this environment truly differs")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _lane_log_path(root: Path, lane: Lane) -> Path:
|
|
41
|
+
log_path = root / ".crapkit" / f"lane-{lane.name}.log"
|
|
42
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return log_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _log_tail(log_path: Path) -> str:
|
|
47
|
+
if not log_path.is_file():
|
|
48
|
+
return ""
|
|
49
|
+
return log_path.read_text(encoding="utf-8", errors="replace").strip()[-500:]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _popen_kwargs(root: Path, lane: Lane) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"cwd": root / lane.cwd if lane.cwd else root,
|
|
55
|
+
"env": {**os.environ, **dict(lane.env)} if lane.env else None,
|
|
56
|
+
"timeout": lane.timeout_seconds or None,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _log_header(fh: IO[str], command: str, attempt: int) -> None:
|
|
61
|
+
if attempt > 1:
|
|
62
|
+
fh.write(f"\n--- attempt {attempt} ---\n")
|
|
63
|
+
fh.write(f"$ {command}\n")
|
|
64
|
+
fh.flush()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _stream_command(root: Path, lane: Lane, log_path: Path, attempt: int) -> int:
|
|
68
|
+
with open(log_path, "a" if attempt > 1 else "w", encoding="utf-8", errors="replace") as fh:
|
|
69
|
+
_log_header(fh, lane.command, attempt)
|
|
70
|
+
try:
|
|
71
|
+
proc = subprocess.run(lane.command, shell=True, stdout=fh, stderr=subprocess.STDOUT,
|
|
72
|
+
**_popen_kwargs(root, lane))
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
fh.write(f"\n[crapkit] timed out after {lane.timeout_seconds}s; killed\n")
|
|
75
|
+
raise ToolError(f"lane {lane.name!r} timed out after {lane.timeout_seconds}s "
|
|
76
|
+
f"(attempt {attempt}); log: {log_path}") from None
|
|
77
|
+
fh.write(f"\n(exit {proc.returncode})\n")
|
|
78
|
+
return proc.returncode
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _attempt_once(root: Path, lane: Lane, log_path: Path, attempt: int) -> int | None:
|
|
82
|
+
"""One attempt; None means it timed out but another attempt remains."""
|
|
83
|
+
try:
|
|
84
|
+
return _stream_command(root, lane, log_path, attempt)
|
|
85
|
+
except ToolError:
|
|
86
|
+
if attempt > lane.retries:
|
|
87
|
+
raise
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _missing_plugin_hint(tail: str) -> str:
|
|
92
|
+
"""The one failure signature a new user cannot decode: pytest rejecting
|
|
93
|
+
--cov points at crapkit's config when the real gap is the pytest-cov package."""
|
|
94
|
+
if "unrecognized arguments" in tail and "--cov" in tail:
|
|
95
|
+
return " — the --cov flags come from the pytest-cov package: pip install pytest-cov"
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _raise_no_artifact(lane: Lane, log_path: Path, exit_code: int | None) -> None:
|
|
100
|
+
detail = f" (command exit {exit_code})" if exit_code is not None else ""
|
|
101
|
+
tail = _log_tail(log_path)
|
|
102
|
+
hint = f"; last output: {tail}" if tail else ""
|
|
103
|
+
raise ToolError(f"lane {lane.name!r} produced no artifact at {lane.artifact}{detail}{hint}"
|
|
104
|
+
f"{_missing_plugin_hint(tail)}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_attempts(root: Path, lane: Lane) -> int | None:
|
|
108
|
+
log_path = _lane_log_path(root, lane)
|
|
109
|
+
exit_code: int | None = None
|
|
110
|
+
for attempt in range(1, lane.retries + 2):
|
|
111
|
+
exit_code = _attempt_once(root, lane, log_path, attempt)
|
|
112
|
+
if exit_code is not None and (root / lane.artifact).is_file():
|
|
113
|
+
return exit_code
|
|
114
|
+
_raise_no_artifact(lane, log_path, exit_code)
|
|
115
|
+
return None # unreachable; keeps the signature honest
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _stamps_path(root: Path) -> Path:
|
|
119
|
+
return root / ".crapkit" / "artifacts.json"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def read_stamps(root: Path) -> dict:
|
|
123
|
+
"""The recorded artifact stamps: {artifact path: {commit, lane, seconds}}.
|
|
124
|
+
A missing or hand-mangled file reads as no stamps at all."""
|
|
125
|
+
path = _stamps_path(root)
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
return {}
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
130
|
+
except ValueError:
|
|
131
|
+
return {}
|
|
132
|
+
return data if isinstance(data, dict) else {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _stamp_entry(git: GitFacts, lane: Lane, seconds: float) -> dict:
|
|
136
|
+
"""What produced this artifact: the commit reuse judges staleness against,
|
|
137
|
+
plus the wall seconds the parallel scheduler starts the slowest lane on.
|
|
138
|
+
|
|
139
|
+
Empty in a non-git sandbox (unit tests), which records nothing. Lanes hand
|
|
140
|
+
their stamp back rather than writing it, so N of them running at once cannot
|
|
141
|
+
lose each other's entry through a read-modify-write of one shared file.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
commit = git.head_commit()
|
|
145
|
+
except GitError:
|
|
146
|
+
return {}
|
|
147
|
+
return {"commit": commit, "lane": lane.name, "seconds": round(seconds, 1)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_stamps(root: Path, entries: dict[str, dict]) -> None:
|
|
151
|
+
"""Merge this run's artifact stamps into .crapkit/artifacts.json in one write.
|
|
152
|
+
|
|
153
|
+
Dying before this point loses stamps but never fabricates one, so the worst
|
|
154
|
+
a crash costs is a rerun of lanes that could have been reused.
|
|
155
|
+
"""
|
|
156
|
+
fresh = {artifact: entry for artifact, entry in entries.items() if entry}
|
|
157
|
+
if not fresh:
|
|
158
|
+
return
|
|
159
|
+
path = _stamps_path(root)
|
|
160
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
path.write_text(json.dumps({**read_stamps(root), **fresh}, sort_keys=True, indent=1),
|
|
162
|
+
encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _recorded_seconds(entry: object) -> float | None:
|
|
166
|
+
"""The duration one stamp recorded, or None when it has none to give."""
|
|
167
|
+
value = entry.get("seconds") if isinstance(entry, dict) else None
|
|
168
|
+
return float(value) if isinstance(value, (int, float)) else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_lane(entry: object, name: str) -> bool:
|
|
172
|
+
return isinstance(entry, dict) and entry.get("lane") == name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _named_seconds(stamps: dict, name: str) -> float:
|
|
176
|
+
"""The longest run any stamp recorded under this lane's NAME.
|
|
177
|
+
|
|
178
|
+
Stamps are filed under the artifact PATH, so moving an artifact orphans its
|
|
179
|
+
duration and the lane sorts as never-measured: the consumer repo renamed 12 of them
|
|
180
|
+
and 11 of its 14 lanes read as zero seconds, which makes longest-first
|
|
181
|
+
scheduling do nothing at all. Every stamp already names the lane that wrote
|
|
182
|
+
it, so the name finds the record the path lost. Longest wins because the
|
|
183
|
+
schedule only ever needs an upper bound on how long the lane can run.
|
|
184
|
+
|
|
185
|
+
Durations only. Reuse and staleness still key on the exact artifact path:
|
|
186
|
+
judging a NEW artifact's freshness by an OLD one's commit would hand back a
|
|
187
|
+
stale coverage number, and a start order can never do that.
|
|
188
|
+
"""
|
|
189
|
+
named = (_recorded_seconds(entry) for entry in stamps.values() if _is_lane(entry, name))
|
|
190
|
+
return max((seconds for seconds in named if seconds is not None), default=0.0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _stamp_seconds(stamps: dict, lane: Lane) -> float:
|
|
194
|
+
"""How long this lane took last time it actually ran; 0 when never recorded.
|
|
195
|
+
The declared artifact is the exact record; the lane name is the fallback that
|
|
196
|
+
survives a rename."""
|
|
197
|
+
exact = _recorded_seconds(stamps.get(lane.artifact))
|
|
198
|
+
return exact if exact is not None else _named_seconds(stamps, lane.name)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def lane_order(root: Path, lanes: list[Lane]) -> list[Lane]:
|
|
202
|
+
"""Longest recorded lane first: with lanes running concurrently the makespan
|
|
203
|
+
is the slowest lane, so starting it last wastes exactly its own duration.
|
|
204
|
+
Sorting is stable, so unrecorded lanes and ties keep declaration order.
|
|
205
|
+
A recorded duration only ever changes WHICH lane starts first — results are
|
|
206
|
+
merged in declaration order regardless, so it cannot move a score."""
|
|
207
|
+
stamps = read_stamps(root)
|
|
208
|
+
return sorted(lanes, key=lambda lane: -_stamp_seconds(stamps, lane))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _scope_changes(git: GitFacts, lane: Lane, scope_paths: dict, since_commit: str) -> list[str]:
|
|
212
|
+
"""Committed or working-tree changes under this lane's scope paths since a commit."""
|
|
213
|
+
prefixes = tuple(f"{p.rstrip('/')}/" for name in lane.scopes for p in scope_paths.get(name, ()))
|
|
214
|
+
if not prefixes:
|
|
215
|
+
return []
|
|
216
|
+
changed = set(git.diff_names_since(since_commit)) | set(git.status_names())
|
|
217
|
+
return sorted(f for f in changed if f.startswith(prefixes))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _warn_stale_artifact(git: GitFacts, lane: Lane, scope_paths: dict | None) -> None:
|
|
221
|
+
"""On reuse: say when the artifact predates changes touching this lane's scopes.
|
|
222
|
+
Uncommitted working-tree edits count — that is the most common way to go stale."""
|
|
223
|
+
stamp = read_stamps(git.root).get(lane.artifact)
|
|
224
|
+
if not stamp or not scope_paths:
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
changed = _scope_changes(git, lane, scope_paths, stamp["commit"])
|
|
228
|
+
except GitError:
|
|
229
|
+
return
|
|
230
|
+
if changed:
|
|
231
|
+
print(f"crapkit: lane {lane.name!r} artifact was built at {stamp['commit'][:11]}; "
|
|
232
|
+
f"{len(changed)} file(s) in its scopes changed since (their coverage is stale)",
|
|
233
|
+
file=sys.stderr)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _facts(root: Path, git: GitFacts | None) -> GitFacts:
|
|
237
|
+
"""A whole command shares one context; a lone caller gets its own."""
|
|
238
|
+
return git if git is not None else GitFacts(root)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def lane_unchanged(root: Path, lane: Lane, scope_paths: dict, git: GitFacts | None = None) -> bool:
|
|
242
|
+
"""True when the recorded artifact still describes this lane's scopes exactly:
|
|
243
|
+
the stamp commit reached HEAD and nothing under the scopes moved since."""
|
|
244
|
+
facts = _facts(root, git)
|
|
245
|
+
stamp = read_stamps(root).get(lane.artifact)
|
|
246
|
+
if not stamp or not (root / lane.artifact).is_file():
|
|
247
|
+
return False
|
|
248
|
+
try:
|
|
249
|
+
if not facts.is_ancestor(stamp["commit"]):
|
|
250
|
+
return False
|
|
251
|
+
return not _scope_changes(facts, lane, scope_paths, stamp["commit"])
|
|
252
|
+
except GitError:
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_and_parse(lane: Lane, root: Path,
|
|
257
|
+
artifact_path: Path) -> tuple[dict[str, list[FnCoverage]], str]:
|
|
258
|
+
"""This lane's coverage, plus the sha256 of the artifact's own bytes.
|
|
259
|
+
|
|
260
|
+
The reader takes the PATH, not the text: a whole-document parse needs the
|
|
261
|
+
bytes and their UTF-8 decode both live before the first function is
|
|
262
|
+
attributed, which is two copies of an artifact that runs to hundreds of MB.
|
|
263
|
+
Streaming holds one chunk and one file's coverage instead, and hashes the
|
|
264
|
+
bytes on the way past, so the recorded digest costs no second read.
|
|
265
|
+
"""
|
|
266
|
+
if lane.parser == "istanbul":
|
|
267
|
+
return parse_istanbul_file(artifact_path, repo_root=str(root))
|
|
268
|
+
if lane.parser == "coveragepy":
|
|
269
|
+
return parse_coveragepy_file(artifact_path, path_prefix=lane.path_prefix)
|
|
270
|
+
raise ToolError(f"lane {lane.name!r}: parser {lane.parser!r} not implemented yet")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _results_provenance(root: Path, lane: Lane) -> dict:
|
|
274
|
+
from .junitparse import suite_summary
|
|
275
|
+
|
|
276
|
+
results_path = root / lane.results_artifact
|
|
277
|
+
if not results_path.is_file():
|
|
278
|
+
# Under --reuse-artifacts too: a declared-but-missing results file
|
|
279
|
+
# would make the no-NEW-failures check pass vacuously.
|
|
280
|
+
raise ToolError(f"lane {lane.name!r}: results_artifact {lane.results_artifact} is missing")
|
|
281
|
+
failed, counts = suite_summary(results_path.read_text(encoding="utf-8"))
|
|
282
|
+
return {"failures": sorted(failed),
|
|
283
|
+
"tests_total": counts["tests"], "tests_skipped": counts["skipped"]}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def build_retest_command(template: str, tests: set[str]) -> str:
|
|
287
|
+
"""Fill a retest template. {tests}: sorted quoted ids verbatim (pytest style).
|
|
288
|
+
{files}: unique quoted classnames — vitest's junit classname IS the test file.
|
|
289
|
+
{names}: re.escape'd alternation of test names, for -t style regex filters."""
|
|
290
|
+
import re
|
|
291
|
+
|
|
292
|
+
ordered = sorted(tests)
|
|
293
|
+
files = sorted({t.split("::", 1)[0] for t in ordered})
|
|
294
|
+
names = "|".join(re.escape(t.split("::", 1)[1]) for t in ordered if "::" in t)
|
|
295
|
+
return (template.replace("{tests}", " ".join(f'"{t}"' for t in ordered))
|
|
296
|
+
.replace("{files}", " ".join(f'"{f}"' for f in files))
|
|
297
|
+
.replace("{names}", names))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def retest_lane(root: Path, lane: Lane, tests: set[str]) -> set[str]:
|
|
301
|
+
"""Run the lane's retest_command on just these ids; return the ones that
|
|
302
|
+
PASS the rerun (the flakes). Any doubt — no artifact, a crash, a timeout —
|
|
303
|
+
keeps everything failed."""
|
|
304
|
+
command = build_retest_command(lane.retest_command, tests)
|
|
305
|
+
log_path = _lane_log_path(root, lane)
|
|
306
|
+
try:
|
|
307
|
+
with open(log_path, "a", encoding="utf-8", errors="replace") as fh:
|
|
308
|
+
fh.write(f"\n--- flake retest ---\n$ {command}\n")
|
|
309
|
+
fh.flush()
|
|
310
|
+
subprocess.run(command, shell=True, stdout=fh, stderr=subprocess.STDOUT,
|
|
311
|
+
**_popen_kwargs(root, lane))
|
|
312
|
+
except subprocess.TimeoutExpired:
|
|
313
|
+
return set()
|
|
314
|
+
still = _still_failed(root, lane)
|
|
315
|
+
if still is None:
|
|
316
|
+
return set()
|
|
317
|
+
return set(tests) - still
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _still_failed(root: Path, lane: Lane) -> set[str] | None:
|
|
321
|
+
"""The failing ids in the lane's results artifact; None means unreadable."""
|
|
322
|
+
from .junitparse import failed_test_ids
|
|
323
|
+
|
|
324
|
+
results_path = root / lane.results_artifact
|
|
325
|
+
if not results_path.is_file():
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
return failed_test_ids(results_path.read_text(encoding="utf-8"))
|
|
329
|
+
except ToolError:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class LaneOutcome(NamedTuple):
|
|
334
|
+
"""One lane's result. `stamp` is what the caller must persist (empty when the
|
|
335
|
+
lane reused an artifact, or when there is no git repo to stamp against)."""
|
|
336
|
+
coverage: dict[str, list[FnCoverage]]
|
|
337
|
+
provenance: dict
|
|
338
|
+
stamp: dict
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _run_or_reuse(root: Path, lane: Lane, git: GitFacts, scope_paths: dict | None,
|
|
342
|
+
reuse_artifact: bool) -> tuple[int | None, float]:
|
|
343
|
+
"""Reuse warns and costs nothing; a real run returns its exit code and wall seconds."""
|
|
344
|
+
if reuse_artifact:
|
|
345
|
+
_warn_stale_artifact(git, lane, scope_paths)
|
|
346
|
+
return None, 0.0
|
|
347
|
+
started = time.monotonic()
|
|
348
|
+
return _run_attempts(root, lane), time.monotonic() - started
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _artifact_path(root: Path, lane: Lane) -> Path:
|
|
352
|
+
path = root / lane.artifact
|
|
353
|
+
if not path.is_file():
|
|
354
|
+
raise ToolError(f"lane {lane.name!r} produced no artifact at {lane.artifact}")
|
|
355
|
+
return path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def run_lane(root: Path, lane: Lane, *, reuse_artifact: bool = False,
|
|
359
|
+
scope_paths: dict | None = None, git: GitFacts | None = None) -> LaneOutcome:
|
|
360
|
+
facts = _facts(root, git)
|
|
361
|
+
_refuse_container_python(lane)
|
|
362
|
+
exit_code, seconds = _run_or_reuse(root, lane, facts, scope_paths, reuse_artifact)
|
|
363
|
+
coverage, digest = _read_and_parse(lane, root, _artifact_path(root, lane))
|
|
364
|
+
provenance = {
|
|
365
|
+
"artifact_sha256": digest,
|
|
366
|
+
"exit_code": exit_code,
|
|
367
|
+
"parser": lane.parser,
|
|
368
|
+
"scopes": list(lane.scopes),
|
|
369
|
+
}
|
|
370
|
+
if lane.results_artifact:
|
|
371
|
+
provenance.update(_results_provenance(root, lane))
|
|
372
|
+
stamp = {} if reuse_artifact else _stamp_entry(facts, lane, seconds)
|
|
373
|
+
return LaneOutcome(coverage, provenance, stamp)
|