aboutsummaryrefslogtreecommitdiff
path: root/languages/python/claude/scripts/coverage-summary.py
diff options
context:
space:
mode:
Diffstat (limited to 'languages/python/claude/scripts/coverage-summary.py')
-rwxr-xr-xlanguages/python/claude/scripts/coverage-summary.py157
1 files changed, 157 insertions, 0 deletions
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:]))