Coverage for scripts/generate_coverage_badge.py: 87%
54 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Generate a small SVG badge from coverage.py JSON output."""
3from __future__ import annotations
5import argparse
6import html
7import json
8from pathlib import Path
11def read_coverage_percent(coverage_json_path: Path) -> float:
12 coverage_data = json.loads(coverage_json_path.read_text(encoding="utf-8"))
13 totals = coverage_data.get("totals")
14 if not isinstance(totals, dict):
15 raise ValueError("coverage JSON does not contain a totals object")
17 percent_covered = totals.get("percent_covered")
18 if not isinstance(percent_covered, int | float):
19 raise ValueError("coverage JSON totals.percent_covered must be numeric")
21 return float(percent_covered)
24def format_coverage_percent(percent: float) -> str:
25 return f"{percent:.0f}%"
28def badge_color(percent: float) -> str:
29 if percent >= 95:
30 return "#4c1"
31 if percent >= 90:
32 return "#97ca00"
33 if percent >= 80:
34 return "#a4a61d"
35 if percent >= 70:
36 return "#dfb317"
37 if percent >= 60:
38 return "#fe7d37"
39 return "#e05d44"
42def estimate_text_width(text: str) -> int:
43 return len(text) * 7 + 10
46def build_badge_svg(label: str, message: str, color: str) -> str:
47 label_width = estimate_text_width(label)
48 message_width = estimate_text_width(message)
49 total_width = label_width + message_width
50 label_text_x = label_width // 2
51 message_text_x = label_width + message_width // 2
53 escaped_label = html.escape(label)
54 escaped_message = html.escape(message)
55 escaped_color = html.escape(color, quote=True)
57 return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{escaped_label}: {escaped_message}">
58 <linearGradient id="s" x2="0" y2="100%">
59 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
60 <stop offset="1" stop-opacity=".1"/>
61 </linearGradient>
62 <clipPath id="r">
63 <rect width="{total_width}" height="20" rx="3" fill="#fff"/>
64 </clipPath>
65 <g clip-path="url(#r)">
66 <rect width="{label_width}" height="20" fill="#555"/>
67 <rect x="{label_width}" width="{message_width}" height="20" fill="{escaped_color}"/>
68 <rect width="{total_width}" height="20" fill="url(#s)"/>
69 </g>
70 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
71 <text aria-hidden="true" x="{label_text_x * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{(label_width - 10) * 10}">{escaped_label}</text>
72 <text x="{label_text_x * 10}" y="140" transform="scale(.1)" textLength="{(label_width - 10) * 10}">{escaped_label}</text>
73 <text aria-hidden="true" x="{message_text_x * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="{(message_width - 10) * 10}">{escaped_message}</text>
74 <text x="{message_text_x * 10}" y="140" transform="scale(.1)" textLength="{(message_width - 10) * 10}">{escaped_message}</text>
75 </g>
76</svg>
77"""
80def generate_badge(coverage_json_path: Path, output_path: Path) -> None:
81 percent = read_coverage_percent(coverage_json_path)
82 svg = build_badge_svg(
83 label="coverage",
84 message=format_coverage_percent(percent),
85 color=badge_color(percent),
86 )
87 output_path.parent.mkdir(parents=True, exist_ok=True)
88 output_path.write_text(svg, encoding="utf-8")
91def parse_args() -> argparse.Namespace:
92 parser = argparse.ArgumentParser(
93 description="Generate an SVG coverage badge from coverage.py JSON output."
94 )
95 parser.add_argument("coverage_json_path", type=Path)
96 parser.add_argument("output_path", type=Path)
97 return parser.parse_args()
100def main() -> int:
101 args = parse_args()
102 generate_badge(args.coverage_json_path, args.output_path)
103 return 0
106if __name__ == "__main__":
107 raise SystemExit(main())