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