Coverage for  / opt / hostedtoolcache / Python / 3.11.14 / x64 / lib / python3.11 / site-packages / rattlesnake / cicd / report_pytest.py: 96%

73 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-27 18:22 +0000

1"""This module extracts key coverage metrics from a coverage output file.""" 

2 

3import argparse 

4import xml.etree.ElementTree as ET 

5from dataclasses import dataclass 

6from pathlib import Path 

7 

8import sys 

9 

10# from typing import Dict, List, Tuple 

11 

12from rattlesnake.cicd.utilities import ( 

13 get_score_color_coverage, 

14 extend_timestamp, 

15 write_report, 

16) 

17 

18 

19@dataclass(frozen=True) 

20class CoverageMetric: 

21 """Represents coverage metrics for a codebase. 

22 

23 Attributes: 

24 lines_valid (int): The total number of valid lines in the codebase. 

25 lines_covered (int): The number of lines that are covered by tests. 

26 coverage (float): The coverage percentage, calculated as 

27 (lines_covered / lines_valid) * 100. Defaults to 0.0. 

28 color (str): The color code (e.g., red, green), based on the coverage. 

29 """ 

30 

31 lines_valid: int = 0 

32 lines_covered: int = 0 

33 

34 @property 

35 def coverage(self) -> float: 

36 """ 

37 Calculates the coverage percentage. 

38 

39 The coverage is calculated as `(lines_covered / lines_valid) * 100`. 

40 Returns 0.0 if `lines_valid` is zero to prevent division by zero errors. 

41 """ 

42 

43 return ( 

44 (self.lines_covered / self.lines_valid * 100) 

45 if self.lines_valid > 0 

46 else 0.0 

47 ) 

48 

49 @property 

50 def color(self) -> str: 

51 """ 

52 Determines the badge color based on the coverage percentage. 

53 """ 

54 return get_score_color_coverage(str(self.coverage)) 

55 

56 

57def get_coverage_metric(coverage_file: Path) -> CoverageMetric: 

58 """ 

59 Gets the lines-valid, lines-covered, and coverage percentage as 

60 a list strings. 

61 """ 

62 

63 cm = CoverageMetric() 

64 

65 try: 

66 tree = ET.parse(coverage_file) 

67 root = tree.getroot() 

68 lines_valid = int(root.attrib["lines-valid"]) 

69 lines_covered = int(root.attrib["lines-covered"]) 

70 cm = CoverageMetric( 

71 lines_valid=lines_valid, 

72 lines_covered=lines_covered, 

73 ) # overwrite default 

74 except (FileNotFoundError, ET.ParseError, KeyError) as e: 

75 print(f"Error processing coverage file: {e}") 

76 

77 return cm 

78 

79 

80def get_report_html( 

81 coverage_metric: CoverageMetric, 

82 timestamp: str, 

83 run_id: str, 

84 ref_name: str, 

85 github_sha: str, 

86 github_repo: str, 

87) -> str: 

88 """ 

89 Generates an HTML report from the coverage metrics. 

90 

91 Args: 

92 coverage_metric: CoverageMetric object containing coverage data 

93 timestamp: The timestampe from bash when pylint ran, in format YYYYMMDD_HHMMSS_UTC 

94 e.g., 20250815_211112_UTC 

95 run_id: GitHub Actions run ID 

96 ref_name: Git reference name (branch) 

97 github_sha: GitHub commit SHA 

98 github_repo: GitHub repository name 

99 

100 Returns: 

101 Complete HTML report as a string 

102 """ 

103 timestamp_ext = extend_timestamp(timestamp) 

104 score_color: str = coverage_metric.color 

105 

106 # Programmatically construct the full report URL 

107 try: 

108 owner, repo_name = github_repo.split("/") 

109 full_report_url = ( 

110 f"https://{owner}.github.io/{repo_name}/reports/coverage/htmlcov/index.html" 

111 ) 

112 except ValueError: 

113 # Fallback or default URL in case the repo format is unexpected 

114 full_report_url = "#" 

115 

116 html_content = f"""<!DOCTYPE html> 

117<html lang="en"> 

118<head> 

119 <meta charset="UTF-8"> 

120 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 

121 <title>Pytest Report</title> 

122 <style> 

123 body {{ 

124 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 

125 margin: 0; padding: 20px; background: #f6f8fa; line-height: 1.6; 

126 background: lightgray; 

127 }} 

128 .container {{ 

129 max-width: 1200px; margin: 0 auto; 

130 }} 

131 .header {{ 

132 background: white; padding: 30px; border-radius: 8px; 

133 box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; 

134 }} 

135 .score {{ 

136 font-size: 2.5em; font-weight: bold; color: {score_color}; 

137 }} 

138 .metadata {{ 

139 color: #6a737d; font-size: 0.9em; margin-top: 10px; 

140 }} 

141 .nav {{ 

142 background: white; padding: 20px; border-radius: 8px; 

143 box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; 

144 }} 

145 .nav a {{ 

146 background: #0366d6; color: white; padding: 10px 20px; 

147 text-decoration: none; border-radius: 6px; margin-right: 10px; 

148 display: inline-block; margin-bottom: 5px; 

149 }} 

150 .nav a:hover {{ 

151 background: #0256cc; 

152 }} 

153 .section {{ 

154 background: white; padding: 20px; border-radius: 8px; 

155 box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 20px; 

156 }} 

157 </style> 

158</head> 

159<body> 

160 <div class="container"> 

161 <div class="header"> 

162 <h1>Pytest Coverage Report</h1> 

163 <div class="score">Coverage: {coverage_metric.coverage:.2f}%</div> 

164 <div class="metadata"> 

165 <div><strong>Lines Covered:</strong> {coverage_metric.lines_covered}</div> 

166 <div><strong>Total Lines:</strong> {coverage_metric.lines_valid}</div> 

167 <div>&nbsp;</div> 

168 <div><strong>Generated:</strong> {timestamp_ext}</div> 

169 <div><strong>Run ID:</strong> <a href="https://github.com/{github_repo}/actions/runs/{run_id}"> {run_id}</a></div> 

170 <div><strong>Branch:</strong> <a href="https://github.com/{github_repo}/tree/{ref_name}"> {ref_name}</a></div> 

171 <div><strong>Commit:</strong> <a href="https://github.com/{github_repo}/commit/{github_sha}"> {github_sha[:7]}</a></div> 

172 <div><strong>Repository:</strong> <a href="https://github.com/{github_repo}">{github_repo}</a></div> 

173 <div>&nbsp;</div> 

174 <div><strong>Full report:</strong> <a href="{full_report_url}">HTML</a></div> 

175 </div> 

176 </div> 

177 </div> 

178 <footer style="text-align: center; margin: 40px 0; color: #6a737d;"> 

179 <p>Generated by GitHub Actions</p> 

180 </footer> 

181</body> 

182</html>""" 

183 

184 return html_content 

185 

186 

187def run_pytest_report( 

188 input_file: str, 

189 output_file: str, 

190 timestamp: str, 

191 run_id: str, 

192 ref_name: str, 

193 github_sha: str, 

194 github_repo: str, 

195) -> CoverageMetric: 

196 """ 

197 Main function to create HTML report from pytest output. 

198 

199 Args: 

200 input_file: Path to the pytest output text file 

201 output_file: Path for the generated HTML report 

202 timestamp: The timestampe from bash when pylint ran, in format YYYYMMDD_HHMMSS_UTC 

203 e.g., 20250815_211112_UTC 

204 run_id: GitHub Actions run ID 

205 ref_name: Git reference name (branch) 

206 github_sha: GitHub commit SHA 

207 github_repo: GitHub repository name 

208 

209 Returns: 

210 CoverageMetric 

211 """ 

212 # Get the coverage metric 

213 coverage_metric = get_coverage_metric(coverage_file=Path(input_file)) 

214 print(f"run_pytest_report: coverage_metric={coverage_metric}") 

215 

216 # Generate HTML report 

217 html_content: str = get_report_html( 

218 coverage_metric, 

219 timestamp, 

220 run_id, 

221 ref_name, 

222 github_sha, 

223 github_repo, 

224 ) 

225 

226 # Write the HTML report 

227 write_report(html_content, output_file) 

228 

229 return coverage_metric 

230 

231 

232def parse_arguments() -> argparse.Namespace: 

233 """ 

234 Parse command line arguments. 

235 

236 Returns: 

237 Parsed arguments namespace 

238 """ 

239 parser: argparse.ArgumentParser = argparse.ArgumentParser( 

240 description="Generate enhanced HTML report from pytest output", 

241 formatter_class=argparse.RawDescriptionHelpFormatter, 

242 epilog=""" 

243Example: 

244 python pytest_report.py \ 

245 --input_file pytest_output_20240101_120000_UTC.txt \ 

246 --output_file pytest_report.html \ 

247 --timestamp 20240101_120000_UTC \ 

248 --run_id 1234567890 \ 

249 --ref_name main \ 

250 --github_sha abc123def456 \ 

251 --github_repo owner/repo-name 

252 """, 

253 ) 

254 

255 parser.add_argument("--input_file", required=True, help="Input pytest output file") 

256 

257 parser.add_argument("--output_file", required=True, help="Output HTML report file") 

258 

259 parser.add_argument( 

260 "--timestamp", required=True, help="UTC timestamp, e.g., 20240101_120000_UTC" 

261 ) 

262 

263 parser.add_argument("--run_id", required=True, help="GitHub Actions run ID") 

264 

265 parser.add_argument( 

266 "--ref_name", required=True, help="Git reference name (branch name)" 

267 ) 

268 

269 parser.add_argument("--github_sha", required=True, help="GitHub commit SHA") 

270 

271 parser.add_argument( 

272 "--github_repo", required=True, help="GitHub repository name (owner/repo-name)" 

273 ) 

274 

275 return parser.parse_args() 

276 

277 

278def main() -> int: 

279 """ 

280 Main entry point for the script. 

281 

282 Returns: 

283 Exit code (0 for success, 1 for failure) 

284 """ 

285 args: argparse.Namespace = parse_arguments() 

286 

287 try: 

288 cm: CoverageMetric = run_pytest_report( 

289 args.input_file, 

290 args.output_file, 

291 args.timestamp, 

292 args.run_id, 

293 args.ref_name, 

294 args.github_sha, 

295 args.github_repo, 

296 ) 

297 

298 print(f"✅ Pytest HTML report generated: {args.output_file}") 

299 print(f"📊 - valid lines of code: {cm.lines_valid}") 

300 print(f"🔍 - lines covered: {cm.lines_covered}") 

301 print(f"🎉 - coverage: {cm.coverage} percent") 

302 

303 except FileNotFoundError: 

304 print(f"❌ Error: The input file '{args.input_file}' was not found.") 

305 return 1 

306 except IOError as e: 

307 print(f"❌ I/O error occurred: {e}") 

308 return 1 

309 except Exception as e: 

310 print(f"❌ An unexpected error occurred: {e}") 

311 return 1 

312 

313 return 0 # Success exit code 

314 

315 

316if __name__ == "__main__": 

317 sys.exit(main())