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

1"""Core report generation for TraceKit. 

2 

3This module provides the main report generation functionality including 

4report structure, configuration, and output generation. 

5 

6 

7Example: 

8 >>> from tracekit.reporting import generate_report 

9 >>> report = generate_report(results, "report.pdf", verbosity="summary") 

10""" 

11 

12from __future__ import annotations 

13 

14from dataclasses import dataclass, field 

15from datetime import datetime 

16from pathlib import Path 

17from typing import TYPE_CHECKING, Any, Literal 

18 

19if TYPE_CHECKING: 

20 from numpy.typing import NDArray 

21 

22 

23@dataclass 

24class Section: 

25 """A section in a report. 

26 

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 """ 

34 

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) 

41 

42 

43@dataclass 

44class ReportConfig: 

45 """Report generation configuration. 

46 

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 """ 

60 

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) 

73 

74 

75@dataclass 

76class Report: 

77 """A generated report. 

78 

79 Attributes: 

80 config: Report configuration. 

81 sections: Report sections. 

82 metadata: Report metadata. 

83 figures: Embedded figures. 

84 tables: Embedded tables. 

85 """ 

86 

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) 

92 

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. 

101 

102 Args: 

103 title: Section title. 

104 content: Section content. 

105 level: Heading level. 

106 **kwargs: Additional section options. 

107 

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 

114 

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. 

122 

123 Args: 

124 data: Table data as 2D list or array. 

125 headers: Column headers. 

126 caption: Table caption. 

127 

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 

140 

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. 

148 

149 Args: 

150 figure: Matplotlib figure or image path. 

151 caption: Figure caption. 

152 width: Figure width. 

153 

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 

166 

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. 

173 

174 Args: 

175 results: Analysis results dictionary. 

176 key_findings: List of key findings to highlight. 

177 

178 Returns: 

179 Executive summary text. 

180 """ 

181 summary_parts = [] 

182 

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 ) 

194 

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}") 

200 

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}%") 

208 

209 return "\n".join(summary_parts) 

210 

211 def to_markdown(self) -> str: 

212 """Convert report to Markdown format. 

213 

214 Returns: 

215 Markdown string. 

216 """ 

217 lines = [] 

218 

219 # Title 

220 lines.append(f"# {self.config.title}") 

221 lines.append("") 

222 

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("") 

227 

228 # Sections 

229 for section in self.sections: 

230 if not section.visible: 

231 continue 

232 

233 prefix = "#" * (section.level + 1) 

234 lines.append(f"{prefix} {section.title}") 

235 lines.append("") 

236 

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("") 

246 

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("") 

257 

258 return "\n".join(lines) 

259 

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", []) 

265 

266 if headers: 

267 lines.append("| " + " | ".join(str(h) for h in headers) + " |") 

268 lines.append("| " + " | ".join("---" for _ in headers) + " |") 

269 

270 for row in data: 

271 lines.append("| " + " | ".join(str(cell) for cell in row) + " |") 

272 

273 if table.get("caption"): 

274 lines.append("") 

275 lines.append(f"*{table['caption']}*") 

276 

277 return lines 

278 

279 def save(self, path: str | Path) -> None: 

280 """Save report to file. 

281 

282 Args: 

283 path: Output file path. 

284 """ 

285 path = Path(path) 

286 

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) 

297 

298 def to_html(self) -> str: 

299 """Convert report to HTML format. 

300 

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 ] 

325 

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 ) 

331 

332 for section in self.sections: 

333 if not section.visible: 

334 continue 

335 

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

337 lines.append(f"<{tag}>{section.title}</{tag}>") 

338 

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>") 

347 

348 lines.extend(["</body>", "</html>"]) 

349 return "\n".join(lines) 

350 

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", []) 

356 

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>") 

362 

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>") 

370 

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

372 

373 if table.get("caption"): 

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

375 

376 return lines 

377 

378 

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. 

390 

391 Creates a formatted report from analysis results with configurable 

392 verbosity and output formats. 

393 

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. 

402 

403 Returns: 

404 Generated Report object. 

405 

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 ) 

415 

416 report = Report(config=config, metadata={"source": "TraceKit"}) 

417 

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) 

422 

423 # Add results section 

424 if verbosity in ("summary", "standard", "detailed", "debug"): 

425 _add_results_section(report, results, verbosity) 

426 

427 # Add methodology section 

428 if verbosity in ("standard", "detailed", "debug"): 

429 _add_methodology_section(report, results) 

430 

431 # Add raw data section 

432 if verbosity in ("detailed", "debug"): 

433 _add_raw_data_section(report, results) 

434 

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) 

444 

445 return report 

446 

447 

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) 

455 

456 # Create results table 

457 if "measurements" in results: 

458 measurements = results["measurements"] 

459 headers = ["Parameter", "Value", "Specification", "Status"] 

460 data = [] 

461 

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]) 

467 

468 report.add_table(data, headers, "Measurement Results") 

469 

470 

471def _add_methodology_section( 

472 report: Report, 

473 results: dict[str, Any], 

474) -> None: 

475 """Add methodology section to report.""" 

476 content = [] 

477 

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") 

484 

485 report.add_section( 

486 "Methodology", 

487 "\n".join(content) if content else "Standard analysis methodology applied.", 

488 level=1, 

489 ) 

490 

491 

492def _add_raw_data_section( 

493 report: Report, 

494 results: dict[str, Any], 

495) -> None: 

496 """Add raw data section to report.""" 

497 content = [] 

498 

499 for key, value in results.items(): 

500 if isinstance(value, int | float | str): 

501 content.append(f"{key}: {value}") 

502 

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 )