8Run a test command once per mutant and classify each outcome honestly.
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.
22 L3 - per-mutant execution details
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.
37- oct.mutation.engine (enumeration and mutant identity)
38- oct.mutation.sandbox (target refusal, apply, and restore)
39- subprocess (per-mutant pytest execution)
45from __future__
import annotations
52from dataclasses
import dataclass, field
53from pathlib
import Path
54from typing
import Callable, Iterable, Sequence
59 ensure_recoverable_target,
65STATUS_KILLED =
"killed"
66STATUS_SURVIVED =
"survived"
67STATUS_TIMEOUT =
"timeout"
72_PYTEST_NO_TESTS_COLLECTED = 5
74DEFAULT_TIMEOUT_SECONDS = 60
77@dataclass(frozen=True, slots=True)
79 """One mutant's classification and the evidence behind it."""
83 duration_seconds: float
84 returncode: int |
None
87 """Return a JSON-ready record carrying the mutant and its verdict."""
96@dataclass(frozen=True, slots=True)
98 """The full result of one mutation run over one target."""
103 baseline_seconds: float
104 outcomes: list[MutantOutcome] = field(default_factory=list)
108 """Return how many mutants were run."""
113 """Return how many mutants the tests noticed."""
114 return self.
_count(STATUS_KILLED)
118 """Return how many mutants the tests failed to notice."""
119 return self.
_count(STATUS_SURVIVED)
123 """Return how many mutants exceeded the per-run timeout."""
124 return self.
_count(STATUS_TIMEOUT)
128 """Return the surviving outcomes in enumeration order."""
129 return [item
for item
in self.
outcomes if item.status == STATUS_SURVIVED]
133 """Return killed over total, or ``None`` when nothing was run.
135 Timeouts stay in the denominator: a mutant nobody could judge is not
136 evidence that the suite would have caught it.
143 """Return how many outcomes carry ``status``."""
144 return sum(1
for item
in self.
outcomes if item.status == status)
147 """Return the full machine-readable structure."""
160 "mutants": [outcome.to_dict()
for outcome
in self.
outcomes],
165 """Return the pytest command used for both the baseline and each mutant.
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.
190) -> tuple[int |
None, float]:
191 """Run the tests once, returning the exit code and elapsed seconds.
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.
197 env = dict(os.environ)
198 env[
"PYTHONPYCACHEPREFIX"] = str(pycache_dir)
199 started = time.monotonic()
201 proc = subprocess.run(
212 except subprocess.TimeoutExpired:
213 return None, time.monotonic() - started
214 return proc.returncode, time.monotonic() - started
223 """Refuse unless the pristine target passes the given tests.
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
229 returncode, duration =
_run_tests(tests, cwd, timeout, pycache_dir)
230 if returncode
is None:
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"
236 if returncode == _PYTEST_NO_TESTS_COLLECTED:
238 f
"no tests were collected from {tests}; every mutant would be "
239 "scored against an empty suite"
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"
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,
259 """Run every enumerated mutant of ``target`` and classify each outcome.
261 ``mutant_ids`` narrows the run to named mutants; it exists for tests and
262 is deliberately not a CLI flag in v1.
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():
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...")
278 resolved_tests, repo_root, timeout, cache_dir /
"baseline"
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."
297 tests=str(resolved_tests),
298 timeout_seconds=timeout,
299 baseline_seconds=baseline_seconds,
305 mutants: list[Mutant],
306 mutant_ids: Sequence[str] |
None,
308 """Return the requested subset, refusing an id the target does not hold."""
309 if mutant_ids
is None:
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]
316 return [by_id[item]
for item
in wanted]
320 mutants: Iterable[Mutant],
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):
335 tests, repo_root, timeout, cache_dir / f
"mutant-{index}"
342 duration_seconds=duration,
343 returncode=returncode,
347 f
"[{index}/{len(ordered)}] {mutant.mutant_id} -> {status} "
354 """Map one test run's exit code onto the three pinned outcome classes."""
355 if returncode
is None:
356 return STATUS_TIMEOUT
358 return STATUS_SURVIVED
dict[str, object] to_dict(self)
int _count(self, str status)
list[MutantOutcome] survivors(self)
dict[str, object] to_dict(self)
str _classify(int|None returncode)
list[str] _pytest_argv(Path tests)
float _verify_baseline(Path tests, Path cwd, int timeout, Path pycache_dir)
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)
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)
tuple[int|None, float] _run_tests(Path tests, Path cwd, int timeout, Path pycache_dir)
list[Mutant] _select(list[Mutant] mutants, Sequence[str]|None mutant_ids)