PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
python_coverage_gate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Run pytest under stdlib trace and enforce a line-coverage threshold."""
3
4from __future__ import annotations
5
6import argparse
7import os
8import sys
9import trace
10from pathlib import Path
11
12
13REPO_ROOT = Path(__file__).resolve().parents[2]
14DEFAULT_TARGETS = [
15 "picurv_cli/core.py",
16 "picurv_cli/cli.py",
17]
18
19
20def normalize_path(path: str | Path) -> str:
21 """!
22 @brief Normalize path.
23 @param[in] path Filesystem path argument passed to `normalize_path()`.
24 @return Value returned by `normalize_path()`.
25 """
26 return str(Path(path).resolve())
27
28
29def parse_args() -> argparse.Namespace:
30 """!
31 @brief Parse args.
32 @return Value returned by `parse_args()`.
33 """
34 parser = argparse.ArgumentParser(
35 description=__doc__,
36 formatter_class=argparse.RawDescriptionHelpFormatter,
37 epilog=(
38 "Examples:\n"
39 " python3 tests/tooling/python_coverage_gate.py\n"
40 " python3 tests/tooling/python_coverage_gate.py --target picurv_cli/core.py --target generators/grid.gen\n"
41 " python3 tests/tooling/python_coverage_gate.py --pytest-args -- -q tests/test_cli_smoke.py\n"
42 ),
43 )
44 parser.add_argument(
45 "--min-line",
46 type=float,
47 default=70.0,
48 help="Minimum required weighted line coverage percent (default: 70.0).",
49 )
50 parser.add_argument(
51 "--target",
52 action="append",
53 default=[],
54 help=(
55 "Repository-relative file to include in coverage computation "
56 "(repeatable). Defaults to core runtime scripts."
57 ),
58 )
59 parser.add_argument(
60 "--output-dir",
61 default="coverage/python",
62 help="Repository-relative output directory for coverage artifacts (default: coverage/python).",
63 )
64 parser.add_argument(
65 "--pytest-args",
66 nargs=argparse.REMAINDER,
67 default=["-q"],
68 help="Arguments passed to pytest (prefix with --pytest-args -- ...).",
69 )
70 return parser.parse_args()
71
72
73def build_trace_ignoredirs() -> list[str]:
74 """!
75 @brief Build trace ignoredirs.
76 @return Value returned by `build_trace_ignoredirs()`.
77 """
78 ignoredirs = {
79 normalize_path(sys.prefix),
80 normalize_path(sys.exec_prefix),
81 normalize_path(Path(sys.prefix) / "lib"),
82 }
83 for raw in list(sys.path):
84 if not raw:
85 continue
86 path = Path(raw)
87 if not path.exists():
88 continue
89 resolved = normalize_path(path)
90 if "/site-packages" in resolved or "/dist-packages" in resolved:
91 ignoredirs.add(resolved)
92 return sorted(ignoredirs)
93
94
95def collect_counts(results: trace.CoverageResults) -> dict[str, dict[int, int]]:
96 """!
97 @brief Collect counts.
98 @param[in] results Argument passed to `collect_counts()`.
99 @return Value returned by `collect_counts()`.
100 """
101 counts_by_file: dict[str, dict[int, int]] = {}
102 for (filename, lineno), count in results.counts.items():
103 file_key = normalize_path(filename)
104 counts_by_file.setdefault(file_key, {})[lineno] = count
105 return counts_by_file
106
107
108def compute_file_coverage(target: Path, counts_by_file: dict[str, dict[int, int]]) -> tuple[int, int, float]:
109 """!
110 @brief Compute file coverage.
111 @param[in] target Argument passed to `compute_file_coverage()`.
112 @param[in] counts_by_file Argument passed to `compute_file_coverage()`.
113 @return Value returned by `compute_file_coverage()`.
114 """
115 finder = getattr(trace, "find_executable_linenos", None)
116 if finder is None:
117 finder = trace._find_executable_linenos # type: ignore[attr-defined]
118 executable = finder(str(target))
119 executable_lines = set(executable.keys())
120 total = len(executable_lines)
121 if total == 0:
122 return 0, 0, 100.0
123
124 observed = counts_by_file.get(normalize_path(target), {})
125 covered = sum(1 for line in executable_lines if observed.get(line, 0) > 0)
126 percent = (100.0 * covered) / total
127 return covered, total, percent
128
129
130def main() -> int:
131 """!
132 @brief Entry point for this script.
133 @return Value returned by `main()`.
134 """
135 args = parse_args()
136 output_dir = (REPO_ROOT / args.output_dir).resolve()
137 output_dir.mkdir(parents=True, exist_ok=True)
138
139 targets_raw = args.target if args.target else DEFAULT_TARGETS
140 targets = [Path(REPO_ROOT / rel).resolve() for rel in targets_raw]
141 for target in targets:
142 if not target.exists():
143 raise SystemExit(f"[coverage-python] target not found: {target}")
144
145 pytest_args = list(args.pytest_args)
146 if pytest_args and pytest_args[0] == "--":
147 pytest_args = pytest_args[1:]
148 if not pytest_args:
149 pytest_args = ["-q"]
150
151 ignoredirs = build_trace_ignoredirs()
152
153 import pytest
154
155 tracer = trace.Trace(count=True, trace=False, ignoredirs=ignoredirs)
156 exit_code = tracer.runfunc(pytest.main, pytest_args)
157 results = tracer.results()
158
159 counts_by_file = collect_counts(results)
160
161 print("[coverage-python] per-file line coverage")
162 print("[coverage-python] -----------------------------------------------")
163
164 total_cov = 0
165 total_exec = 0
166 for target in targets:
167 covered, executable, percent = compute_file_coverage(target, counts_by_file)
168 total_cov += covered
169 total_exec += executable
170 rel = target.relative_to(REPO_ROOT)
171 print(f"[coverage-python] {rel}: {covered}/{executable} ({percent:.2f}%)")
172
173 overall = 100.0 if total_exec == 0 else (100.0 * total_cov) / total_exec
174 print("[coverage-python] -----------------------------------------------")
175 print(f"[coverage-python] weighted total: {total_cov}/{total_exec} ({overall:.2f}%)")
176 print(f"[coverage-python] minimum required: {args.min_line:.2f}%")
177
178 summary_path = output_dir / "summary.txt"
179 summary_path.write_text(
180 "\n".join(
181 [
182 f"weighted_total={overall:.4f}",
183 f"covered_lines={total_cov}",
184 f"executable_lines={total_exec}",
185 f"minimum_required={args.min_line:.4f}",
186 ]
187 )
188 + "\n",
189 encoding="utf-8",
190 )
191
192 if int(exit_code) != 0:
193 print(f"[coverage-python] pytest failed with exit code {exit_code}.", file=sys.stderr)
194 return int(exit_code)
195 if overall < args.min_line:
196 print(
197 f"[coverage-python] FAIL: coverage {overall:.2f}% is below required {args.min_line:.2f}%.",
198 file=sys.stderr,
199 )
200 return 2
201 return 0
202
203
204if __name__ == "__main__":
205 raise SystemExit(main())
tuple[int, int, float] compute_file_coverage(Path target, dict[str, dict[int, int]] counts_by_file)
Compute file coverage.
list[str] build_trace_ignoredirs()
Build trace ignoredirs.
dict[str, dict[int, int]] collect_counts(trace.CoverageResults results)
Collect counts.
str normalize_path(str|Path path)
Normalize path.
argparse.Namespace parse_args()
Parse args.
int main()
Entry point for this script.
Head of a generic C-style linked list.
Definition variables.h:443