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

1"""Generate a small SVG badge from coverage.py JSON output.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import html 

7import json 

8from pathlib import Path 

9 

10 

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") 

16 

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") 

20 

21 return float(percent_covered) 

22 

23 

24def format_coverage_percent(percent: float) -> str: 

25 return f"{percent:.0f}%" 

26 

27 

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" 

40 

41 

42def estimate_text_width(text: str) -> int: 

43 return len(text) * 7 + 10 

44 

45 

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 

52 

53 escaped_label = html.escape(label) 

54 escaped_message = html.escape(message) 

55 escaped_color = html.escape(color, quote=True) 

56 

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""" 

78 

79 

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") 

89 

90 

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() 

98 

99 

100def main() -> int: 

101 args = parse_args() 

102 generate_badge(args.coverage_json_path, args.output_path) 

103 return 0 

104 

105 

106if __name__ == "__main__": 

107 raise SystemExit(main())