oct
Loading...
Searching...
No Matches
coverage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/complexity/coverage.py
4
5"""
6Purpose
7-------
8Run pytest under coverage.py and map executed statements to function spans.
9
10Responsibilities
11----------------
12- Execute subproject suites in process for witnesses or in a subprocess for probes.
13- Preserve suite return codes and both output streams.
14- Mark functions in unexecuted files as zero-coverage ``never-imported`` records.
15- Intersect coverage.py statement data with the engine's pinned source spans.
16
17Diagnostics
18-----------
19Domain: OCT-COMPLEXITY
20Levels:
21 L2 - suite lifecycle
22 L3 - coverage mapping details
23 L4 - deep tracing
24
25Contracts
26---------
27- Third-party imports remain lazy so :mod:`oct.complexity` stays zero-dependency.
28- Missing coverage.py produces an explicit error rather than an estimate.
29- Failed suites retain their output for UNMEASURED probe reporting.
30
31Dependencies
32------------
33- coverage (not imported; optional statement measurement and data reading, loaded by name through importlib and run as python -m coverage)
34- pytest (not imported; test runner, loaded by name for in-process witnesses and run under coverage in probes)
35- oct.complexity.engine (function spans)
36"""
37
38# OCT-LINT: disable-file=dbg_usage reason="Measurement output must remain deterministic and importing the package root must stay zero-dependency under Plan 214 P1"
39# OCT-LINT: disable-file=dbg_import reason="Measurement output and lazy dependency boundaries must remain deterministic"
40
41from __future__ import annotations
42
43import contextlib
44import importlib
45import io
46import os
47import subprocess
48import sys
49from dataclasses import asdict, dataclass
50from pathlib import Path
51from typing import Any, Sequence
52
53from oct.complexity.engine import FunctionComplexity
54
55
56class CoverageUnavailableError(RuntimeError):
57 """Raised when a coverage-backed operation lacks coverage.py."""
58
59
60@dataclass(frozen=True, slots=True)
62 """Captured outcome of one pytest suite executed under coverage.py."""
63
64 returncode: int
65 data_file: str
66 command: tuple[str, ...]
67 stdout: str
68 stderr: str
69
70 @property
71 def output(self) -> str:
72 """Return stdout and stderr without discarding either stream."""
73 parts = [part.rstrip() for part in (self.stdout, self.stderr) if part]
74 return "\n".join(parts)
75
76
77@dataclass(frozen=True, slots=True)
79 """A P1 complexity record enriched with pinned P2 coverage fields."""
80
81 file: str
82 qualname: str
83 lineno: int
84 end_lineno: int
85 complexity: int
86 coverage: float
87 coverage_status: str
88 covered_statements: int
89 statement_count: int
90
91 def to_dict(self) -> dict[str, str | int | float]:
92 """Return every P1/P2 field in a JSON-ready dictionary."""
93 return asdict(self)
94
95
96def _import_coverage() -> Any:
97 """Import coverage.py lazily and normalize the missing-extra error."""
98 try:
99 return importlib.import_module("coverage")
100 except ImportError as exc:
102 "coverage.py is not installed; install oct[metrics] to map coverage"
103 ) from exc
104
105
106def _import_pytest() -> Any:
107 """Import pytest lazily for the in-process witness runner."""
108 try:
109 return importlib.import_module("pytest")
110 except ImportError as exc:
112 "pytest is not installed; install oct[test] to run a suite"
113 ) from exc
114
115
117 project_root: str | Path,
118 pytest_args: Sequence[str] = ("tests",),
119 data_file: str | Path = ".coverage",
120 source: Sequence[str | Path] = (),
121 *,
122 in_process: bool = False,
123 python_executable: str | Path | None = None,
124 env: dict[str, str] | None = None,
125) -> CoverageRun:
126 """Execute a subproject's pytest suite and retain its coverage data.
127
128 Production probes use the subprocess path so each subproject has clean
129 import state. The in-process path exists for worktree tests, where the
130 repository's editable install points at MAIN and subprocess assertions
131 would otherwise verify the wrong source tree.
132 """
133 root = Path(project_root).resolve()
134 resolved_data = Path(data_file)
135 if not resolved_data.is_absolute():
136 resolved_data = root / resolved_data
137 resolved_data = resolved_data.resolve()
138 resolved_data.parent.mkdir(parents=True, exist_ok=True)
139 source_paths = tuple(str(Path(item).resolve()) for item in source)
140 if in_process:
142 root,
143 tuple(pytest_args),
144 resolved_data,
145 source_paths,
146 )
148 root,
149 tuple(pytest_args),
150 resolved_data,
151 source_paths,
152 python_executable=python_executable,
153 env=env,
154 )
155
156
158 project_root: Path,
159 pytest_args: tuple[str, ...],
160 data_file: Path,
161 source: tuple[str, ...],
162) -> CoverageRun:
163 """Run pytest under a Coverage instance in the current interpreter."""
164 coverage_module = _import_coverage()
165 pytest_module = _import_pytest()
166 coverage = coverage_module.Coverage(
167 data_file=str(data_file),
168 source=list(source) or None,
169 )
170 stdout = io.StringIO()
171 stderr = io.StringIO()
172 old_cwd = Path.cwd()
173 sys.path.insert(0, str(project_root))
174 command = ("pytest", *pytest_args)
175 returncode = 1
176 try:
177 os.chdir(project_root)
178 coverage.start()
179 with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
180 returncode = int(pytest_module.main(list(pytest_args)))
181 finally:
182 coverage.stop()
183 coverage.save()
184 os.chdir(old_cwd)
185 _remove_first(sys.path, str(project_root))
186 return CoverageRun(
187 returncode=returncode,
188 data_file=str(data_file),
189 command=command,
190 stdout=stdout.getvalue(),
191 stderr=stderr.getvalue(),
192 )
193
194
195def _remove_first(items: list[str], value: str) -> None:
196 """Remove the first matching value without disturbing prior path state."""
197 try:
198 items.remove(value)
199 except ValueError:
200 pass
201
202
204 project_root: Path,
205 pytest_args: tuple[str, ...],
206 data_file: Path,
207 source: tuple[str, ...],
208 *,
209 python_executable: str | Path | None,
210 env: dict[str, str] | None,
211) -> CoverageRun:
212 """Run pytest under the coverage CLI in an isolated child process."""
214 command = [
215 str(python_executable or sys.executable),
216 "-m",
217 "coverage",
218 "run",
219 f"--data-file={data_file}",
220 ]
221 if source:
222 command.append(f"--source={','.join(source)}")
223 command.extend(["-m", "pytest", *pytest_args])
224 completed = subprocess.run(
225 command,
226 cwd=project_root,
227 env=env,
228 capture_output=True,
229 text=True,
230 errors="replace",
231 )
232 return CoverageRun(
233 returncode=completed.returncode,
234 data_file=str(data_file),
235 command=tuple(command),
236 stdout=completed.stdout,
237 stderr=completed.stderr,
238 )
239
240
242 records: Sequence[FunctionComplexity],
243 data_file: str | Path,
244) -> list[CoveredFunction]:
245 """Intersect coverage.py statements with each function's source span."""
246 coverage_module = _import_coverage()
247 resolved_data = Path(data_file).resolve()
248 if not resolved_data.is_file():
249 raise FileNotFoundError(f"coverage data file not found: {resolved_data}")
250 coverage = coverage_module.Coverage(data_file=str(resolved_data))
251 coverage.load()
252 measured_files = {
253 _normalized_path(filename): filename
254 for filename in coverage.get_data().measured_files()
255 }
256 return [
257 _map_one(coverage, measured_files, record)
258 for record in records
259 ]
260
261
263 coverage: Any,
264 measured_files: dict[str, str],
265 record: FunctionComplexity,
266) -> CoveredFunction:
267 """Map one function record, preserving unseen files as zero coverage."""
268 measured_name = measured_files.get(_normalized_path(record.file))
269 executed = (
270 set(coverage.get_data().lines(measured_name) or ())
271 if measured_name is not None
272 else set()
273 )
274 status = "measured" if executed else "never-imported"
275 statements = _statement_lines(coverage, record.file)
276 span_statements = {
277 line
278 for line in statements
279 if record.lineno <= line <= record.end_lineno
280 }
281 if status == "never-imported":
282 covered_count = 0
283 fraction = 0.0
284 else:
285 covered_count = len(span_statements & executed)
286 fraction = (
287 covered_count / len(span_statements)
288 if span_statements
289 else 1.0
290 )
291 return CoveredFunction(
292 file=record.file,
293 qualname=record.qualname,
294 lineno=record.lineno,
295 end_lineno=record.end_lineno,
296 complexity=record.complexity,
297 coverage=fraction,
298 coverage_status=status,
299 covered_statements=covered_count,
300 statement_count=len(span_statements),
301 )
302
303
304def _statement_lines(coverage: Any, filename: str) -> set[int]:
305 """Return executable statement lines using coverage.py's own parser."""
306 _filename, statements, _excluded, _missing, _branches = coverage.analysis2(
307 filename
308 )
309 return set(statements)
310
311
312def _normalized_path(filename: str | Path) -> str:
313 """Normalize measured filenames for case-insensitive Windows matching."""
314 return os.path.normcase(str(Path(filename).resolve()))
dict[str, str|int|float] to_dict(self)
Definition coverage.py:91
CoveredFunction _map_one(Any coverage, dict[str, str] measured_files, FunctionComplexity record)
Definition coverage.py:266
CoverageRun _run_pytest_subprocess(Path project_root, tuple[str,...] pytest_args, Path data_file, tuple[str,...] source, *, str|Path|None python_executable, dict[str, str]|None env)
Definition coverage.py:211
str _normalized_path(str|Path filename)
Definition coverage.py:312
None _remove_first(list[str] items, str value)
Definition coverage.py:195
set[int] _statement_lines(Any coverage, str filename)
Definition coverage.py:304
list[CoveredFunction] map_coverage(Sequence[FunctionComplexity] records, str|Path data_file)
Definition coverage.py:244
CoverageRun _run_pytest_in_process(Path project_root, tuple[str,...] pytest_args, Path data_file, tuple[str,...] source)
Definition coverage.py:162
CoverageRun run_pytest_with_coverage(str|Path project_root, Sequence[str] pytest_args=("tests",), str|Path data_file=".coverage", Sequence[str|Path] source=(), *, bool in_process=False, str|Path|None python_executable=None, dict[str, str]|None env=None)
Definition coverage.py:125