Coverage for src/pytribeam/cicd/report_lint.py: 81%
82 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"""
3Lint HTML Report Generator
5This module extracts lint output and generates a custom HTML report.
6"""
8import argparse
9import os
10import re
11import sys
13from pytribeam.cicd.utilities import (
14 ReportMetadata,
15 add_common_args,
16 extend_timestamp,
17 get_score_color_lint,
18 report_main_runner,
19)
22def get_lint_content(input_file: str) -> str:
23 """
24 Read lint output from file.
26 Args:
27 input_file: Path to the lint output file
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()
38def get_lint_sections(content: str) -> tuple:
39 """
40 Parse lint output into issues and summary sections.
42 Args:
43 content: The lint output content
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
56def get_score_from_summary(summary_lines: list) -> str:
57 """
58 Extract lint score from summary.
60 Args:
61 summary_lines: Lines from the summary section
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"
74def get_html_header(score: str, metadata: ReportMetadata) -> str:
75 """
76 Generate the HTML header.
78 Args:
79 score: The pylint score
80 metadata: CI/CD metadata
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)
97 timestamp_ext = extend_timestamp(metadata.timestamp)
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}"
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"""
167def get_html_issues_table(issues: list, metadata: ReportMetadata) -> str:
168 """
169 Generate issues table.
171 Args:
172 issues: List of lint issues
173 metadata: CI/CD metadata
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+: (.+)"
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)
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>'
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>"""
202 msg_types = {
203 "C": "Convention",
204 "R": "Refactor",
205 "W": "Warning",
206 "E": "Error",
207 "F": "Fatal",
208 }
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)
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 )
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"'
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>"""
236 table_html += """
237 </tbody>
238 </table>
239 </div>"""
240 return table_html
243def get_html_footer() -> str:
244 """
245 Generate the HTML footer.
247 Returns:
248 HTML footer string
249 """
250 return """
251 <div class="footer">
252 <p>© 2026 Sandia National Laboratories | Generated by GitHub Actions</p>
253 </div>
254 </div>
255</body>
256</html>"""
259def generate_report(args: argparse.Namespace, metadata: ReportMetadata) -> None:
260 """
261 Orchestrate report generation.
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)
271 html = get_html_header(score, metadata)
272 html += get_html_issues_table(issues, metadata)
273 html += get_html_footer()
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)
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}")
285def parse_arguments() -> argparse.Namespace:
286 """
287 Parse command line arguments.
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 )
300 add_common_args(parser)
302 return parser.parse_args()
305def main() -> int:
306 """Main entry point."""
307 args: argparse.Namespace = parse_arguments()
308 return report_main_runner(generate_report, args)
311if __name__ == "__main__":
312 sys.exit(main())