Coverage for src/pytribeam/cicd/report_coverage.py: 85%

68 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2026-07-23 14:51 +0000

1#!/usr/bin/env python3 

2""" 

3Coverage HTML Report Generator 

4 

5This module extracts key coverage metrics from a coverage XML file and generates a custom HTML report. 

6""" 

7 

8import argparse 

9import os 

10import sys 

11import xml.etree.ElementTree as ET 

12from dataclasses import dataclass 

13from pathlib import Path 

14 

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) 

23 

24 

25@dataclass(frozen=True) 

26class CoverageMetric: 

27 """Represents coverage metrics for a codebase. 

28 

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

33 

34 lines_valid: int = 0 

35 lines_covered: int = 0 

36 branches_valid: int = 0 

37 branches_covered: int = 0 

38 

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 ) 

54 

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

61 

62 

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

83 

84 

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) 

103 

104 timestamp_ext = extend_timestamp(metadata.timestamp) 

105 

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

113 

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

119 

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> 

175  

176 <div class="footer"> 

177 <p>&copy; 2026 Sandia National Laboratories | Generated by GitHub Actions</p> 

178 </div> 

179 </div> 

180</body> 

181</html>""" 

182 

183 

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

189 

190 html_content = get_report_html(coverage_metric, metadata) 

191 

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) 

196 

197 write_report(html_content, args.output_file) 

198 

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

203 

204 

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

216 

217 

218def main() -> int: 

219 """ 

220 Main entry point. 

221 """ 

222 args = parse_arguments() 

223 return report_main_runner(generate_report, args) 

224 

225 

226if __name__ == "__main__": 

227 sys.exit(main())