annator-command-center / tests /scripts /enforce_coverage.py
techprotrade's picture
Deploy ATOM FastAPI command center runtime (part 9)
81e3673 verified
Raw
History Blame Contribute Delete
9.47 kB
#!/usr/bin/env python3
"""
Coverage Enforcement Script for Pre-commit Hooks
Enforces 75% minimum coverage on new code and prevents coverage regression.
Supports standalone execution, PR validation, and pre-commit hook integration.
Usage:
python enforce_coverage.py --help
python enforce_coverage.py --minimum 75 --files-only
python enforce_coverage.py --verbose
"""
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
@dataclass
class CoverageResult:
"""Coverage check result for a single file."""
filename: str
line_coverage: float
branch_coverage: float
lines_covered: int
lines_total: int
branches_covered: int
branches_total: int
is_new: bool
is_modified: bool
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Enforce coverage thresholds on changed Python files"
)
parser.add_argument(
"--minimum",
type=float,
default=75.0,
help="Minimum coverage percentage for new code (default: 75.0)"
)
parser.add_argument(
"--files-only",
action="store_true",
help="Only check changed files, not full coverage"
)
parser.add_argument(
"--staged",
action="store_true",
help="Check staged files instead of all changed files"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed coverage information"
)
parser.add_argument(
"--json",
action="store_true",
help="Output results in JSON format"
)
parser.add_argument(
"--coverage-json",
type=Path,
default=Path("tests/coverage_reports/metrics/coverage.json"),
help="Path to coverage.json file"
)
return parser.parse_args()
def get_changed_files(staged: bool = False) -> List[str]:
"""
Get list of changed Python files using git diff.
Args:
staged: If True, check staged files. If False, check all changed files.
Returns:
List of changed Python filenames
"""
cmd = ["git", "diff", "--name-only"]
if staged:
cmd.append("--staged")
# Add HEAD to compare against working tree
if not staged:
cmd.append("HEAD")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
files = result.stdout.strip().split("\n")
# Filter for Python files only
return [f for f in files if f.endswith(".py") and f]
except subprocess.CalledProcessError:
return []
def run_coverage(files: Optional[List[str]] = None) -> dict:
"""
Run pytest with coverage and parse coverage.json output.
Args:
files: List of specific files to check. If None, checks all files.
Returns:
Coverage data dictionary from coverage.json
"""
# Build pytest command
cmd = [
"python", "-m", "pytest",
"-q",
"--cov=.",
"--cov-report=json",
"--cov-fail-under=0", # Don't fail on threshold, just calculate
]
# Add specific files if provided
if files:
cmd.extend(files)
try:
# Run pytest with coverage
subprocess.run(
cmd,
capture_output=True,
text=True,
check=False
)
# Read coverage.json output
coverage_path = Path("tests/coverage_reports/metrics/coverage.json")
if coverage_path.exists():
with open(coverage_path, "r") as f:
return json.load(f)
else:
print(f"Warning: coverage.json not found at {coverage_path}")
return {}
except Exception as e:
print(f"Error running coverage: {e}")
return {}
def check_coverage_thresholds(
coverage_data: dict,
minimum: float,
files_only: bool = False
) -> tuple[List[CoverageResult], bool]:
"""
Check coverage results against minimum thresholds.
Args:
coverage_data: Coverage data from coverage.json
minimum: Minimum coverage percentage (0-100)
files_only: If True, only check changed files
Returns:
Tuple of (list of CoverageResult objects, overall_pass_status)
"""
results = []
all_pass = True
if "files" not in coverage_data:
return results, False
for filename, file_data in coverage_data["files"].items():
summary = file_data.get("summary", {})
line_coverage = summary.get("percent_covered", 0.0)
branch_coverage = summary.get("percent_branches_covered", 0.0)
lines_covered = summary.get("covered_lines", 0)
lines_total = summary.get("num_statements", 0)
branches_covered = summary.get("covered_branches", 0)
branches_total = summary.get("num_branches", 0)
# Skip test files
if "tests/" in filename or filename.startswith("test_"):
continue
result = CoverageResult(
filename=filename,
line_coverage=line_coverage,
branch_coverage=branch_coverage,
lines_covered=lines_covered,
lines_total=lines_total,
branches_covered=branches_covered,
branches_total=branches_total,
is_new=False, # TODO: Detect new files via git
is_modified=False # TODO: Detect modified files via git
)
results.append(result)
# Check if file passes threshold
if line_coverage < minimum:
all_pass = False
return results, all_pass
def print_results(results: List[CoverageResult], minimum: float, verbose: bool = False):
"""
Print coverage results in human-readable format.
Args:
results: List of CoverageResult objects
minimum: Minimum coverage threshold
verbose: Print detailed information
"""
if not results:
print("No coverage data found.")
return
# Sort by coverage (lowest first)
results.sort(key=lambda r: r.line_coverage)
# Print failing files first
failing = [r for r in results if r.line_coverage < minimum]
passing = [r for r in results if r.line_coverage >= minimum]
if failing:
print("\n❌ Coverage Enforcement FAILED")
print(f"{'File':<60} {'Line Cov':>10} {'Branch Cov':>12} {'Status':>10}")
print("-" * 100)
for result in failing:
status = "FAIL"
print(
f"{result.filename:<60} "
f"{result.line_coverage:>9.2f}% "
f"{result.branch_coverage:>11.2f}% "
f"{status:>10}"
)
if verbose:
print(
f" Lines: {result.lines_covered}/{result.lines_total} | "
f"Branches: {result.branches_covered}/{result.branches_total}"
)
if passing:
if failing:
print("\n✅ Passing files:")
else:
print("\n✅ Coverage Enforcement PASSED")
print(f"{'File':<60} {'Line Cov':>10} {'Branch Cov':>12} {'Status':>10}")
print("-" * 100)
for result in passing:
status = "PASS"
print(
f"{result.filename:<60} "
f"{result.line_coverage:>9.2f}% "
f"{result.branch_coverage:>11.2f}% "
f"{status:>10}"
)
# Print summary
total_lines = sum(r.lines_total for r in results)
total_covered = sum(r.lines_covered for r in results)
overall_coverage = (total_covered / total_lines * 100) if total_lines > 0 else 0
print("\n" + "=" * 100)
print(f"Overall: {overall_coverage:.2f}% coverage ({total_covered}/{total_lines} lines)")
print(f"Threshold: {minimum:.2f}% minimum required")
if failing:
print(f"\n{len(failing)} file(s) below threshold. Add tests to improve coverage.")
print("Run 'pytest tests/ --cov' to see full coverage report.")
def main():
"""Main entry point."""
args = parse_args()
# Get changed files if in files-only mode
changed_files = None
if args.files_only:
changed_files = get_changed_files(staged=args.staged)
if not changed_files:
print("No Python files changed. Skipping coverage check.")
return 0
# Run coverage analysis
coverage_data = run_coverage(changed_files)
if not coverage_data:
print("Error: Could not load coverage data")
return 1
# Check thresholds
results, all_pass = check_coverage_thresholds(
coverage_data,
args.minimum,
args.files_only
)
# Print results
if args.json:
output = [
{
"filename": r.filename,
"line_coverage": r.line_coverage,
"branch_coverage": r.branch_coverage,
"lines_covered": r.lines_covered,
"lines_total": r.lines_total,
"passes": r.line_coverage >= args.minimum
}
for r in results
]
print(json.dumps(output, indent=2))
else:
print_results(results, args.minimum, args.verbose)
# Return exit code
return 0 if all_pass else 1
if __name__ == "__main__":
sys.exit(main())