2"""Run pytest under stdlib trace and enforce a line-coverage threshold."""
4from __future__
import annotations
10from pathlib
import Path
13REPO_ROOT = Path(__file__).resolve().parents[2]
21 """Avoid stdlib trace's basename-only ignore cache for same-named modules."""
24 """! @brief Store normalized ignored directory roots. @param[in] ignoredirs Directory roots to ignore. """
25 self.
_ignoredirs = [os.path.normpath(path)
for path
in ignoredirs]
27 def names(self, filename, modulename: str) -> bool:
29 @brief Decide by full path whether a module is ignored.
30 @param[in] filename Source filename reported by the tracer.
31 @param[in] modulename Source module name reported by the tracer.
32 @return True when ignored.
37 normalized = os.path.normpath(filename)
38 return any(normalized.startswith(root + os.sep)
for root
in self.
_ignoredirs)
43 @brief Resolve a filesystem path to the canonical string used as a coverage-map key.
44 @param[in] path Filesystem path argument passed to `normalize_path()`.
45 @return Value returned by `normalize_path()`.
47 return str(Path(path).resolve())
52 @brief Parse coverage thresholds, targets, output location, and pytest arguments for this gate.
53 @return Value returned by `parse_args()`.
55 parser = argparse.ArgumentParser(
57 formatter_class=argparse.RawDescriptionHelpFormatter,
60 " python3 tests/tooling/python_coverage_gate.py\n"
61 " python3 tests/tooling/python_coverage_gate.py --target picurv_cli/core.py --target generators/grid.gen\n"
62 " python3 tests/tooling/python_coverage_gate.py --pytest-args -- -q tests/test_cli_smoke.py\n"
69 help=
"Minimum required weighted line coverage percent (default: 70.0).",
76 "Repository-relative file to include in coverage computation "
77 "(repeatable). Defaults to core runtime scripts."
82 default=
"coverage/python",
83 help=
"Repository-relative output directory for coverage artifacts (default: coverage/python).",
87 nargs=argparse.REMAINDER,
89 help=
"Arguments passed to pytest (prefix with --pytest-args -- ...).",
91 return parser.parse_args()
96 @brief Build trace ignoredirs.
97 @return Value returned by `build_trace_ignoredirs()`.
104 for raw
in list(sys.path):
108 if not path.exists():
111 if "/site-packages" in resolved
or "/dist-packages" in resolved:
112 ignoredirs.add(resolved)
113 return sorted(ignoredirs)
118 @brief Transform Python trace results into a per-file, per-line execution-count mapping.
119 @param[in] results Argument passed to `collect_counts()`.
120 @return Value returned by `collect_counts()`.
122 counts_by_file: dict[str, dict[int, int]] = {}
123 for (filename, lineno), count
in results.counts.items():
125 counts_by_file.setdefault(file_key, {})[lineno] = count
126 return counts_by_file
131 @brief Compute file coverage.
132 @param[in] target Argument passed to `compute_file_coverage()`.
133 @param[in] counts_by_file Argument passed to `compute_file_coverage()`.
134 @return Value returned by `compute_file_coverage()`.
136 finder = getattr(trace,
"find_executable_linenos",
None)
138 finder = trace._find_executable_linenos
139 executable = finder(str(target))
140 executable_lines = set(executable.keys())
141 total = len(executable_lines)
146 covered = sum(1
for line
in executable_lines
if observed.get(line, 0) > 0)
147 percent = (100.0 * covered) / total
148 return covered, total, percent
153 @brief Entry point for this script.
154 @return Value returned by `main()`.
157 output_dir = (REPO_ROOT / args.output_dir).resolve()
158 output_dir.mkdir(parents=
True, exist_ok=
True)
160 targets_raw = args.target
if args.target
else DEFAULT_TARGETS
161 targets = [Path(REPO_ROOT / rel).resolve()
for rel
in targets_raw]
162 for target
in targets:
163 if not target.exists():
164 raise SystemExit(f
"[coverage-python] target not found: {target}")
166 pytest_args =
list(args.pytest_args)
167 if pytest_args
and pytest_args[0] ==
"--":
168 pytest_args = pytest_args[1:]
176 tracer = trace.Trace(count=
True, trace=
False, ignoredirs=ignoredirs)
181 exit_code = tracer.runfunc(pytest.main, pytest_args)
182 results = tracer.results()
186 print(
"[coverage-python] per-file line coverage")
187 print(
"[coverage-python] -----------------------------------------------")
191 for target
in targets:
194 total_exec += executable
195 rel = target.relative_to(REPO_ROOT)
196 print(f
"[coverage-python] {rel}: {covered}/{executable} ({percent:.2f}%)")
198 overall = 100.0
if total_exec == 0
else (100.0 * total_cov) / total_exec
199 print(
"[coverage-python] -----------------------------------------------")
200 print(f
"[coverage-python] weighted total: {total_cov}/{total_exec} ({overall:.2f}%)")
201 print(f
"[coverage-python] minimum required: {args.min_line:.2f}%")
203 summary_path = output_dir /
"summary.txt"
204 summary_path.write_text(
207 f
"weighted_total={overall:.4f}",
208 f
"covered_lines={total_cov}",
209 f
"executable_lines={total_exec}",
210 f
"minimum_required={args.min_line:.4f}",
217 if int(exit_code) != 0:
218 print(f
"[coverage-python] pytest failed with exit code {exit_code}.", file=sys.stderr)
219 return int(exit_code)
220 if overall < args.min_line:
222 f
"[coverage-python] FAIL: coverage {overall:.2f}% is below required {args.min_line:.2f}%.",
229if __name__ ==
"__main__":
230 raise SystemExit(
main())
bool names(self, filename, str modulename)
Decide by full path whether a module is ignored.
__init__(self, list[str] ignoredirs)
Store normalized ignored directory roots.
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)
Transform Python trace results into a per-file, per-line execution-count mapping.
str normalize_path(str|Path path)
Resolve a filesystem path to the canonical string used as a coverage-map key.
argparse.Namespace parse_args()
Parse coverage thresholds, targets, output location, and pytest arguments for this gate.
int main()
Entry point for this script.
Head of a generic C-style linked list.