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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""HTML report generation for TraceKit.
3This module provides professional HTML report generation with modern features
4including responsive design, interactive plots, and collapsible sections.
6Features:
7 - Professional formatting standards
8 - Visual emphasis system
9 - Smart content filtering (interactive)
10 - Modern HTML with progressive disclosure
11 - Collapsible sections mechanism
13Example:
14 >>> from tracekit.reporting.html import generate_html_report
15 >>> html = generate_html_report(report, interactive=True, dark_mode=False)
16"""
18from __future__ import annotations
20from pathlib import Path
21from typing import TYPE_CHECKING, Any
23if TYPE_CHECKING:
24 from tracekit.reporting.core import Report
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.
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).
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 ]
66 return "\n".join(html_parts)
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>"""
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}
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}
110* {
111 box-sizing: border-box;
112 margin: 0;
113 padding: 0;
114}
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}
126.container {
127 max-width: 1200px;
128 margin: 0 auto;
129 padding: 1in;
130}
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}
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; }
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}
153pre {
154 padding: 10px;
155 overflow-x: auto;
156}
158/* Visual Emphasis */
159.pass {
160 color: var(--success-color);
161 font-weight: bold;
162}
164.fail {
165 color: var(--danger-color);
166 font-weight: bold;
167}
169.warning {
170 color: var(--warning-color);
171 font-weight: bold;
172}
174.pass::before { content: '\\2713 '; }
175.fail::before { content: '\\2717 '; }
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}
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}
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}
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}
208.callout-title {
209 font-weight: bold;
210 margin-bottom: 10px;
211}
213/* Tables */
214table {
215 border-collapse: collapse;
216 width: 100%;
217 margin: 15px 0;
218 font-size: 10pt;
219}
221th, td {
222 border: 1px solid var(--border-color);
223 padding: 8px 12px;
224 text-align: left;
225}
227th {
228 background-color: var(--table-header-bg);
229 font-weight: bold;
230 font-family: Arial, Helvetica, sans-serif;
231}
233tr:nth-child(even) {
234 background-color: var(--table-alt-row-bg);
235}
237tr:hover {
238 background-color: rgba(52, 152, 219, 0.1);
239}
241caption {
242 caption-side: bottom;
243 font-style: italic;
244 padding: 8px;
245 text-align: left;
246}
248/* Collapsible sections */
249.collapsible {
250 cursor: pointer;
251 user-select: none;
252 display: flex;
253 align-items: center;
254 gap: 8px;
255}
257.collapsible::before {
258 content: '\\25BC';
259 display: inline-block;
260 transition: transform 0.3s;
261}
263.collapsible.collapsed::before {
264 transform: rotate(-90deg);
265}
267.collapsible-content {
268 max-height: 5000px;
269 overflow: hidden;
270 transition: max-height 0.3s ease-out;
271}
273.collapsible-content.collapsed {
274 max-height: 0;
275}
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}
286.metadata-item {
287 display: inline-block;
288 margin-right: 20px;
289}
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}
301nav ul {
302 list-style: none;
303 display: flex;
304 gap: 20px;
305 flex-wrap: wrap;
306}
308nav a {
309 color: white;
310 text-decoration: none;
311}
313nav a:hover {
314 text-decoration: underline;
315}
317/* Responsive design */
318@media (max-width: 768px) {
319 .container {
320 padding: 0.5in;
321 }
323 h1 { font-size: 20pt; }
324 h2 { font-size: 16pt; }
325 h3 { font-size: 12pt; }
327 table {
328 font-size: 9pt;
329 }
331 nav ul {
332 flex-direction: column;
333 gap: 10px;
334 }
335}
337/* Print styles */
338@media print {
339 .container {
340 max-width: 100%;
341 padding: 0;
342 }
344 nav {
345 display: none;
346 }
348 .collapsible-content {
349 max-height: none !important;
350 }
351}
352</style>"""
353 return styles
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');
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 });
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 }
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});
395function sortTable(table, column) {
396 const tbody = table.querySelector('tbody');
397 const rows = Array.from(tbody.querySelectorAll('tr'));
399 rows.sort(function(a, b) {
400 const aText = a.cells[column].textContent.trim();
401 const bText = b.cells[column].textContent.trim();
403 // Try numeric comparison first
404 const aNum = parseFloat(aText);
405 const bNum = parseFloat(bText);
407 if (!isNaN(aNum) && !isNaN(bNum)) {
408 return aNum - bNum;
409 }
411 // Fall back to string comparison
412 return aText.localeCompare(bText);
413 });
415 rows.forEach(function(row) {
416 tbody.appendChild(row);
417 });
418}
419</script>"""
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>')
430 return f"""
431<nav>
432 <ul>
433 {"".join(nav_items)}
434 </ul>
435</nav>"""
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 )
453 return f'<div class="metadata">{" ".join(items)}</div>'
456def _generate_html_content(report: Report, collapsible: bool) -> str:
457 """Generate main content sections."""
458 content = []
460 for section in report.sections:
461 if not section.visible:
462 continue
464 section_id = section.title.lower().replace(" ", "-")
465 content.append(f'<section id="{section_id}">')
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}>")
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>")
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>")
497 if collapsible and section.collapsible:
498 content.append("</div>")
500 content.append("</section>")
502 return "\n".join(content)
505def _table_to_html(table: dict[str, Any]) -> str:
506 """Convert table dictionary to HTML."""
507 lines = ['<table class="sortable">']
509 headers = table.get("headers", [])
510 data = table.get("data", [])
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>")
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>")
536 if table.get("caption"):
537 lines.append(f"<caption>{table['caption']}</caption>")
539 return "\n".join(lines)
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", "")
547 html = f'<figure style="max-width: {width}; margin: 20px auto;">'
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>'
558 if caption:
559 html += f"<figcaption>{caption}</figcaption>"
561 html += "</figure>"
562 return html
565def save_html_report(
566 report: Report,
567 path: str | Path,
568 **kwargs: Any,
569) -> None:
570 """Save report as HTML file.
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")