annator-command-center / tests /scripts /coverage_report_generator.py
techprotrade's picture
Deploy ATOM FastAPI command center runtime (part 9)
81e3673 verified
Raw
History Blame Contribute Delete
9.77 kB
#!/usr/bin/env python3
"""
Enhanced Coverage Report Generator
Generates comprehensive coverage reports with HTML drill-down, branch coverage
visualization, and actionable insights for improving test coverage.
Usage:
python backend/tests/scripts/coverage_report_generator.py [--modules MODULES] [--threshold N] [--output FILE]
Examples:
python backend/tests/scripts/coverage_report_generator.py
python backend/tests/scripts/coverage_report_generator.py --modules core api
python backend/tests/scripts/coverage_report_generator.py --threshold 60 --output coverage_summary.txt
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
def run_coverage_tests(modules: Optional[List[str]] = None) -> bool:
"""
Run pytest with coverage and generate all report formats.
Args:
modules: List of module names to test (e.g., ['core', 'api'])
Returns:
True if tests passed, False otherwise
"""
cmd = [
"python", "-m", "pytest",
"tests/",
"-v",
"--cov=.",
"--cov-report=html",
"--cov-report=json",
"--cov-report=term-missing",
]
if modules:
# Add specific module coverage
for module in modules:
cmd.append(f"--cov={module}")
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=Path(__file__).parent.parent)
return result.returncode == 0
def parse_coverage_json(json_path: Path) -> Dict[str, Any]:
"""
Parse coverage.json file and extract metrics.
Args:
json_path: Path to coverage.json
Returns:
Parsed coverage data dictionary
"""
if not json_path.exists():
raise FileNotFoundError(f"Coverage JSON not found: {json_path}")
with open(json_path, 'r') as f:
coverage = json.load(f)
return coverage
def find_low_coverage_files(coverage: Dict[str, Any], threshold: int = 50) -> List[Tuple[str, float, int]]:
"""
Identify files with coverage below threshold.
Args:
coverage: Coverage data dictionary
threshold: Coverage percentage threshold (default: 50%)
Returns:
List of (file_path, coverage_percent, missing_lines) tuples sorted by coverage
"""
low_coverage = []
for file_path, file_data in coverage.get("files", {}).items():
summary = file_data.get("summary", {})
percent = summary.get("percent_covered", 0)
missing = summary.get("missing_lines", 0)
if percent < threshold:
low_coverage.append((file_path, percent, missing))
# Sort by coverage percentage (ascending)
return sorted(low_coverage, key=lambda x: x[1])
def find_untested_modules(coverage: Dict[str, Any]) -> List[str]:
"""
Find modules with zero coverage.
Args:
coverage: Coverage data dictionary
Returns:
List of module paths with 0% coverage
"""
untested = []
for file_path, file_data in coverage.get("files", {}).items():
summary = file_data.get("summary", {})
percent = summary.get("percent_covered", 0)
if percent == 0:
untested.append(file_path)
return sorted(untested)
def calculate_branch_gap(coverage: Dict[str, Any]) -> Tuple[int, int, int]:
"""
Calculate branch coverage gap.
Args:
coverage: Coverage data dictionary
Returns:
Tuple of (covered_branches, total_branches, gap)
"""
totals = coverage.get("totals", {})
covered = totals.get("covered_branches", 0)
total = totals.get("num_branches", 0)
gap = total - covered
return covered, total, gap
def generate_summary_report(coverage: Dict[str, Any], threshold: int = 50) -> str:
"""
Generate formatted summary report.
Args:
coverage: Coverage data dictionary
threshold: Coverage threshold for low-coverage files
Returns:
Formatted report string
"""
lines = []
# Overall coverage
totals = coverage.get("totals", {})
line_coverage = totals.get("percent_covered", 0)
covered_lines = totals.get("covered_lines", 0)
total_lines = totals.get("num_statements", 0)
missing_lines = totals.get("missing_lines", 0)
lines.append("=" * 80)
lines.append("COVERAGE SUMMARY REPORT")
lines.append("=" * 80)
lines.append("")
# Overall metrics
lines.append("OVERALL COVERAGE")
lines.append("-" * 40)
lines.append(f" Line Coverage: {line_coverage:.2f}%")
lines.append(f" Lines Covered: {covered_lines:,} / {total_lines:,}")
lines.append(f" Lines Missing: {missing_lines:,}")
lines.append("")
# Branch coverage
covered_branches, total_branches, branch_gap = calculate_branch_gap(coverage)
if total_branches > 0:
branch_percent = (covered_branches / total_branches) * 100
lines.append("BRANCH COVERAGE")
lines.append("-" * 40)
lines.append(f" Branch Coverage: {branch_percent:.2f}%")
lines.append(f" Branches Covered: {covered_branches} / {total_branches}")
lines.append(f" Branch Gap: {branch_gap} missing branches")
lines.append("")
# Low coverage files
low_coverage_files = find_low_coverage_files(coverage, threshold)
if low_coverage_files:
lines.append(f"MODULES BELOW {threshold}% COVERAGE")
lines.append("-" * 40)
# Group by module
by_module = {}
for file_path, percent, missing in low_coverage_files:
# Extract module name
parts = file_path.split("/")
module = parts[1] if len(parts) > 1 else "root"
if module not in by_module:
by_module[module] = []
by_module[module].append((file_path, percent, missing))
# Sort modules by worst coverage
for module in sorted(by_module.keys()):
lines.append(f"\n {module.upper()}/")
for file_path, percent, missing in sorted(by_module[module], key=lambda x: x[1]):
short_path = file_path.replace("backend/", "")
lines.append(f" {percent:5.2f}% {missing:4d} missing {short_path}")
lines.append("")
# Untested modules
untested = find_untested_modules(coverage)
if untested:
lines.append(f"UNTESTED MODULES (0% coverage)")
lines.append("-" * 40)
for file_path in untested[:20]: # Limit to 20
short_path = file_path.replace("backend/", "")
lines.append(f" {short_path}")
if len(untested) > 20:
lines.append(f" ... and {len(untested) - 20} more")
lines.append("")
# Top files with most uncovered lines
all_files = []
for file_path, file_data in coverage.get("files", {}).items():
summary = file_data.get("summary", {})
missing = summary.get("missing_lines", 0)
if missing > 0:
all_files.append((file_path, missing))
# Sort by missing lines
top_missing = sorted(all_files, key=lambda x: x[1], reverse=True)[:10]
if top_missing:
lines.append("TOP 10 FILES WITH MOST UNCOVERED LINES")
lines.append("-" * 40)
for i, (file_path, missing) in enumerate(top_missing, 1):
short_path = file_path.replace("backend/", "")
lines.append(f" {i:2d}. {missing:4d} missing {short_path}")
lines.append("")
lines.append("=" * 80)
lines.append("")
return "\n".join(lines)
def main():
"""Main execution entry point."""
parser = argparse.ArgumentParser(
description="Generate enhanced coverage reports with actionable insights"
)
parser.add_argument(
"--modules",
nargs="*",
default=None,
help="Modules to test (e.g., core api). Default: all modules"
)
parser.add_argument(
"--threshold",
type=int,
default=50,
help="Coverage threshold for low-coverage warning (default: 50%%)"
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Output file for summary report (default: stdout)"
)
parser.add_argument(
"--skip-run",
action="store_true",
help="Skip running tests, only parse existing coverage.json"
)
args = parser.parse_args()
# Change to backend directory
backend_dir = Path(__file__).parent.parent
coverage_json = backend_dir / "coverage_reports" / "metrics" / "coverage.json"
htmlcov_dir = backend_dir / "coverage_reports" / "html"
# Run tests with coverage
if not args.skip_run:
print("Running coverage tests...")
success = run_coverage_tests(args.modules)
if not success:
print("\nWarning: Tests failed, but generating report from existing coverage data...")
else:
print("Skipping test run, parsing existing coverage...")
# Parse coverage data
try:
coverage = parse_coverage_json(coverage_json)
except FileNotFoundError as e:
print(f"Error: {e}")
print("\nPlease run tests first or remove --skip-run flag")
return 1
# Generate summary report
report = generate_summary_report(coverage, args.threshold)
# Output report
if args.output:
output_path = Path(args.output)
with open(output_path, 'w') as f:
f.write(report)
print(f"\nReport written to: {output_path}")
else:
print("\n" + report)
# Report locations
print("Coverage reports generated:")
print(f" - HTML Report: file://{htmlcov_dir}/index.html")
print(f" - JSON Metrics: {coverage_json}")
return 0
if __name__ == "__main__":
sys.exit(main())