Coverage for src/pytribeam/cicd/report_lint.py: 81%

82 statements  

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

1#!/usr/bin/env python3 

2""" 

3Lint HTML Report Generator 

4 

5This module extracts lint output and generates a custom HTML report. 

6""" 

7 

8import argparse 

9import os 

10import re 

11import sys 

12 

13from pytribeam.cicd.utilities import ( 

14 ReportMetadata, 

15 add_common_args, 

16 extend_timestamp, 

17 get_score_color_lint, 

18 report_main_runner, 

19) 

20 

21 

22def get_lint_content(input_file: str) -> str: 

23 """ 

24 Read lint output from file. 

25 

26 Args: 

27 input_file: Path to the lint output file 

28 

29 Returns: 

30 Content of the lint output file 

31 """ 

32 if not os.path.exists(input_file): 32 ↛ 33line 32 didn't jump to line 33, because the condition on line 32 was never true

33 raise FileNotFoundError(f"The input file '{input_file}' was not found.") 

34 with open(input_file, "r", encoding="utf-8") as f: 

35 return f.read() 

36 

37 

38def get_lint_sections(content: str) -> tuple: 

39 """ 

40 Parse lint output into issues and summary sections. 

41 

42 Args: 

43 content: The lint output content 

44 

45 Returns: 

46 A tuple of (issues, summary_lines) 

47 """ 

48 # Pylint usually separates the report with a line of dashes 

49 sections = re.split(r"-{10,}", content) 

50 issues = sections[0].strip().split("\n") 

51 issues = [i for i in issues if i.strip()] 

52 summary = sections[1].split("\n") if len(sections) > 1 else [] 

53 return issues, summary 

54 

55 

56def get_score_from_summary(summary_lines: list) -> str: 

57 """ 

58 Extract lint score from summary. 

59 

60 Args: 

61 summary_lines: Lines from the summary section 

62 

63 Returns: 

64 The extracted score as a string 

65 """ 

66 score_pattern = r"Your code has been rated at (\d+\.\d+)/10" 

67 for line in summary_lines: 67 ↛ 71line 67 didn't jump to line 71, because the loop on line 67 didn't complete

68 match = re.search(score_pattern, line) 

69 if match: 

70 return match.group(1) 

71 return "0.00" 

72 

73 

74def get_html_header(score: str, metadata: ReportMetadata) -> str: 

75 """ 

76 Generate the HTML header. 

77 

78 Args: 

79 score: The pylint score 

80 metadata: CI/CD metadata 

81 

82 Returns: 

83 HTML header string 

84 """ 

85 # Map badge colors to valid CSS colors 

86 color_map = { 

87 "brightgreen": "#4c1", 

88 "green": "#97ca00", 

89 "yellow": "#dfb317", 

90 "orange": "#fe7d37", 

91 "red": "#e05d44", 

92 "gray": "#9f9f9f", 

93 } 

94 raw_color = get_score_color_lint(score) 

95 score_color = color_map.get(raw_color, raw_color) 

96 

97 timestamp_ext = extend_timestamp(metadata.timestamp) 

98 

99 # Pre-calculate GitHub URLs 

100 github_url = f"https://github.com/{metadata.github_repo}" 

101 run_url = f"{github_url}/actions/runs/{metadata.run_id}" 

102 branch_url = f"{github_url}/tree/{metadata.ref_name}" 

103 commit_url = f"{github_url}/commit/{metadata.github_sha}" 

104 

105 return f"""<!DOCTYPE html> 

106<html lang="en"> 

107<head> 

108 <meta charset="UTF-8"> 

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

110 <title>pyTriBeam | Lint Report</title> 

111 <style> 

112 body {{ 

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

114 margin: 0; padding: 20px; background: #DCDCDC; line-height: 1.6; 

115 }} 

116 .container {{ 

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

118 }} 

119 .header {{ 

120 background: #F5F5F5; padding: 30px; border-radius: 8px; 

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

122 }} 

123 .score {{ 

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

125 }} 

126 .metadata {{ 

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

128 line-height: 1.25; 

129 }} 

130 .metadata div {{ margin-bottom: 0px; }} 

131 .section {{ 

132 background: #F5F5F5; padding: 20px; border-radius: 8px; 

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

134 }} 

135 table {{ 

136 width: 100%; border-collapse: collapse; background: #F5F5F5; 

137 border: 1px solid #e1e4e8; border-radius: 6px; 

138 }} 

139 th {{ 

140 text-align: left; padding: 12px; background: #E8E8E8; 

141 border-bottom: 1px solid #e1e4e8; 

142 }} 

143 td {{ padding: 12px; border-bottom: 1px solid #e1e4e8; font-size: 14px; }} 

144 .footer {{ text-align: center; margin: 40px 0; color: #6a737d; font-size: 0.8em; }} 

145 </style> 

146</head> 

147<body> 

148 <div class="container"> 

149 <div class="header"> 

150 <h1>Lint Report</h1> 

151 <div class="score">Pylint Score: {score}/10</div> 

152 <div class="metadata"> 

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

154 <div><strong>Run ID:</strong> 

155 <a href="{run_url}">{metadata.run_id}</a></div> 

156 <div><strong>Branch:</strong> 

157 <a href="{branch_url}">{metadata.ref_name}</a></div> 

158 <div><strong>Commit:</strong> 

159 <a href="{commit_url}">{metadata.github_sha[:7]}</a></div> 

160 <div><strong>Repository:</strong> 

161 <a href="{github_url}">{metadata.github_repo}</a></div> 

162 </div> 

163 </div> 

164""" 

165 

166 

167def get_html_issues_table(issues: list, metadata: ReportMetadata) -> str: 

168 """ 

169 Generate issues table. 

170 

171 Args: 

172 issues: List of lint issues 

173 metadata: CI/CD metadata 

174 

175 Returns: 

176 HTML table string 

177 """ 

178 valid_issues = [] 

179 # Pattern: path/to/file.py:line:col: TYPE: Message 

180 pattern = r"(.+):(\d+):(\d+): ([A-Z])\d+: (.+)" 

181 

182 for issue in issues: 

183 if re.search(pattern, issue): 183 ↛ 182line 183 didn't jump to line 182, because the condition on line 183 was always true

184 valid_issues.append(issue) 

185 

186 if not valid_issues: 186 ↛ 187line 186 didn't jump to line 187, because the condition on line 186 was never true

187 return '<div class="section"><p>No issues found! Great job.</p></div>' 

188 

189 table_html = """ 

190 <div class="section"> 

191 <table> 

192 <thead> 

193 <tr> 

194 <th>File</th> 

195 <th>Line:Col</th> 

196 <th>Type</th> 

197 <th>Message</th> 

198 </tr> 

199 </thead> 

200 <tbody>""" 

201 

202 msg_types = { 

203 "C": "Convention", 

204 "R": "Refactor", 

205 "W": "Warning", 

206 "E": "Error", 

207 "F": "Fatal", 

208 } 

209 

210 for issue in valid_issues: 

211 match = re.search(pattern, issue) 

212 if match: 212 ↛ 210line 212 didn't jump to line 210, because the condition on line 212 was always true

213 file_path, line, col, type_code, text = match.groups() 

214 type_name = msg_types.get(type_code, type_code) 

215 

216 # GitHub direct link 

217 file_url = ( 

218 f"https://github.com/{metadata.github_repo}/blob/" 

219 f"{metadata.github_sha}/{file_path}#L{line}" 

220 ) 

221 

222 row_color = "" 

223 if type_code == "E": 223 ↛ 224line 223 didn't jump to line 224, because the condition on line 223 was never true

224 row_color = ' style="background-color: #ffeef0"' 

225 elif type_code == "W": 225 ↛ 226line 225 didn't jump to line 226, because the condition on line 225 was never true

226 row_color = ' style="background-color: #fff5b1"' 

227 

228 table_html += f""" 

229 <tr{row_color}> 

230 <td><a href="{file_url}">{file_path}</a></td> 

231 <td>{line}:{col}</td> 

232 <td>{type_name}</td> 

233 <td>{text}</td> 

234 </tr>""" 

235 

236 table_html += """ 

237 </tbody> 

238 </table> 

239 </div>""" 

240 return table_html 

241 

242 

243def get_html_footer() -> str: 

244 """ 

245 Generate the HTML footer. 

246 

247 Returns: 

248 HTML footer string 

249 """ 

250 return """ 

251 <div class="footer"> 

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

253 </div> 

254 </div> 

255</body> 

256</html>""" 

257 

258 

259def generate_report(args: argparse.Namespace, metadata: ReportMetadata) -> None: 

260 """ 

261 Orchestrate report generation. 

262 

263 Args: 

264 args: Parsed command line arguments 

265 metadata: CI/CD metadata 

266 """ 

267 content = get_lint_content(args.input_file) 

268 issues, summary = get_lint_sections(content) 

269 score = get_score_from_summary(summary) 

270 

271 html = get_html_header(score, metadata) 

272 html += get_html_issues_table(issues, metadata) 

273 html += get_html_footer() 

274 

275 # Ensure output directory exists 

276 output_dir = os.path.dirname(args.output_file) 

277 if output_dir: 277 ↛ 280line 277 didn't jump to line 280, because the condition on line 277 was always true

278 os.makedirs(output_dir, exist_ok=True) 

279 

280 with open(args.output_file, "w", encoding="utf-8") as f: 

281 f.write(html) 

282 print(f"[OK] Lint report generated: {args.output_file}") 

283 

284 

285def parse_arguments() -> argparse.Namespace: 

286 """ 

287 Parse command line arguments. 

288 

289 Returns: 

290 Parsed arguments namespace 

291 """ 

292 parser: argparse.ArgumentParser = argparse.ArgumentParser( 

293 description="Generate a custom HTML report from lint output.", 

294 ) 

295 parser.add_argument("--input_file", required=True, help="Path to lint output file") 

296 parser.add_argument( 

297 "--output_file", required=True, help="Path for output HTML file" 

298 ) 

299 

300 add_common_args(parser) 

301 

302 return parser.parse_args() 

303 

304 

305def main() -> int: 

306 """Main entry point.""" 

307 args: argparse.Namespace = parse_arguments() 

308 return report_main_runner(generate_report, args) 

309 

310 

311if __name__ == "__main__": 

312 sys.exit(main())