Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

386 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Generate code coverage reports using gcovr.
Runs gcovr on a build directory instrumented with --coverage flags,
producing XML (Cobertura), HTML, and Markdown reports.
Usage:
python3 scripts/generate-coverage.py --build-dir build/coverage --source-dir .
# Compare against a baseline:
python3 scripts/generate-coverage.py --build-dir build/coverage --source-dir . \
--baseline .codecov/baseline.json
# Custom output directory:
python3 scripts/generate-coverage.py --build-dir build/coverage --source-dir . \
--output-dir .codecov --label all
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
GCOVR_EXCLUDE_PATTERNS = [
r"/usr/.*",
r"/opt/.*",
r".*external/.*",
r".*examples/.*",
r".*tests/.*",
r".*/googletest/.*",
]
def find_tool(name: str, required: bool = True) -> Optional[str]:
path = shutil.which(name)
if required and path is None:
print(f"ERROR: {name} not found in PATH", file=sys.stderr)
sys.exit(1)
return path
def run_gcovr(
*,
gcov_cmd: str,
source_dir: Path,
build_dir: Path,
output_dir: Path,
label: str,
) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / f"{label}.json"
xml_path = output_dir / f"{label}.xml"
html_path = output_dir / f"{label}.html"
gcovr_cmd = [sys.executable, "-m", "gcovr"]
cmd = [
*gcovr_cmd,
"--root",
str(source_dir),
"--gcov-executable",
gcov_cmd,
"--exclude-unreachable-branches",
"--exclude-throw-branches",
"--gcov-ignore-parse-errors",
"--merge-lines",
"--merge-mode-functions=merge-use-line-max",
"-s",
"-p",
"--json",
str(json_path),
"--xml",
str(xml_path),
"--html-details",
str(html_path),
]
for pattern in GCOVR_EXCLUDE_PATTERNS:
cmd.extend(["--exclude", pattern])
cmd.append(str(build_dir))
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.stdout:
print(result.stdout)
if result.returncode != 0:
print(f"gcovr stderr:\n{result.stderr}", file=sys.stderr)
sys.exit(1)
return json_path
def load_coverage_json(path: Path) -> dict:
with open(path) as f:
return json.load(f)
def compute_file_coverage(data: dict) -> list[dict]:
files = []
for file_data in data.get("files", []):
filename = file_data.get("filename", "") or file_data.get("file", "")
if not filename:
continue
lines = file_data.get("lines", [])
if not lines:
continue
line_counts: dict[int, int] = {}
for line in lines:
ln = line.get("line_number", 0)
count = line.get("count", 0)
if ln not in line_counts or count > line_counts[ln]:
line_counts[ln] = count
covered = sum(1 for c in line_counts.values() if c > 0)
total = len(line_counts)
pct = (covered / total * 100) if total > 0 else 0.0
func_by_line: dict[int, bool] = {}
for func in file_data.get("functions", []):
lineno = func.get("lineno", 0)
ran = func.get("execution_count", 0) > 0
func_by_line[lineno] = func_by_line.get(lineno, False) or ran
func_covered = sum(1 for v in func_by_line.values() if v)
func_total = len(func_by_line)
func_pct = (func_covered / func_total * 100) if func_total > 0 else 0.0
files.append(
{
"filename": filename,
"covered_lines": covered,
"total_lines": total,
"coverage_pct": pct,
"covered_functions": func_covered,
"total_functions": func_total,
"function_pct": func_pct,
}
)
return sorted(files, key=lambda f: f["coverage_pct"])
def compute_totals(file_coverages: list[dict]) -> dict:
total_covered = sum(f["covered_lines"] for f in file_coverages)
total_lines = sum(f["total_lines"] for f in file_coverages)
pct = (total_covered / total_lines * 100) if total_lines > 0 else 0.0
total_func_covered = sum(f.get("covered_functions", 0) for f in file_coverages)
total_funcs = sum(f.get("total_functions", 0) for f in file_coverages)
func_pct = (total_func_covered / total_funcs * 100) if total_funcs > 0 else 0.0
return {
"covered_lines": total_covered,
"total_lines": total_lines,
"coverage_pct": pct,
"file_count": len(file_coverages),
"covered_functions": total_func_covered,
"total_functions": total_funcs,
"function_pct": func_pct,
}
def coverage_bar(pct: float) -> str:
if pct >= 80:
icon = "🟢"
elif pct >= 50:
icon = "🟡"
else:
icon = "🔴"
return f"{icon} **{pct:.1f}%**"
def generate_markdown(
*,
label: str,
file_coverages: list[dict],
totals: dict,
baseline_totals: Optional[dict],
source_dir: Path,
) -> str:
lines = []
lines.append(f"## Code Coverage: {label}")
lines.append("")
delta_str = ""
if baseline_totals:
delta = totals["coverage_pct"] - baseline_totals["coverage_pct"]
sign = "+" if delta >= 0 else ""
emoji = "📈" if delta >= 0 else "📉"
delta_str = f" ({emoji} {sign}{delta:.2f}% vs base)"
lines.append(
f"**Lines**: {coverage_bar(totals['coverage_pct'])}{delta_str} "
f"— {totals['covered_lines']:,}/{totals['total_lines']:,} "
f"across {totals['file_count']} files"
)
lines.append(
f"**Functions**: {coverage_bar(totals['function_pct'])} "
f"— {totals['covered_functions']:,}/{totals['total_functions']:,}"
)
lines.append("")
if file_coverages:
groups = [
("🔴 0-20%", [f for f in file_coverages if f["coverage_pct"] < 20]),
("🟠 20-50%", [f for f in file_coverages if 20 <= f["coverage_pct"] < 50]),
("🟡 50-80%", [f for f in file_coverages if 50 <= f["coverage_pct"] < 80]),
("🟢 80-100%", [f for f in file_coverages if f["coverage_pct"] >= 80]),
]
for group_label, group_files in groups:
if not group_files:
continue
lines.append(f"<details>")
lines.append(f"<summary>{group_label} ({len(group_files)} files)</summary>")
lines.append("")
lines.append("| Lines | Functions | File |")
lines.append("|-------|-----------|------|")
for f in group_files:
rel = os.path.relpath(f["filename"], source_dir)
func_str = (
f"{f['covered_functions']}/{f['total_functions']}"
if f.get("total_functions", 0) > 0
else "—"
)
lines.append(
f"| {f['coverage_pct']:5.1f}% {f['covered_lines']}/{f['total_lines']} | "
f"{func_str} | "
f"`{rel}` |"
)
lines.append("")
lines.append("</details>")
lines.append("")
buckets = {"0-20%": 0, "20-50%": 0, "50-80%": 0, "80-100%": 0}
for f in file_coverages:
p = f["coverage_pct"]
if p < 20:
buckets["0-20%"] += 1
elif p < 50:
buckets["20-50%"] += 1
elif p < 80:
buckets["50-80%"] += 1
else:
buckets["80-100%"] += 1
lines.append("### Distribution")
lines.append("")
lines.append("| Range | Files |")
lines.append("|-------|-------|")
for bucket, count in buckets.items():
lines.append(f"| {bucket} | {count} |")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Generate code coverage reports")
parser.add_argument(
"--build-dir",
required=True,
type=Path,
help="Build directory with .gcda/.gcno files",
)
parser.add_argument(
"--source-dir",
required=True,
type=Path,
help="Source directory root",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Output directory for reports (default: <source-dir>/.codecov)",
)
parser.add_argument(
"--label",
type=str,
default="all",
help="Coverage report label",
)
parser.add_argument(
"--baseline",
type=Path,
default=None,
help="Baseline coverage JSON for delta comparison",
)
parser.add_argument(
"--gcov",
type=str,
default=None,
help="Path to gcov executable",
)
args = parser.parse_args()
source_dir = args.source_dir.resolve()
build_dir = args.build_dir.resolve()
output_dir = (args.output_dir or source_dir / ".codecov").resolve()
gcov_cmd = args.gcov or find_tool("gcov")
gitignore_path = output_dir / ".gitignore"
if not gitignore_path.exists():
output_dir.mkdir(parents=True, exist_ok=True)
gitignore_path.write_text("/*\n")
print(f"Source dir: {source_dir}")
print(f"Build dir: {build_dir}")
print(f"Output dir: {output_dir}")
print(f"Label: {args.label}")
print(f"gcov: {gcov_cmd}")
print()
json_path = run_gcovr(
gcov_cmd=gcov_cmd,
source_dir=source_dir,
build_dir=build_dir,
output_dir=output_dir,
label=args.label,
)
data = load_coverage_json(json_path)
file_coverages = compute_file_coverage(data)
totals = compute_totals(file_coverages)
baseline_totals = None
if args.baseline and args.baseline.exists():
baseline_data = load_coverage_json(args.baseline)
baseline_files = compute_file_coverage(baseline_data)
baseline_totals = compute_totals(baseline_files)
md = generate_markdown(
label=args.label,
file_coverages=file_coverages,
totals=totals,
baseline_totals=baseline_totals,
source_dir=source_dir,
)
md_path = output_dir / f"{args.label}.md"
md_path.write_text(md)
print(f"Wrote markdown report: {md_path}")
summary = {
"label": args.label,
"coverage_pct": round(totals["coverage_pct"], 2),
"covered_lines": totals["covered_lines"],
"total_lines": totals["total_lines"],
"function_pct": round(totals["function_pct"], 2),
"covered_functions": totals["covered_functions"],
"total_functions": totals["total_functions"],
"file_count": totals["file_count"],
}
if baseline_totals:
summary["baseline_pct"] = round(baseline_totals["coverage_pct"], 2)
summary["delta_pct"] = round(
totals["coverage_pct"] - baseline_totals["coverage_pct"], 2
)
summary_path = output_dir / f"{args.label}-summary.json"
summary_path.write_text(json.dumps(summary, indent=2))
print(f"Wrote summary JSON: {summary_path}")
print(f"\nCoverage: {totals['coverage_pct']:.2f}%")
if baseline_totals:
delta = totals["coverage_pct"] - baseline_totals["coverage_pct"]
print(f"Delta: {'+' if delta >= 0 else ''}{delta:.2f}%")
if __name__ == "__main__":
main()