diff options
Diffstat (limited to 'languages/python/claude')
| -rw-r--r-- | languages/python/claude/rules/python-testing.md | 16 | ||||
| -rwxr-xr-x | languages/python/claude/scripts/coverage-summary.py | 157 |
2 files changed, 173 insertions, 0 deletions
diff --git a/languages/python/claude/rules/python-testing.md b/languages/python/claude/rules/python-testing.md index 4edde35..dedcce4 100644 --- a/languages/python/claude/rules/python-testing.md +++ b/languages/python/claude/rules/python-testing.md @@ -82,6 +82,22 @@ generation in-process. See `testing.md` § Combinatorial Coverage for the general rule and when to skip. +## Measuring Coverage — `make coverage-summary` + +The bundle ships a coverage summary at `.claude/scripts/coverage-summary.py` +and a Makefile fragment (`coverage-makefile.txt`) with `coverage` and +`coverage-summary` targets. After `make coverage` runs the suite under +coverage.py and writes a JSON report, `make coverage-summary` prints a +file-weighted project number and the source files no test imported. + +The number to watch is that missing-file count. A module no test imports never +appears in coverage.py's report, so a line-weighted total skips it silently and +the suite looks healthier than it is. The summary counts every `*.py` under the +source dir that's absent from the report as 0%, so an untested module drags the +project number down where you can see it. It doesn't reimplement the per-file +table — `coverage report` already prints that. Copy the fragment's targets into +your own Makefile to adopt it; the bundle never edits your Makefile. + ## Mocking Guidelines ### Mock these (external boundaries): diff --git a/languages/python/claude/scripts/coverage-summary.py b/languages/python/claude/scripts/coverage-summary.py new file mode 100755 index 0000000..a869fff --- /dev/null +++ b/languages/python/claude/scripts/coverage-summary.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Whole-project coverage summary from a coverage.py JSON report. + +Batch helper for `make coverage-summary`. After `make coverage` writes a +coverage.py JSON report (`coverage json`), this prints a file-weighted project +number and the source files present on disk but absent from the report. + +The value here is the missing-file detection: a module no test imports never +appears in coverage.py's output, so a line-weighted total silently skips it and +the suite looks healthier than it is. This counts such a file as 0% and weights +the project number by file rather than by line, so untested modules stay +visible. coverage.py's own `coverage report` prints the per-file table, so this +deliberately doesn't reimplement that — it adds the accounting `report` lacks. + +Self-contained on the standard library (json only); it parses the report, it +does not import coverage. The coverage.py JSON shape it reads is: + { "files": { "<path>": { "summary": { "covered_lines": N, + "num_statements": M, ... } } } } +Paths are relative to the directory coverage ran in (the project root). + +CLI: + coverage-summary.py REPORT SOURCE_DIR [PROJECT_ROOT] + REPORT path to the coverage.py JSON report + SOURCE_DIR the package/source directory to account for + PROJECT_ROOT defaults to the current directory +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import NoReturn + + +def _die(msg: str) -> NoReturn: + print(f"coverage-summary: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def parse_report(report_path) -> dict: + """Parse a coverage.py JSON report into {abspath: (covered, total)}. + + COVERED is the file's covered_lines, TOTAL its num_statements. coverage.py + records paths relative to the directory it ran in, which is where the report + is written, so relative paths resolve against the report's parent. Exits with + a clear message when the report is missing or malformed. + """ + p = Path(report_path) + if not p.is_file(): + _die(f"coverage report not found: {p}") + base = p.resolve().parent + try: + data = json.loads(p.read_text()) + except (ValueError, OSError) as e: + _die(f"malformed coverage JSON in {p}: {e}") + result: dict = {} + for path, entry in (data.get("files") or {}).items(): + summary = entry.get("summary", {}) if isinstance(entry, dict) else {} + covered = int(summary.get("covered_lines", 0)) + total = int(summary.get("num_statements", 0)) + fp = Path(path) + abspath = fp.resolve() if fp.is_absolute() else (base / fp).resolve() + result[str(abspath)] = (covered, total) + return result + + +def under_dir(table: dict, source_dir, project_root) -> dict: + """Filter TABLE to files under SOURCE_DIR, re-keyed relative to PROJECT_ROOT.""" + source_dir = Path(source_dir).resolve() + project_root = Path(project_root).resolve() + result: dict = {} + for abspath, counts in table.items(): + ap = Path(abspath) + if source_dir == ap or source_dir in ap.parents: + result[str(ap.relative_to(project_root))] = counts + return result + + +def source_files(source_dir, project_root) -> list: + """Return *.py files under SOURCE_DIR (recursive), relative to PROJECT_ROOT. + + Packages nest, so this recurses; __pycache__ is skipped. The caller scopes + coverage by choosing SOURCE_DIR. + """ + source_dir = Path(source_dir).resolve() + project_root = Path(project_root).resolve() + out = [] + for p in source_dir.rglob("*.py"): + if "__pycache__" in p.parts: + continue + out.append(str(p.resolve().relative_to(project_root))) + return sorted(out) + + +def missing(tracked, source_dir, project_root) -> list: + """Source files on disk but absent from TRACKED (the report's keys).""" + tracked_set = set(tracked) + return [f for f in source_files(source_dir, project_root) if f not in tracked_set] + + +def file_pct(covered: int, total: int) -> float: + """COVERED/TOTAL as a percentage. A file with no statements is 100%.""" + return (100.0 * covered / total) if total > 0 else 100.0 + + +def project_pct(report_path, source_dir, project_root) -> float: + """File-weighted project coverage: every tracked file contributes its own + percentage, every source file missing from the report contributes 0%, and + the result is the mean over all files under SOURCE_DIR.""" + tracked = under_dir(parse_report(report_path), source_dir, project_root) + miss = missing(list(tracked), source_dir, project_root) + total_count = len(tracked) + len(miss) + if total_count == 0: + return 0.0 + score = sum(file_pct(c, t) for c, t in tracked.values()) + return score / total_count + + +def summary_text(report_path, source_dir, project_root) -> str: + """Render the project number and the missing-file list (not the per-file + table — `coverage report` already prints that).""" + tracked = under_dir(parse_report(report_path), source_dir, project_root) + miss = missing(list(tracked), source_dir, project_root) + pct = project_pct(report_path, source_dir, project_root) + rel_src = Path(source_dir).resolve().relative_to(Path(project_root).resolve()) + total = len(tracked) + len(miss) + lines = [ + f"Coverage summary for {rel_src}", + "", + f"Project coverage: {pct:.1f}% ({len(tracked)} tracked, {len(miss)} " + f"missing, {total} total; missing files count as 0%)", + "", + f"Not in coverage report: {len(miss)} file{'' if len(miss) == 1 else 's'}", + ] + if miss: + lines.append("These files had no coverage entry; they count as 0% in project coverage.") + lines += [f" {m}" for m in sorted(miss)] + else: + lines.append("Every source file appears in the coverage report.") + lines.append("") + lines.append("(Per-file table: run `coverage report`.)") + return "\n".join(lines) + "\n" + + +def main(argv) -> int: + if not 2 <= len(argv) <= 3: + print("usage: coverage-summary.py REPORT SOURCE_DIR [PROJECT_ROOT]", file=sys.stderr) + return 2 + report, source_dir = argv[0], argv[1] + project_root = argv[2] if len(argv) == 3 else "." + print() + print(summary_text(report, source_dir, project_root), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) |
