Coverage for src / tracekit / reporting / html.py: 94%

103 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""HTML report generation for TraceKit. 

2 

3This module provides professional HTML report generation with modern features 

4including responsive design, interactive plots, and collapsible sections. 

5 

6Features: 

7 - Professional formatting standards 

8 - Visual emphasis system 

9 - Smart content filtering (interactive) 

10 - Modern HTML with progressive disclosure 

11 - Collapsible sections mechanism 

12 

13Example: 

14 >>> from tracekit.reporting.html import generate_html_report 

15 >>> html = generate_html_report(report, interactive=True, dark_mode=False) 

16""" 

17 

18from __future__ import annotations 

19 

20from pathlib import Path 

21from typing import TYPE_CHECKING, Any 

22 

23if TYPE_CHECKING: 

24 from tracekit.reporting.core import Report 

25 

26 

27def generate_html_report( 

28 report: Report, 

29 *, 

30 interactive: bool = True, 

31 dark_mode: bool = False, 

32 collapsible_sections: bool = True, 

33 responsive: bool = True, 

34 self_contained: bool = True, 

35) -> str: 

36 """Generate modern HTML report. 

37 

38 Args: 

39 report: Report object to render. 

40 interactive: Enable interactive features (sorting, filtering). 

41 dark_mode: Include dark mode support. 

42 collapsible_sections: Make sections collapsible. 

43 responsive: Enable responsive design for mobile. 

44 self_contained: Include all assets inline (no external dependencies). 

45 

46 Returns: 

47 HTML string. 

48 """ 

49 html_parts = [ 

50 _generate_html_header(report, dark_mode, responsive), 

51 _generate_html_styles(dark_mode, responsive), 

52 _generate_html_scripts() if interactive or collapsible_sections else "", 

53 "</head>", 

54 "<body>", 

55 _generate_html_nav(report) if len(report.sections) > 3 else "", 

56 '<div class="container">', 

57 f"<header><h1>{report.config.title}</h1>", 

58 _generate_metadata_section(report), 

59 "</header>", 

60 _generate_html_content(report, collapsible_sections), 

61 "</div>", 

62 "</body>", 

63 "</html>", 

64 ] 

65 

66 return "\n".join(html_parts) 

67 

68 

69def _generate_html_header(report: Report, dark_mode: bool, responsive: bool) -> str: 

70 """Generate HTML header.""" 

71 return f"""<!DOCTYPE html> 

72<html lang="en"> 

73<head> 

74 <meta charset="UTF-8"> 

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

76 <meta name="author" content="{report.config.author or "TraceKit"}"> 

77 <meta name="generator" content="TraceKit Reporting System"> 

78 <title>{report.config.title}</title>""" 

79 

80 

81def _generate_html_styles(dark_mode: bool, responsive: bool) -> str: 

82 """Generate CSS styles for HTML report.""" 

83 styles = """ 

84<style> 

85/* Professional Formatting Standards */ 

86:root { 

87 --primary-color: #2c3e50; 

88 --secondary-color: #3498db; 

89 --success-color: #27ae60; 

90 --warning-color: #f39c12; 

91 --danger-color: #e74c3c; 

92 --bg-color: #ffffff; 

93 --text-color: #333333; 

94 --border-color: #dddddd; 

95 --table-header-bg: #f2f2f2; 

96 --table-alt-row-bg: #f9f9f9; 

97} 

98 

99/* Dark mode support */ 

100@media (prefers-color-scheme: dark) { 

101 body.dark-mode { 

102 --bg-color: #1e1e1e; 

103 --text-color: #e0e0e0; 

104 --border-color: #444444; 

105 --table-header-bg: #2d2d2d; 

106 --table-alt-row-bg: #252525; 

107 } 

108} 

109 

110* { 

111 box-sizing: border-box; 

112 margin: 0; 

113 padding: 0; 

114} 

115 

116body { 

117 font-family: 'Times New Roman', Times, serif; 

118 font-size: 10pt; 

119 line-height: 1.5; 

120 color: var(--text-color); 

121 background-color: var(--bg-color); 

122 margin: 0; 

123 padding: 0; 

124} 

125 

126.container { 

127 max-width: 1200px; 

128 margin: 0 auto; 

129 padding: 1in; 

130} 

131 

132/* Typography */ 

133h1, h2, h3, h4, h5, h6 { 

134 font-family: Arial, Helvetica, sans-serif; 

135 line-height: 1.2; 

136 margin-top: 1em; 

137 margin-bottom: 0.5em; 

138 color: var(--primary-color); 

139} 

140 

141h1 { font-size: 24pt; } 

142h2 { font-size: 18pt; border-bottom: 2px solid var(--border-color); padding-bottom: 0.3em; } 

143h3 { font-size: 14pt; } 

144h4 { font-size: 12pt; } 

145 

146code, pre { 

147 font-family: 'Courier New', Courier, monospace; 

148 background-color: var(--table-alt-row-bg); 

149 padding: 2px 4px; 

150 border-radius: 3px; 

151} 

152 

153pre { 

154 padding: 10px; 

155 overflow-x: auto; 

156} 

157 

158/* Visual Emphasis */ 

159.pass { 

160 color: var(--success-color); 

161 font-weight: bold; 

162} 

163 

164.fail { 

165 color: var(--danger-color); 

166 font-weight: bold; 

167} 

168 

169.warning { 

170 color: var(--warning-color); 

171 font-weight: bold; 

172} 

173 

174.pass::before { content: '\\2713 '; } 

175.fail::before { content: '\\2717 '; } 

176 

177/* Severity indicators */ 

178.severity-critical { 

179 background-color: rgba(231, 76, 60, 0.2); 

180 border-left: 4px solid var(--danger-color); 

181 padding: 10px; 

182 margin: 10px 0; 

183} 

184 

185.severity-warning { 

186 background-color: rgba(243, 156, 18, 0.2); 

187 border-left: 4px solid var(--warning-color); 

188 padding: 10px; 

189 margin: 10px 0; 

190} 

191 

192.severity-info { 

193 background-color: rgba(52, 152, 219, 0.2); 

194 border-left: 4px solid var(--secondary-color); 

195 padding: 10px; 

196 margin: 10px 0; 

197} 

198 

199/* Callout boxes */ 

200.callout { 

201 background-color: rgba(241, 196, 15, 0.15); 

202 border: 1px solid var(--warning-color); 

203 border-radius: 5px; 

204 padding: 15px; 

205 margin: 15px 0; 

206} 

207 

208.callout-title { 

209 font-weight: bold; 

210 margin-bottom: 10px; 

211} 

212 

213/* Tables */ 

214table { 

215 border-collapse: collapse; 

216 width: 100%; 

217 margin: 15px 0; 

218 font-size: 10pt; 

219} 

220 

221th, td { 

222 border: 1px solid var(--border-color); 

223 padding: 8px 12px; 

224 text-align: left; 

225} 

226 

227th { 

228 background-color: var(--table-header-bg); 

229 font-weight: bold; 

230 font-family: Arial, Helvetica, sans-serif; 

231} 

232 

233tr:nth-child(even) { 

234 background-color: var(--table-alt-row-bg); 

235} 

236 

237tr:hover { 

238 background-color: rgba(52, 152, 219, 0.1); 

239} 

240 

241caption { 

242 caption-side: bottom; 

243 font-style: italic; 

244 padding: 8px; 

245 text-align: left; 

246} 

247 

248/* Collapsible sections */ 

249.collapsible { 

250 cursor: pointer; 

251 user-select: none; 

252 display: flex; 

253 align-items: center; 

254 gap: 8px; 

255} 

256 

257.collapsible::before { 

258 content: '\\25BC'; 

259 display: inline-block; 

260 transition: transform 0.3s; 

261} 

262 

263.collapsible.collapsed::before { 

264 transform: rotate(-90deg); 

265} 

266 

267.collapsible-content { 

268 max-height: 5000px; 

269 overflow: hidden; 

270 transition: max-height 0.3s ease-out; 

271} 

272 

273.collapsible-content.collapsed { 

274 max-height: 0; 

275} 

276 

277/* Metadata section */ 

278.metadata { 

279 background-color: var(--table-alt-row-bg); 

280 padding: 15px; 

281 margin: 20px 0; 

282 border-radius: 5px; 

283 font-size: 9pt; 

284} 

285 

286.metadata-item { 

287 display: inline-block; 

288 margin-right: 20px; 

289} 

290 

291/* Navigation */ 

292nav { 

293 background-color: var(--primary-color); 

294 color: white; 

295 padding: 15px; 

296 position: sticky; 

297 top: 0; 

298 z-index: 1000; 

299} 

300 

301nav ul { 

302 list-style: none; 

303 display: flex; 

304 gap: 20px; 

305 flex-wrap: wrap; 

306} 

307 

308nav a { 

309 color: white; 

310 text-decoration: none; 

311} 

312 

313nav a:hover { 

314 text-decoration: underline; 

315} 

316 

317/* Responsive design */ 

318@media (max-width: 768px) { 

319 .container { 

320 padding: 0.5in; 

321 } 

322 

323 h1 { font-size: 20pt; } 

324 h2 { font-size: 16pt; } 

325 h3 { font-size: 12pt; } 

326 

327 table { 

328 font-size: 9pt; 

329 } 

330 

331 nav ul { 

332 flex-direction: column; 

333 gap: 10px; 

334 } 

335} 

336 

337/* Print styles */ 

338@media print { 

339 .container { 

340 max-width: 100%; 

341 padding: 0; 

342 } 

343 

344 nav { 

345 display: none; 

346 } 

347 

348 .collapsible-content { 

349 max-height: none !important; 

350 } 

351} 

352</style>""" 

353 return styles 

354 

355 

356def _generate_html_scripts() -> str: 

357 """Generate JavaScript for interactive features.""" 

358 return """ 

359<script> 

360// Collapsible sections 

361document.addEventListener('DOMContentLoaded', function() { 

362 const collapsibles = document.querySelectorAll('.collapsible'); 

363 

364 collapsibles.forEach(function(collapsible) { 

365 collapsible.addEventListener('click', function() { 

366 this.classList.toggle('collapsed'); 

367 const content = this.nextElementSibling; 

368 if (content && content.classList.contains('collapsible-content')) { 

369 content.classList.toggle('collapsed'); 

370 } 

371 }); 

372 }); 

373 

374 // Dark mode toggle 

375 const darkModeToggle = document.getElementById('dark-mode-toggle'); 

376 if (darkModeToggle) { 

377 darkModeToggle.addEventListener('click', function() { 

378 document.body.classList.toggle('dark-mode'); 

379 }); 

380 } 

381 

382 // Table sorting (if interactive) 

383 const tables = document.querySelectorAll('table.sortable'); 

384 tables.forEach(function(table) { 

385 const headers = table.querySelectorAll('th'); 

386 headers.forEach(function(header, index) { 

387 header.addEventListener('click', function() { 

388 sortTable(table, index); 

389 }); 

390 header.style.cursor = 'pointer'; 

391 }); 

392 }); 

393}); 

394 

395function sortTable(table, column) { 

396 const tbody = table.querySelector('tbody'); 

397 const rows = Array.from(tbody.querySelectorAll('tr')); 

398 

399 rows.sort(function(a, b) { 

400 const aText = a.cells[column].textContent.trim(); 

401 const bText = b.cells[column].textContent.trim(); 

402 

403 // Try numeric comparison first 

404 const aNum = parseFloat(aText); 

405 const bNum = parseFloat(bText); 

406 

407 if (!isNaN(aNum) && !isNaN(bNum)) { 

408 return aNum - bNum; 

409 } 

410 

411 // Fall back to string comparison 

412 return aText.localeCompare(bText); 

413 }); 

414 

415 rows.forEach(function(row) { 

416 tbody.appendChild(row); 

417 }); 

418} 

419</script>""" 

420 

421 

422def _generate_html_nav(report: Report) -> str: 

423 """Generate navigation menu.""" 

424 nav_items = [] 

425 for section in report.sections: 

426 if section.visible: 

427 section_id = section.title.lower().replace(" ", "-") 

428 nav_items.append(f'<li><a href="#{section_id}">{section.title}</a></li>') 

429 

430 return f""" 

431<nav> 

432 <ul> 

433 {"".join(nav_items)} 

434 </ul> 

435</nav>""" 

436 

437 

438def _generate_metadata_section(report: Report) -> str: 

439 """Generate metadata section.""" 

440 items = [] 

441 if report.config.author: 

442 items.append( 

443 f'<span class="metadata-item"><strong>Author:</strong> {report.config.author}</span>' 

444 ) 

445 items.append( 

446 f'<span class="metadata-item"><strong>Date:</strong> {report.config.created.strftime("%Y-%m-%d %H:%M")}</span>' 

447 ) 

448 if report.config.verbosity: 448 ↛ 453line 448 didn't jump to line 453 because the condition on line 448 was always true

449 items.append( 

450 f'<span class="metadata-item"><strong>Detail Level:</strong> {report.config.verbosity}</span>' 

451 ) 

452 

453 return f'<div class="metadata">{" ".join(items)}</div>' 

454 

455 

456def _generate_html_content(report: Report, collapsible: bool) -> str: 

457 """Generate main content sections.""" 

458 content = [] 

459 

460 for section in report.sections: 

461 if not section.visible: 

462 continue 

463 

464 section_id = section.title.lower().replace(" ", "-") 

465 content.append(f'<section id="{section_id}">') 

466 

467 # Section header 

468 tag = f"h{min(section.level + 1, 6)}" 

469 if collapsible and section.collapsible: 

470 content.append(f'<{tag} class="collapsible">{section.title}</{tag}>') 

471 content.append('<div class="collapsible-content">') 

472 else: 

473 content.append(f"<{tag}>{section.title}</{tag}>") 

474 

475 # Section content 

476 if isinstance(section.content, str): 

477 content.append(f"<p>{section.content}</p>") 

478 elif isinstance(section.content, list): 478 ↛ 489line 478 didn't jump to line 489 because the condition on line 478 was always true

479 for item in section.content: 

480 if isinstance(item, dict): 480 ↛ 486line 480 didn't jump to line 486 because the condition on line 480 was always true

481 if item.get("type") == "table": 

482 content.append(_table_to_html(item)) 

483 elif item.get("type") == "figure": 483 ↛ 479line 483 didn't jump to line 479 because the condition on line 483 was always true

484 content.append(_figure_to_html(item)) 

485 else: 

486 content.append(f"<p>{item}</p>") 

487 

488 # Subsections 

489 for subsec in section.subsections: 

490 if not subsec.visible: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true

491 continue 

492 sub_tag = f"h{min(subsec.level + 1, 6)}" 

493 content.append(f"<{sub_tag}>{subsec.title}</{sub_tag}>") 

494 if isinstance(subsec.content, str): 494 ↛ 489line 494 didn't jump to line 489 because the condition on line 494 was always true

495 content.append(f"<p>{subsec.content}</p>") 

496 

497 if collapsible and section.collapsible: 

498 content.append("</div>") 

499 

500 content.append("</section>") 

501 

502 return "\n".join(content) 

503 

504 

505def _table_to_html(table: dict[str, Any]) -> str: 

506 """Convert table dictionary to HTML.""" 

507 lines = ['<table class="sortable">'] 

508 

509 headers = table.get("headers", []) 

510 data = table.get("data", []) 

511 

512 if headers: 512 ↛ 518line 512 didn't jump to line 518 because the condition on line 512 was always true

513 lines.append("<thead><tr>") 

514 for h in headers: 

515 lines.append(f"<th>{h}</th>") 

516 lines.append("</tr></thead>") 

517 

518 lines.append("<tbody>") 

519 for row in data: 

520 lines.append("<tr>") 

521 for cell in row: 

522 # Apply visual emphasis for PASS/FAIL 

523 cell_str = str(cell) 

524 if "PASS" in cell_str.upper(): 

525 lines.append(f'<td class="pass">{cell}</td>') 

526 elif "FAIL" in cell_str.upper(): 

527 lines.append(f'<td class="fail">{cell}</td>') 

528 elif "WARNING" in cell_str.upper(): 

529 lines.append(f'<td class="warning">{cell}</td>') 

530 else: 

531 lines.append(f"<td>{cell}</td>") 

532 lines.append("</tr>") 

533 lines.append("</tbody>") 

534 lines.append("</table>") 

535 

536 if table.get("caption"): 

537 lines.append(f"<caption>{table['caption']}</caption>") 

538 

539 return "\n".join(lines) 

540 

541 

542def _figure_to_html(figure: dict[str, Any]) -> str: 

543 """Convert figure dictionary to HTML.""" 

544 width = figure.get("width", "100%") 

545 caption = figure.get("caption", "") 

546 

547 html = f'<figure style="max-width: {width}; margin: 20px auto;">' 

548 

549 # Handle different figure types 

550 fig_obj = figure.get("figure") 

551 if isinstance(fig_obj, str): 

552 # Assume it's a path to an image 

553 html += f'<img src="{fig_obj}" alt="{caption}" style="width: 100%;">' 

554 else: 

555 # Placeholder for matplotlib figures 

556 html += f'<div class="figure-placeholder">[Figure: {caption}]</div>' 

557 

558 if caption: 

559 html += f"<figcaption>{caption}</figcaption>" 

560 

561 html += "</figure>" 

562 return html 

563 

564 

565def save_html_report( 

566 report: Report, 

567 path: str | Path, 

568 **kwargs: Any, 

569) -> None: 

570 """Save report as HTML file. 

571 

572 Args: 

573 report: Report object. 

574 path: Output file path. 

575 **kwargs: Additional options for generate_html_report. 

576 """ 

577 html_content = generate_html_report(report, **kwargs) 

578 Path(path).write_text(html_content, encoding="utf-8")