Coverage for src/pytribeam/cicd/report_coverage.py: 85%
68 statements
« prev ^ index » next coverage.py v7.5.1, created at 2026-07-23 00:29 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2026-07-23 00:29 +0000
1#!/usr/bin/env python3
2"""
3Coverage HTML Report Generator
5This module extracts key coverage metrics from a coverage XML file and generates a custom HTML report.
6"""
8import argparse
9import os
10import sys
11import xml.etree.ElementTree as ET
12from dataclasses import dataclass
13from pathlib import Path
15from pytribeam.cicd.utilities import (
16 ReportMetadata,
17 add_common_args,
18 extend_timestamp,
19 get_score_color_coverage,
20 report_main_runner,
21 write_report,
22)
25@dataclass(frozen=True)
26class CoverageMetric:
27 """Represents coverage metrics for a codebase.
29 Attributes:
30 lines_valid (int): The total number of valid lines in the codebase.
31 lines_covered (int): The number of lines that are covered by tests.
32 """
34 lines_valid: int = 0
35 lines_covered: int = 0
36 branches_valid: int = 0
37 branches_covered: int = 0
39 @property
40 def coverage(self) -> float:
41 """
42 Calculates the coverage percentage.
43 """
44 return (
45 # (self.lines_covered / self.lines_valid * 100)
46 (
47 (self.lines_covered + self.branches_covered)
48 / (self.lines_valid + self.branches_valid)
49 * 100
50 )
51 if self.lines_valid > 0
52 else 0.0
53 )
55 @property
56 def color(self) -> str:
57 """
58 Determines the badge color based on the coverage percentage.
59 """
60 return get_score_color_coverage(str(self.coverage))
63def get_coverage_metric(coverage_file: Path) -> CoverageMetric:
64 """
65 Parses the coverage XML file to extract metrics.
66 """
67 try:
68 tree = ET.parse(coverage_file)
69 root = tree.getroot()
70 lines_valid = int(root.attrib["lines-valid"])
71 lines_covered = int(root.attrib["lines-covered"])
72 branches_valid = int(root.attrib["branches-valid"])
73 branches_covered = int(root.attrib["branches-covered"])
74 return CoverageMetric(
75 lines_valid=lines_valid,
76 lines_covered=lines_covered,
77 branches_valid=branches_valid,
78 branches_covered=branches_covered,
79 )
80 except (FileNotFoundError, ET.ParseError, KeyError, AttributeError) as e:
81 print(f"Error processing coverage file: {e}")
82 return CoverageMetric()
85def get_report_html(
86 coverage_metric: CoverageMetric,
87 metadata: ReportMetadata,
88) -> str:
89 """
90 Generates an HTML report from the coverage metrics.
91 """
92 # Map badge colors to valid CSS colors
93 color_map = {
94 "brightgreen": "#4c1",
95 "green": "#97ca00",
96 "yellow": "#dfb317",
97 "orange": "#fe7d37",
98 "red": "#e05d44",
99 "gray": "#9f9f9f",
100 }
101 raw_color = coverage_metric.color
102 score_color = color_map.get(raw_color, raw_color)
104 timestamp_ext = extend_timestamp(metadata.timestamp)
106 # Programmatically construct the full report URL
107 try:
108 owner, repo_name = metadata.github_repo.split("/")
109 subdir = "main" if metadata.ref_name == "main" else "dev"
110 full_report_url = f"https://{owner}.github.io/{repo_name}/{subdir}/reports/coverage/htmlcov/index.html"
111 except ValueError:
112 full_report_url = "#"
114 # Pre-calculate GitHub URLs
115 github_url = f"https://github.com/{metadata.github_repo}"
116 run_url = f"{github_url}/actions/runs/{metadata.run_id}"
117 branch_url = f"{github_url}/tree/{metadata.ref_name}"
118 commit_url = f"{github_url}/commit/{metadata.github_sha}"
120 return f"""<!DOCTYPE html>
121<html lang="en">
122<head>
123 <meta charset="UTF-8">
124 <meta name="viewport" content="width=device-width, initial-scale=1.0">
125 <title>pyTriBeam | Coverage Report</title>
126 <style>
127 body {{
128 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
129 margin: 0; padding: 20px; background: #DCDCDC; line-height: 1.6;
130 }}
131 .container {{
132 max-width: 1200px; margin: 0 auto;
133 }}
134 .header {{
135 background: #F5F5F5; padding: 30px; border-radius: 8px;
136 box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px;
137 }}
138 .score {{
139 font-size: 2.5em; font-weight: bold; color: {score_color};
140 }}
141 .metadata {{
142 color: #6a737d; font-size: 0.9em; margin-top: 10px;
143 line-height: 1.25;
144 }}
145 .metadata div {{ margin-bottom: 0px; }}
146 .section {{
147 background: #F5F5F5; padding: 20px; border-radius: 8px;
148 box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px;
149 }}
150 .footer {{ text-align: center; margin: 40px 0; color: #6a737d; font-size: 0.8em; }}
151 </style>
152</head>
153<body>
154 <div class="container">
155 <div class="header">
156 <h1>Coverage Report</h1>
157 <div class="score">Coverage: {coverage_metric.coverage:.2f}%</div>
158 <div class="metadata">
159 <div><strong>Lines Covered:</strong> {coverage_metric.lines_covered}</div>
160 <div><strong>Total Lines:</strong> {coverage_metric.lines_valid}</div>
161 <div><strong>Branches Covered:</strong> {coverage_metric.branches_covered}</div>
162 <div><strong>Total Branches:</strong> {coverage_metric.branches_valid}</div>
163 <div><strong>Generated:</strong> {timestamp_ext}</div>
164 <div><strong>Run ID:</strong>
165 <a href="{run_url}">{metadata.run_id}</a></div>
166 <div><strong>Branch:</strong>
167 <a href="{branch_url}">{metadata.ref_name}</a></div>
168 <div><strong>Commit:</strong>
169 <a href="{commit_url}">{metadata.github_sha[:7]}</a></div>
170 <div><strong>Repository:</strong>
171 <a href="{github_url}">{metadata.github_repo}</a></div>
172 <div><strong>Full report:</strong> <a href="{full_report_url}" style="color: #0366d6;">Detailed HTML Report</a></div>
173 </div>
174 </div>
176 <div class="footer">
177 <p>© 2026 Sandia National Laboratories | Generated by GitHub Actions</p>
178 </div>
179 </div>
180</body>
181</html>"""
184def generate_report(args: argparse.Namespace, metadata: ReportMetadata) -> None:
185 """
186 Orchestrate report generation.
187 """
188 coverage_metric = get_coverage_metric(coverage_file=Path(args.input_file))
190 html_content = get_report_html(coverage_metric, metadata)
192 # Ensure output directory exists
193 output_dir = os.path.dirname(args.output_file)
194 if output_dir: 194 ↛ 197line 194 didn't jump to line 197, because the condition on line 194 was always true
195 os.makedirs(output_dir, exist_ok=True)
197 write_report(html_content, args.output_file)
199 print(f"[OK] Coverage report generated: {args.output_file}")
200 print(f"[I] - valid lines: {coverage_metric.lines_valid}")
201 print(f"[I] - covered lines: {coverage_metric.lines_covered}")
202 print(f"[I] - coverage: {coverage_metric.coverage:.2f}%")
205def parse_arguments() -> argparse.Namespace:
206 """
207 Parse command line arguments.
208 """
209 parser = argparse.ArgumentParser(
210 description="Generate enhanced HTML report from coverage XML"
211 )
212 parser.add_argument("--input_file", required=True, help="Input coverage XML file")
213 parser.add_argument("--output_file", required=True, help="Output HTML report file")
214 add_common_args(parser)
215 return parser.parse_args()
218def main() -> int:
219 """
220 Main entry point.
221 """
222 args = parse_arguments()
223 return report_main_runner(generate_report, args)
226if __name__ == "__main__":
227 sys.exit(main())