Source code for firecrown.fctools.coverage_summary

#!/usr/bin/env python
"""Comprehensive tool to analyze test coverage output in JSON format.

This tool provides a detailed analysis of test coverage including:
- Overall coverage summary
- Per-file coverage breakdown
- Detailed information about untested lines and branches
- Files with less than perfect coverage
"""

import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

import typer
from rich.console import Console

if TYPE_CHECKING:
    from .common import format_line_ranges, load_json_file
else:
    try:
        from .common import format_line_ranges, load_json_file
    except ImportError:  # pragma: no cover
        from common import format_line_ranges, load_json_file


[docs] @dataclass class CoverageSummary: """Summary statistics for coverage analysis.""" total_files: int = 0 files_with_perfect_coverage: int = 0 files_with_missing_lines: int = 0 files_with_missing_branches: int = 0 files_with_excluded_lines: int = 0 overall_line_coverage: float = 0.0 overall_branch_coverage: float = 0.0 total_statements: int = 0 total_missing_lines: int = 0 total_excluded_lines: int = 0 total_branches: int = 0 total_missing_branches: int = 0
[docs] @dataclass class FileIssue: """Information about coverage issues in a specific file.""" file_path: str line_coverage: float branch_coverage: float missing_lines_count: int missing_branches_count: int excluded_lines_count: int missing_lines: list[int] missing_branches: list[list[int]] excluded_lines: list[int] total_statements: int total_branches: int
def _calculate_branch_coverage_summary( totals: dict[str, Any], ) -> tuple[int, int, float]: """Calculate overall branch coverage statistics.""" total_branches = totals.get("num_branches", 0) missing_branches = totals.get("missing_branches", 0) if total_branches > 0: overall_branch_coverage = ( (total_branches - missing_branches) / total_branches ) * 100 else: overall_branch_coverage = 100.0 return total_branches, missing_branches, overall_branch_coverage def _calculate_file_branch_coverage( file_summary: dict[str, Any], total_statements: int ) -> float: """Calculate branch coverage for a single file.""" total_branches = file_summary.get("num_branches", 0) if total_branches > 0: covered_branches = file_summary.get("covered_branches", 0) return (covered_branches / total_branches) * 100 return 100.0 if total_statements > 0 else 0.0 def _create_file_issue( file_path: str, file_summary: dict[str, Any], missing_lines: list[int], missing_branches: list[list[int]], excluded_lines: list[int], line_coverage: float, branch_coverage: float, ) -> FileIssue: """Create a FileIssue object for a file with coverage issues.""" return FileIssue( file_path=file_path, line_coverage=line_coverage, branch_coverage=branch_coverage, missing_lines_count=len(missing_lines), missing_branches_count=len(missing_branches), excluded_lines_count=len(excluded_lines), missing_lines=missing_lines, missing_branches=missing_branches, excluded_lines=excluded_lines, total_statements=file_summary.get("num_statements", 0), total_branches=file_summary.get("num_branches", 0), ) def _analyze_single_file( file_path: str, file_data: dict[str, Any], summary: CoverageSummary ) -> FileIssue | None: """Analyze a single file and update summary, return FileIssue if needed.""" summary.total_files += 1 file_summary = file_data.get("summary", {}) missing_lines = file_data.get("missing_lines", []) missing_branches = file_data.get("missing_branches", []) excluded_lines = file_data.get("excluded_lines", []) # Get file statistics total_statements = file_summary.get("num_statements", 0) line_coverage = file_summary.get("percent_covered", 0.0) branch_coverage = _calculate_file_branch_coverage(file_summary, total_statements) # Check if file has perfect coverage has_missing_lines = len(missing_lines) > 0 has_missing_branches = len(missing_branches) > 0 has_excluded_lines = len(excluded_lines) > 0 if has_missing_lines: summary.files_with_missing_lines += 1 if has_missing_branches: summary.files_with_missing_branches += 1 if has_excluded_lines: summary.files_with_excluded_lines += 1 if not has_missing_lines and not has_missing_branches: summary.files_with_perfect_coverage += 1 # Return FileIssue if there are coverage issues (missing lines/branches) # OR if there are excluded lines to report (for informational purposes) if has_missing_lines or has_missing_branches or has_excluded_lines: return _create_file_issue( file_path, file_summary, missing_lines, missing_branches, excluded_lines, line_coverage, branch_coverage, ) return None
[docs] def analyze_coverage_json( console: Console, coverage_file: Path ) -> tuple[CoverageSummary, list[FileIssue]]: """Analyze coverage data from a JSON file.""" coverage_data = load_json_file(console, coverage_file, "coverage analysis") files_data = coverage_data.get("files", {}) totals = coverage_data.get("totals", {}) summary = CoverageSummary() # Set basic totals summary.total_statements = totals.get("num_statements", 0) summary.total_missing_lines = totals.get("missing_lines", 0) summary.total_excluded_lines = totals.get("excluded_lines", 0) summary.overall_line_coverage = totals.get("percent_covered", 0.0) # Calculate branch coverage from totals branch_data = _calculate_branch_coverage_summary(totals) total_branches, missing_branches, overall_branch_coverage = branch_data summary.total_branches = total_branches summary.total_missing_branches = missing_branches summary.overall_branch_coverage = overall_branch_coverage # Analyze each file file_issues = [] for file_path, file_data in files_data.items(): file_issue = _analyze_single_file(file_path, file_data, summary) if file_issue: file_issues.append(file_issue) # Sort file issues by line coverage (worst first) file_issues.sort(key=lambda x: (x.line_coverage, x.branch_coverage)) return summary, file_issues
def _print_source_code_for_missing_lines(console: Console, issue: FileIssue) -> None: """Print source code for missing lines if file exists.""" try: source_file = Path(issue.file_path) if source_file.exists(): with open(source_file, encoding="utf-8") as f: lines = f.readlines() console.print(" [cyan]Source code for missing lines:[/cyan]") for line_num in sorted(issue.missing_lines): if 1 <= line_num <= len(lines): line_content = lines[line_num - 1].rstrip() console.print(f" [red]{line_num:4d}: {line_content}[/red]") else: console.print( " [yellow](Source file not found for line details)[/yellow]" ) except (OSError, UnicodeDecodeError) as e: console.print(f" [red](Error reading source file: {e})[/red]") def _print_file_issue_details( console: Console, issue: FileIssue, show_source: bool ) -> None: """Print detailed coverage information for a single file.""" # Show coverage percentages covered_statements = issue.total_statements - issue.missing_lines_count console.print( f" Line Coverage: [bold]{issue.line_coverage:.1f}%[/bold] " f"({covered_statements}/{issue.total_statements} statements)" ) if issue.total_branches > 0: covered_branches = issue.total_branches - issue.missing_branches_count console.print( f" Branch Coverage: [bold]{issue.branch_coverage:.1f}%[/bold] " f"({covered_branches}/{issue.total_branches} branches)" ) # Show missing lines if issue.missing_lines: lines_str = format_line_ranges(issue.missing_lines) console.print( f" Missing Lines ({issue.missing_lines_count}): [red]{lines_str}[/red]" ) if show_source: _print_source_code_for_missing_lines(console, issue) # Show missing branches if issue.missing_branches: branches_count = issue.missing_branches_count console.print(f" Missing Branches ({branches_count}):") for branch in issue.missing_branches: console.print(f" [yellow]{branch}[/yellow]") # Show excluded lines if issue.excluded_lines: lines_str = format_line_ranges(issue.excluded_lines) console.print( f" Excluded Lines ({issue.excluded_lines_count}): [dim]{lines_str}[/dim]" ) app = typer.Typer()
[docs] @app.command() def main( coverage_file: Path = typer.Argument( ..., exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True, help="Path to the coverage JSON file to analyze", ), show_source: bool = typer.Option( False, "--show-source", help="Show source code for missing lines" ), show_perfect: bool = typer.Option( False, "--show-perfect", help="Show files with perfect coverage" ), ) -> None: """Analyze test coverage output in JSON format. This tool provides a detailed analysis of test coverage including overall coverage summary, per-file breakdown, detailed information about untested lines and branches, and files with imperfect coverage. """ console = Console() try: summary, file_issues = analyze_coverage_json(console, coverage_file) print_coverage_summary(console, summary) print_file_issues(console, file_issues, show_source=show_source) if show_perfect: # Get all file paths from the original data coverage_data = load_json_file(console, coverage_file, "coverage analysis") all_files = list(coverage_data.get("files", {}).keys()) print_perfect_coverage_files(console, file_issues, all_files) except OSError as e: console.print(f"[bold red]Error analyzing coverage file: {e}[/bold red]") sys.exit(1)
if __name__ == "__main__": # pragma: no cover app()