from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from xml.etree import ElementTree as ET
if TYPE_CHECKING:
from collections.abc import Iterable
DEFAULT_REPORT = Path("coverage/cobertura.xml")
@dataclass(frozen=True)
class CoverageEntry:
coverage: float
coverable: int
covered: int
path: Path
def format(self, relative_to: Path | None = None) -> str:
display_path = self.relative_path(relative_to)
return f"{self.coverage:6.2f}% {display_path}"
def relative_path(self, relative_to: Path | None) -> Path:
if relative_to is None:
return self.path
try:
return self.path.relative_to(relative_to)
except ValueError:
return self.path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Summarize Cobertura XML coverage report.")
parser.add_argument(
"--report",
type=Path,
default=DEFAULT_REPORT,
help="Path to Cobertura XML report (default: %(default)s).",
)
parser.add_argument(
"--prefix",
default="",
help=("Only include files whose (relative) path starts with this prefix. Use empty string to include all."),
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="Limit output to the N lowest-covered entries.",
)
parser.add_argument(
"--descending",
action="store_true",
help="Sort in descending order (default: ascending).",
)
return parser.parse_args()
def load_report(report_path: Path) -> ET.Element:
if not report_path.is_file():
raise SystemExit(f"Coverage report not found: {report_path}")
try:
return ET.parse(report_path).getroot() except ET.ParseError as exc:
raise SystemExit(f"Coverage report must be valid XML: {report_path}: {exc}") from exc
def coverage_entries(root: ET.Element) -> Iterable[CoverageEntry]:
for class_element in root.findall(".//class"):
raw_path = class_element.get("filename")
if not raw_path:
continue
lines = class_element.findall("./lines/line")
coverable = len(lines)
if not coverable:
continue
covered = sum(1 for line in lines if _line_hits(line, raw_path) > 0)
path = Path(raw_path)
coverage = (covered / coverable) * 100
yield CoverageEntry(coverage=coverage, coverable=coverable, covered=covered, path=path)
def _line_hits(line: ET.Element, raw_path: str) -> int:
raw_hits = line.get("hits", "0")
try:
return int(raw_hits)
except ValueError as exc:
line_number = line.get("number", "unknown")
raise SystemExit(f"Coverage report has non-integer hits value for {raw_path}:{line_number}: {raw_hits!r}") from exc
def filter_entries(
entries: Iterable[CoverageEntry],
prefix: str,
relative_to: Path,
) -> list[CoverageEntry]:
if not prefix:
return list(entries)
normalized_prefix = prefix if prefix.endswith("/") else f"{prefix}/"
filtered: list[CoverageEntry] = []
for entry in entries:
relative = entry.relative_path(relative_to)
relative_str = relative.as_posix()
if relative_str.startswith(normalized_prefix):
filtered.append(entry)
return filtered
def main() -> None:
args = parse_args()
data = load_report(args.report)
repo_root = Path(__file__).resolve().parent.parent
entries = list(coverage_entries(data))
filtered = filter_entries(entries, args.prefix, repo_root)
if not filtered:
prefix_message = f" with prefix '{args.prefix}'" if args.prefix else ""
print(f"No coverable files found{prefix_message}.")
return
sorted_entries = sorted(filtered, key=lambda item: item.coverage, reverse=args.descending)
if args.limit is not None:
sorted_entries = sorted_entries[: args.limit]
for entry in sorted_entries:
print(entry.format(relative_to=repo_root))
if __name__ == "__main__":
main()