oct
Loading...
Searching...
No Matches
runner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mutation/runner.py
4
5"""
6Purpose
7-------
8Run a test command once per mutant and classify each outcome honestly.
9
10Responsibilities
11----------------
12- Prove the tests are green on the pristine target before judging any mutant.
13- Execute the given test path in a subprocess with a per-mutant timeout.
14- Classify each mutant as killed, survived, or timeout, never conflating them.
15- Stream per-mutant progress so a long run is never silent.
16
17Diagnostics
18-----------
19Domain: OCT-MUTATION
20Levels:
21 L2 - lifecycle
22 L3 - per-mutant execution details
23 L4 - deep tracing
24
25Contracts
26---------
27- A timeout is its own class and is never counted as a kill.
28- The pristine baseline must pass; without it every mutant would look killed,
29 so a red or empty baseline is a named refusal rather than a 100% kill rate.
30- Each subprocess gets a fresh bytecode-cache directory, so no run can be
31 decided by a stale ``.pyc`` from the previous mutant.
32- Runtime is mutant count times one test run; the caller is told the count
33 before the loop starts.
34
35Dependencies
36------------
37- oct.mutation.engine (enumeration and mutant identity)
38- oct.mutation.sandbox (target refusal, apply, and restore)
39- subprocess (per-mutant pytest execution)
40"""
41
42# OCT-LINT: disable-file=dbg_usage reason="Progress is the user-facing channel here; diagnostics would duplicate it into a stream the command reserves"
43# OCT-LINT: disable-file=dbg_import reason="Runner is kept import-minimal so the restore path stays dependency-light"
44
45from __future__ import annotations
46
47import os
48import subprocess
49import sys
50import tempfile
51import time
52from dataclasses import dataclass, field
53from pathlib import Path
54from typing import Callable, Iterable, Sequence
55
56from oct.mutation.engine import Mutant, enumerate_source, relative_id
57from oct.mutation.sandbox import (
58 MutationRefusal,
59 ensure_recoverable_target,
60 mutated_target,
61 read_target,
62)
63
64
65STATUS_KILLED = "killed"
66STATUS_SURVIVED = "survived"
67STATUS_TIMEOUT = "timeout"
68
69# pytest's own "no tests were collected" exit code. The baseline preflight
70# turns this into a refusal, which is what makes the mutant-loop mapping of
71# "any non-zero exit is a kill" honest.
72_PYTEST_NO_TESTS_COLLECTED = 5
73
74DEFAULT_TIMEOUT_SECONDS = 60
75
76
77@dataclass(frozen=True, slots=True)
79 """One mutant's classification and the evidence behind it."""
80
81 mutant: Mutant
82 status: str
83 duration_seconds: float
84 returncode: int | None
85
86 def to_dict(self) -> dict[str, object]:
87 """Return a JSON-ready record carrying the mutant and its verdict."""
88 return {
89 **self.mutant.to_dict(),
90 "status": self.status,
91 "duration_seconds": round(self.duration_seconds, 3),
92 "returncode": self.returncode,
93 }
94
95
96@dataclass(frozen=True, slots=True)
98 """The full result of one mutation run over one target."""
99
100 target: str
101 tests: str
102 timeout_seconds: int
103 baseline_seconds: float
104 outcomes: list[MutantOutcome] = field(default_factory=list)
105
106 @property
107 def total(self) -> int:
108 """Return how many mutants were run."""
109 return len(self.outcomes)
110
111 @property
112 def killed(self) -> int:
113 """Return how many mutants the tests noticed."""
114 return self._count(STATUS_KILLED)
115
116 @property
117 def survived(self) -> int:
118 """Return how many mutants the tests failed to notice."""
119 return self._count(STATUS_SURVIVED)
120
121 @property
122 def timed_out(self) -> int:
123 """Return how many mutants exceeded the per-run timeout."""
124 return self._count(STATUS_TIMEOUT)
125
126 @property
127 def survivors(self) -> list[MutantOutcome]:
128 """Return the surviving outcomes in enumeration order."""
129 return [item for item in self.outcomes if item.status == STATUS_SURVIVED]
130
131 @property
132 def kill_rate(self) -> float | None:
133 """Return killed over total, or ``None`` when nothing was run.
134
135 Timeouts stay in the denominator: a mutant nobody could judge is not
136 evidence that the suite would have caught it.
137 """
138 if not self.outcomes:
139 return None
140 return self.killed / len(self.outcomes)
141
142 def _count(self, status: str) -> int:
143 """Return how many outcomes carry ``status``."""
144 return sum(1 for item in self.outcomes if item.status == status)
145
146 def to_dict(self) -> dict[str, object]:
147 """Return the full machine-readable structure."""
148 return {
149 "target": self.target,
150 "tests": self.tests,
151 "timeout_seconds": self.timeout_seconds,
152 "baseline_seconds": round(self.baseline_seconds, 3),
153 "totals": {
154 "mutants": self.total,
155 "killed": self.killed,
156 "survived": self.survived,
157 "timeout": self.timed_out,
158 },
159 "kill_rate": None if self.kill_rate is None else round(self.kill_rate, 4),
160 "mutants": [outcome.to_dict() for outcome in self.outcomes],
161 }
162
163
164def _pytest_argv(tests: Path) -> list[str]:
165 """Return the pytest command used for both the baseline and each mutant.
166
167 ``-x`` stops at the first failure because one failure already proves the
168 mutant was noticed. Plugin autoloading for randomisation and the cache is
169 switched off so a run is decided by the mutant and nothing else.
170 """
171 return [
172 sys.executable,
173 "-m",
174 "pytest",
175 str(tests),
176 "-q",
177 "-x",
178 "-p",
179 "no:randomly",
180 "-p",
181 "no:cacheprovider",
182 ]
183
184
186 tests: Path,
187 cwd: Path,
188 timeout: int,
189 pycache_dir: Path,
190) -> tuple[int | None, float]:
191 """Run the tests once, returning the exit code and elapsed seconds.
192
193 The exit code is ``None`` when the run exceeded ``timeout``; the child is
194 killed by :func:`subprocess.run` at that point. Grandchildren the test run
195 spawned are not tracked, which is a named residual rather than a guarantee.
196 """
197 env = dict(os.environ)
198 env["PYTHONPYCACHEPREFIX"] = str(pycache_dir)
199 started = time.monotonic()
200 try:
201 proc = subprocess.run(
202 _pytest_argv(tests),
203 cwd=str(cwd),
204 env=env,
205 shell=False,
206 capture_output=True,
207 text=True,
208 encoding="utf-8",
209 errors="replace",
210 timeout=timeout,
211 )
212 except subprocess.TimeoutExpired:
213 return None, time.monotonic() - started
214 return proc.returncode, time.monotonic() - started
215
216
218 tests: Path,
219 cwd: Path,
220 timeout: int,
221 pycache_dir: Path,
222) -> float:
223 """Refuse unless the pristine target passes the given tests.
224
225 Skipping this check is how a mutation tool reports a flattering kill rate
226 it did not earn: against a red suite every mutant "fails the tests" and is
227 scored as killed.
228 """
229 returncode, duration = _run_tests(tests, cwd, timeout, pycache_dir)
230 if returncode is None:
231 raise MutationRefusal(
232 f"the tests did not finish within {timeout}s on the unmutated "
233 "target, so no mutant could be judged against them; raise "
234 "--timeout or narrow --tests"
235 )
236 if returncode == _PYTEST_NO_TESTS_COLLECTED:
237 raise MutationRefusal(
238 f"no tests were collected from {tests}; every mutant would be "
239 "scored against an empty suite"
240 )
241 if returncode != 0:
242 raise MutationRefusal(
243 f"the tests fail on the unmutated target (pytest exit {returncode}); "
244 "against a red suite every mutant looks killed, so the run stops "
245 "before it can report a kill rate it did not earn"
246 )
247 return duration
248
249
251 *,
252 target: str | Path,
253 tests: str | Path,
254 timeout: int = DEFAULT_TIMEOUT_SECONDS,
255 root: str | Path | None = None,
256 mutant_ids: Sequence[str] | None = None,
257 on_progress: Callable[[str], None] | None = None,
258) -> RunReport:
259 """Run every enumerated mutant of ``target`` and classify each outcome.
260
261 ``mutant_ids`` narrows the run to named mutants; it exists for tests and
262 is deliberately not a CLI flag in v1.
263 """
264 resolved_target = Path(target).resolve()
265 resolved_tests = Path(tests).resolve()
266 repo_root = ensure_recoverable_target(resolved_target)
267 if not resolved_tests.exists():
268 raise MutationRefusal(f"tests path does not exist: {resolved_tests}")
269 anchor = Path(root).resolve() if root is not None else repo_root
270 relpath = relative_id(resolved_target, anchor)
271 _, source, _ = read_target(resolved_target)
272 mutants = _select(enumerate_source(source, relpath), mutant_ids)
273 report_progress = on_progress or (lambda _message: None)
274 with tempfile.TemporaryDirectory(prefix="oct-mutate-") as cache_root:
275 cache_dir = Path(cache_root)
276 report_progress("Verifying the tests are green on the unmutated target...")
277 baseline_seconds = _verify_baseline(
278 resolved_tests, repo_root, timeout, cache_dir / "baseline"
279 )
280 report_progress(
281 f"Baseline green in {baseline_seconds:.1f}s. "
282 f"Running {len(mutants)} mutant(s); "
283 f"about {len(mutants) * baseline_seconds:.0f}s at baseline speed."
284 )
285 outcomes = _run_each(
286 mutants,
287 resolved_target,
288 resolved_tests,
289 repo_root,
290 relpath,
291 timeout,
292 cache_dir,
293 report_progress,
294 )
295 return RunReport(
296 target=relpath,
297 tests=str(resolved_tests),
298 timeout_seconds=timeout,
299 baseline_seconds=baseline_seconds,
300 outcomes=outcomes,
301 )
302
303
305 mutants: list[Mutant],
306 mutant_ids: Sequence[str] | None,
307) -> list[Mutant]:
308 """Return the requested subset, refusing an id the target does not hold."""
309 if mutant_ids is None:
310 return mutants
311 wanted = list(mutant_ids)
312 by_id = {mutant.mutant_id: mutant for mutant in mutants}
313 missing = [item for item in wanted if item not in by_id]
314 if missing:
315 raise MutationRefusal(f"unknown mutant id(s): {', '.join(missing)}")
316 return [by_id[item] for item in wanted]
317
318
320 mutants: Iterable[Mutant],
321 target: Path,
322 tests: Path,
323 repo_root: Path,
324 relpath: str,
325 timeout: int,
326 cache_dir: Path,
327 report_progress: Callable[[str], None],
328) -> list[MutantOutcome]:
329 """Apply, run, and classify each mutant, restoring the target every time."""
330 outcomes: list[MutantOutcome] = []
331 ordered = list(mutants)
332 for index, mutant in enumerate(ordered, start=1):
333 with mutated_target(target, mutant.mutant_id, relpath):
334 returncode, duration = _run_tests(
335 tests, repo_root, timeout, cache_dir / f"mutant-{index}"
336 )
337 status = _classify(returncode)
338 outcomes.append(
340 mutant=mutant,
341 status=status,
342 duration_seconds=duration,
343 returncode=returncode,
344 )
345 )
346 report_progress(
347 f"[{index}/{len(ordered)}] {mutant.mutant_id} -> {status} "
348 f"({duration:.1f}s)"
349 )
350 return outcomes
351
352
353def _classify(returncode: int | None) -> str:
354 """Map one test run's exit code onto the three pinned outcome classes."""
355 if returncode is None:
356 return STATUS_TIMEOUT
357 if returncode == 0:
358 return STATUS_SURVIVED
359 return STATUS_KILLED
dict[str, object] to_dict(self)
Definition runner.py:86
int _count(self, str status)
Definition runner.py:142
list[MutantOutcome] survivors(self)
Definition runner.py:127
dict[str, object] to_dict(self)
Definition runner.py:146
str _classify(int|None returncode)
Definition runner.py:353
list[str] _pytest_argv(Path tests)
Definition runner.py:164
float _verify_baseline(Path tests, Path cwd, int timeout, Path pycache_dir)
Definition runner.py:222
list[MutantOutcome] _run_each(Iterable[Mutant] mutants, Path target, Path tests, Path repo_root, str relpath, int timeout, Path cache_dir, Callable[[str], None] report_progress)
Definition runner.py:328
RunReport run_mutants(*, str|Path target, str|Path tests, int timeout=DEFAULT_TIMEOUT_SECONDS, str|Path|None root=None, Sequence[str]|None mutant_ids=None, Callable[[str], None]|None on_progress=None)
Definition runner.py:258
tuple[int|None, float] _run_tests(Path tests, Path cwd, int timeout, Path pycache_dir)
Definition runner.py:190
list[Mutant] _select(list[Mutant] mutants, Sequence[str]|None mutant_ids)
Definition runner.py:307