Coverage for src / tracekit / reporting / core.py: 97%
207 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"""Core report generation for TraceKit.
3This module provides the main report generation functionality including
4report structure, configuration, and output generation.
7Example:
8 >>> from tracekit.reporting import generate_report
9 >>> report = generate_report(results, "report.pdf", verbosity="summary")
10"""
12from __future__ import annotations
14from dataclasses import dataclass, field
15from datetime import datetime
16from pathlib import Path
17from typing import TYPE_CHECKING, Any, Literal
19if TYPE_CHECKING:
20 from numpy.typing import NDArray
23@dataclass
24class Section:
25 """A section in a report.
27 Attributes:
28 title: Section title.
29 content: Section content (text, tables, figures).
30 level: Heading level (1-4).
31 collapsible: Whether section is collapsible in HTML output.
32 visible: Whether section is visible in output.
33 """
35 title: str
36 content: str | list[Any] = ""
37 level: int = 2
38 collapsible: bool = False
39 visible: bool = True
40 subsections: list[Section] = field(default_factory=list)
43@dataclass
44class ReportConfig:
45 """Report generation configuration.
47 Attributes:
48 title: Report title.
49 author: Report author.
50 verbosity: Detail level (executive, summary, standard, detailed, debug).
51 format: Output format (pdf, html, markdown, docx).
52 template: Template name or path.
53 page_size: Page size (letter, A4).
54 margins: Page margins in inches.
55 logo_path: Path to logo image.
56 watermark: Watermark text.
57 show_toc: Include table of contents.
58 show_page_numbers: Include page numbers.
59 """
61 title: str = "TraceKit Analysis Report"
62 author: str = ""
63 verbosity: Literal["executive", "summary", "standard", "detailed", "debug"] = "standard"
64 format: Literal["pdf", "html", "markdown", "docx"] = "pdf"
65 template: str = "default"
66 page_size: Literal["letter", "A4"] = "letter"
67 margins: float = 1.0
68 logo_path: str | None = None
69 watermark: str | None = None
70 show_toc: bool = True
71 show_page_numbers: bool = True
72 created: datetime = field(default_factory=datetime.now)
75@dataclass
76class Report:
77 """A generated report.
79 Attributes:
80 config: Report configuration.
81 sections: Report sections.
82 metadata: Report metadata.
83 figures: Embedded figures.
84 tables: Embedded tables.
85 """
87 config: ReportConfig
88 sections: list[Section] = field(default_factory=list)
89 metadata: dict[str, Any] = field(default_factory=dict)
90 figures: list[Any] = field(default_factory=list)
91 tables: list[Any] = field(default_factory=list)
93 def add_section(
94 self,
95 title: str,
96 content: str | list[Any] = "",
97 level: int = 2,
98 **kwargs: Any,
99 ) -> Section:
100 """Add a section to the report.
102 Args:
103 title: Section title.
104 content: Section content.
105 level: Heading level.
106 **kwargs: Additional section options.
108 Returns:
109 The created Section.
110 """
111 section = Section(title=title, content=content, level=level, **kwargs)
112 self.sections.append(section)
113 return section
115 def add_table(
116 self,
117 data: list[list[Any]] | NDArray[Any],
118 headers: list[str] | None = None,
119 caption: str = "",
120 ) -> dict: # type: ignore[type-arg]
121 """Add a table to the report.
123 Args:
124 data: Table data as 2D list or array.
125 headers: Column headers.
126 caption: Table caption.
128 Returns:
129 Table reference dictionary.
130 """
131 table = {
132 "type": "table",
133 "data": data if isinstance(data, list) else data.tolist(),
134 "headers": headers,
135 "caption": caption,
136 "id": len(self.tables),
137 }
138 self.tables.append(table)
139 return table
141 def add_figure(
142 self,
143 figure: Any,
144 caption: str = "",
145 width: str = "100%",
146 ) -> dict: # type: ignore[type-arg]
147 """Add a figure to the report.
149 Args:
150 figure: Matplotlib figure or image path.
151 caption: Figure caption.
152 width: Figure width.
154 Returns:
155 Figure reference dictionary.
156 """
157 fig = {
158 "type": "figure",
159 "figure": figure,
160 "caption": caption,
161 "width": width,
162 "id": len(self.figures),
163 }
164 self.figures.append(fig)
165 return fig
167 def generate_executive_summary(
168 self,
169 results: dict[str, Any],
170 key_findings: list[str] | None = None,
171 ) -> str:
172 """Generate executive summary from results.
174 Args:
175 results: Analysis results dictionary.
176 key_findings: List of key findings to highlight.
178 Returns:
179 Executive summary text.
180 """
181 summary_parts = []
183 # Overall status
184 if "pass_count" in results and "total_count" in results:
185 pass_count = results["pass_count"]
186 total = results["total_count"]
187 if pass_count == total:
188 summary_parts.append(f"All {total} tests passed.")
189 else:
190 fail_count = total - pass_count
191 summary_parts.append(
192 f"{fail_count} of {total} tests failed ({fail_count / total * 100:.0f}%)."
193 )
195 # Key findings
196 if key_findings:
197 summary_parts.append("\nKey Findings:")
198 for finding in key_findings[:5]: # Top 5
199 summary_parts.append(f"- {finding}")
201 # Margin summary
202 if "min_margin" in results:
203 margin = results["min_margin"]
204 if margin < 10:
205 summary_parts.append(f"\nWarning: Minimum margin is {margin:.1f}%")
206 elif margin < 20:
207 summary_parts.append(f"\nNote: Minimum margin is {margin:.1f}%")
209 return "\n".join(summary_parts)
211 def to_markdown(self) -> str:
212 """Convert report to Markdown format.
214 Returns:
215 Markdown string.
216 """
217 lines = []
219 # Title
220 lines.append(f"# {self.config.title}")
221 lines.append("")
223 if self.config.author:
224 lines.append(f"**Author:** {self.config.author}")
225 lines.append(f"**Date:** {self.config.created.strftime('%Y-%m-%d %H:%M')}")
226 lines.append("")
228 # Sections
229 for section in self.sections:
230 if not section.visible:
231 continue
233 prefix = "#" * (section.level + 1)
234 lines.append(f"{prefix} {section.title}")
235 lines.append("")
237 if isinstance(section.content, str):
238 lines.append(section.content)
239 elif isinstance(section.content, list): 239 ↛ 245line 239 didn't jump to line 245 because the condition on line 239 was always true
240 for item in section.content:
241 if isinstance(item, dict) and item.get("type") == "table": 241 ↛ 244line 241 didn't jump to line 244 because the condition on line 241 was always true
242 lines.extend(self._table_to_markdown(item))
243 else:
244 lines.append(str(item))
245 lines.append("")
247 # Subsections
248 for subsec in section.subsections:
249 if not subsec.visible: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 continue
251 prefix = "#" * (subsec.level + 1)
252 lines.append(f"{prefix} {subsec.title}")
253 lines.append("")
254 if isinstance(subsec.content, str):
255 lines.append(subsec.content)
256 lines.append("")
258 return "\n".join(lines)
260 def _table_to_markdown(self, table: dict) -> list[str]: # type: ignore[type-arg]
261 """Convert table to Markdown format."""
262 lines = []
263 headers = table.get("headers", [])
264 data = table.get("data", [])
266 if headers:
267 lines.append("| " + " | ".join(str(h) for h in headers) + " |")
268 lines.append("| " + " | ".join("---" for _ in headers) + " |")
270 for row in data:
271 lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
273 if table.get("caption"):
274 lines.append("")
275 lines.append(f"*{table['caption']}*")
277 return lines
279 def save(self, path: str | Path) -> None:
280 """Save report to file.
282 Args:
283 path: Output file path.
284 """
285 path = Path(path)
287 if path.suffix == ".md":
288 content = self.to_markdown()
289 path.write_text(content)
290 elif path.suffix == ".html":
291 content = self.to_html()
292 path.write_text(content)
293 else:
294 # For PDF and other formats, use Markdown as intermediate
295 content = self.to_markdown()
296 path.with_suffix(".md").write_text(content)
298 def to_html(self) -> str:
299 """Convert report to HTML format.
301 Returns:
302 HTML string.
303 """
304 lines = [
305 "<!DOCTYPE html>",
306 "<html>",
307 "<head>",
308 f"<title>{self.config.title}</title>",
309 "<style>",
310 "body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }",
311 "h1 { color: #333; }",
312 "h2 { color: #555; border-bottom: 1px solid #ddd; }",
313 "table { border-collapse: collapse; width: 100%; margin: 10px 0; }",
314 "th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }",
315 "th { background-color: #f2f2f2; }",
316 "tr:nth-child(even) { background-color: #f9f9f9; }",
317 ".pass { color: green; }",
318 ".fail { color: red; }",
319 ".warning { color: orange; }",
320 "</style>",
321 "</head>",
322 "<body>",
323 f"<h1>{self.config.title}</h1>",
324 ]
326 if self.config.author:
327 lines.append(f"<p><strong>Author:</strong> {self.config.author}</p>")
328 lines.append(
329 f"<p><strong>Date:</strong> {self.config.created.strftime('%Y-%m-%d %H:%M')}</p>"
330 )
332 for section in self.sections:
333 if not section.visible:
334 continue
336 tag = f"h{min(section.level + 1, 6)}"
337 lines.append(f"<{tag}>{section.title}</{tag}>")
339 if isinstance(section.content, str):
340 lines.append(f"<p>{section.content}</p>")
341 elif isinstance(section.content, list): 341 ↛ 332line 341 didn't jump to line 332 because the condition on line 341 was always true
342 for item in section.content:
343 if isinstance(item, dict) and item.get("type") == "table": 343 ↛ 346line 343 didn't jump to line 346 because the condition on line 343 was always true
344 lines.extend(self._table_to_html(item))
345 else:
346 lines.append(f"<p>{item}</p>")
348 lines.extend(["</body>", "</html>"])
349 return "\n".join(lines)
351 def _table_to_html(self, table: dict) -> list[str]: # type: ignore[type-arg]
352 """Convert table to HTML format."""
353 lines = ["<table>"]
354 headers = table.get("headers", [])
355 data = table.get("data", [])
357 if headers:
358 lines.append("<thead><tr>")
359 for h in headers:
360 lines.append(f"<th>{h}</th>")
361 lines.append("</tr></thead>")
363 lines.append("<tbody>")
364 for row in data:
365 lines.append("<tr>")
366 for cell in row:
367 lines.append(f"<td>{cell}</td>")
368 lines.append("</tr>")
369 lines.append("</tbody>")
371 lines.append("</table>")
373 if table.get("caption"):
374 lines.append(f"<p><em>{table['caption']}</em></p>")
376 return lines
379def generate_report(
380 results: dict[str, Any],
381 output_path: str | Path | None = None,
382 *,
383 title: str = "TraceKit Analysis Report",
384 verbosity: Literal["executive", "summary", "standard", "detailed", "debug"] = ("standard"),
385 template: str = "default",
386 formats: list[str] | None = None,
387 **kwargs: Any,
388) -> Report:
389 """Generate a report from analysis results.
391 Creates a formatted report from analysis results with configurable
392 verbosity and output formats.
394 Args:
395 results: Analysis results dictionary.
396 output_path: Output file path (optional).
397 title: Report title.
398 verbosity: Detail level.
399 template: Template name.
400 formats: Output formats (pdf, html, markdown).
401 **kwargs: Additional configuration options.
403 Returns:
404 Generated Report object.
406 Example:
407 >>> report = generate_report(results, "report.pdf", verbosity="summary")
408 """
409 config = ReportConfig(
410 title=title,
411 verbosity=verbosity,
412 template=template,
413 **{k: v for k, v in kwargs.items() if hasattr(ReportConfig, k)},
414 )
416 report = Report(config=config, metadata={"source": "TraceKit"})
418 # Add executive summary
419 if verbosity in ("executive", "summary", "standard", "detailed", "debug"): 419 ↛ 424line 419 didn't jump to line 424 because the condition on line 419 was always true
420 summary = report.generate_executive_summary(results)
421 report.add_section("Executive Summary", summary, level=1)
423 # Add results section
424 if verbosity in ("summary", "standard", "detailed", "debug"):
425 _add_results_section(report, results, verbosity)
427 # Add methodology section
428 if verbosity in ("standard", "detailed", "debug"):
429 _add_methodology_section(report, results)
431 # Add raw data section
432 if verbosity in ("detailed", "debug"):
433 _add_raw_data_section(report, results)
435 # Save if output path provided
436 if output_path:
437 output_path = Path(output_path)
438 if formats:
439 for fmt in formats:
440 path = output_path.with_suffix(f".{fmt}")
441 report.save(path)
442 else:
443 report.save(output_path)
445 return report
448def _add_results_section(
449 report: Report,
450 results: dict[str, Any],
451 verbosity: str,
452) -> None:
453 """Add results section to report."""
454 report.add_section("Test Results", level=1)
456 # Create results table
457 if "measurements" in results:
458 measurements = results["measurements"]
459 headers = ["Parameter", "Value", "Specification", "Status"]
460 data = []
462 for name, meas in measurements.items():
463 value = meas.get("value", "N/A")
464 spec = meas.get("specification", "N/A")
465 status = "PASS" if meas.get("passed", True) else "FAIL"
466 data.append([name, value, spec, status])
468 report.add_table(data, headers, "Measurement Results")
471def _add_methodology_section(
472 report: Report,
473 results: dict[str, Any],
474) -> None:
475 """Add methodology section to report."""
476 content = []
478 if "sample_rate" in results:
479 content.append(f"Sample rate: {results['sample_rate']} Hz")
480 if "num_samples" in results:
481 content.append(f"Number of samples: {results['num_samples']}")
482 if "analysis_time" in results:
483 content.append(f"Analysis time: {results['analysis_time']:.3f} seconds")
485 report.add_section(
486 "Methodology",
487 "\n".join(content) if content else "Standard analysis methodology applied.",
488 level=1,
489 )
492def _add_raw_data_section(
493 report: Report,
494 results: dict[str, Any],
495) -> None:
496 """Add raw data section to report."""
497 content = []
499 for key, value in results.items():
500 if isinstance(value, int | float | str):
501 content.append(f"{key}: {value}")
503 report.add_section(
504 "Raw Data",
505 "\n".join(content) if content else "No raw data available.",
506 level=1,
507 collapsible=True,
508 )