Coverage for src / tracekit / reporting / template_system.py: 93%

134 statements  

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

1"""Template system for TraceKit reports. 

2 

3This module provides template loading, management, inheritance, and built-in 

4report templates. 

5 

6 

7Example: 

8 >>> from tracekit.reporting.template_system import load_template, register_template 

9 >>> template = load_template("compliance") 

10 >>> # Create custom template extending compliance 

11 >>> custom = load_template("compliance") 

12 >>> register_template("my_compliance", custom) 

13""" 

14 

15from __future__ import annotations 

16 

17import copy 

18from dataclasses import dataclass, field 

19from pathlib import Path 

20from typing import Any 

21 

22import yaml 

23 

24 

25@dataclass 

26class TemplateSection: 

27 """A section definition in a template. 

28 

29 Attributes: 

30 title: Section title. 

31 content_type: Type of content (text, table, figure, measurement). 

32 condition: Conditional expression for inclusion. 

33 template: Jinja2 template for content. 

34 subsections: Child sections. 

35 order: Section order (for sorting during inheritance merge). 

36 override: If True, replaces parent section with same title. 

37 

38 References: 

39 REPORT-007: Template Definition Format 

40 """ 

41 

42 title: str 

43 content_type: str = "text" 

44 condition: str | None = None 

45 template: str = "" 

46 subsections: list[TemplateSection] = field(default_factory=list) 

47 order: int = 0 

48 override: bool = False 

49 

50 

51@dataclass 

52class ReportTemplate: 

53 """A report template definition. 

54 

55 Attributes: 

56 name: Template name. 

57 version: Template version. 

58 description: Template description. 

59 author: Template author. 

60 extends: Parent template name for inheritance (REPORT-005). 

61 sections: Template sections. 

62 styles: Style definitions. 

63 metadata: Additional metadata. 

64 overrides: Section-specific overrides (REPORT-008). 

65 

66 References: 

67 REPORT-005: Template Inheritance 

68 REPORT-007: Template Definition Format 

69 REPORT-008: Template Overrides 

70 """ 

71 

72 name: str 

73 version: str = "1.0" 

74 description: str = "" 

75 author: str = "" 

76 extends: str | None = None 

77 sections: list[TemplateSection] = field(default_factory=list) 

78 styles: dict[str, Any] = field(default_factory=dict) 

79 metadata: dict[str, Any] = field(default_factory=dict) 

80 overrides: dict[str, dict[str, Any]] = field(default_factory=dict) 

81 

82 

83# Built-in templates 

84BUILTIN_TEMPLATES: dict[str, ReportTemplate] = { 

85 "default": ReportTemplate( 

86 name="Default Report", 

87 version="1.0", 

88 description="Standard analysis report template", 

89 sections=[ 

90 TemplateSection( 

91 title="Executive Summary", 

92 content_type="text", 

93 template="{{ summary }}", 

94 order=0, 

95 ), 

96 TemplateSection( 

97 title="Test Results", 

98 content_type="table", 

99 template="{{ results_table }}", 

100 order=10, 

101 ), 

102 TemplateSection( 

103 title="Methodology", 

104 content_type="text", 

105 condition="verbosity != 'executive'", 

106 order=20, 

107 ), 

108 ], 

109 ), 

110 "compliance": ReportTemplate( 

111 name="Compliance Report", 

112 version="1.0", 

113 description="Regulatory compliance testing report", 

114 extends="default", 

115 sections=[ 

116 TemplateSection( 

117 title="Executive Summary", 

118 content_type="text", 

119 template="{{ compliance_summary }}", 

120 override=True, 

121 order=0, 

122 ), 

123 TemplateSection( 

124 title="Test Standards", 

125 content_type="text", 

126 template="Standards tested: {{ standards }}", 

127 order=5, 

128 ), 

129 TemplateSection( 

130 title="Violations", 

131 content_type="table", 

132 condition="has_violations", 

133 order=15, 

134 ), 

135 TemplateSection( 

136 title="Certificate", 

137 content_type="text", 

138 order=30, 

139 ), 

140 ], 

141 ), 

142 "characterization": ReportTemplate( 

143 name="Characterization Report", 

144 version="1.0", 

145 description="Device characterization report", 

146 sections=[ 

147 TemplateSection( 

148 title="Summary", 

149 content_type="text", 

150 order=0, 

151 ), 

152 TemplateSection( 

153 title="Timing Parameters", 

154 content_type="table", 

155 order=10, 

156 ), 

157 TemplateSection( 

158 title="Signal Quality", 

159 content_type="table", 

160 order=20, 

161 ), 

162 TemplateSection( 

163 title="Margin Analysis", 

164 content_type="table", 

165 order=30, 

166 ), 

167 TemplateSection( 

168 title="Waveform Plots", 

169 content_type="figure", 

170 order=40, 

171 ), 

172 ], 

173 ), 

174 "debug": ReportTemplate( 

175 name="Debug Report", 

176 version="1.0", 

177 description="Detailed debug report with full data", 

178 sections=[ 

179 TemplateSection( 

180 title="Summary", 

181 content_type="text", 

182 order=0, 

183 ), 

184 TemplateSection( 

185 title="Error Analysis", 

186 content_type="text", 

187 order=10, 

188 ), 

189 TemplateSection( 

190 title="Protocol Decode", 

191 content_type="table", 

192 order=20, 

193 ), 

194 TemplateSection( 

195 title="Timing Diagram", 

196 content_type="figure", 

197 order=30, 

198 ), 

199 TemplateSection( 

200 title="Raw Data", 

201 content_type="text", 

202 order=40, 

203 ), 

204 TemplateSection( 

205 title="Provenance", 

206 content_type="text", 

207 order=50, 

208 ), 

209 ], 

210 ), 

211 "production": ReportTemplate( 

212 name="Production Report", 

213 version="1.0", 

214 description="Production test report with pass/fail and yield", 

215 sections=[ 

216 TemplateSection( 

217 title="Test Summary", 

218 content_type="text", 

219 template="Tested: {{ total }} | Passed: {{ passed }} | Failed: {{ failed }}", 

220 order=0, 

221 ), 

222 TemplateSection( 

223 title="Results", 

224 content_type="table", 

225 order=10, 

226 ), 

227 TemplateSection( 

228 title="Yield Analysis", 

229 content_type="table", 

230 order=20, 

231 ), 

232 ], 

233 ), 

234 "comparison": ReportTemplate( 

235 name="Comparison Report", 

236 version="1.0", 

237 description="Before/after comparison report", 

238 sections=[ 

239 TemplateSection( 

240 title="Summary", 

241 content_type="text", 

242 order=0, 

243 ), 

244 TemplateSection( 

245 title="Differences", 

246 content_type="table", 

247 order=10, 

248 ), 

249 TemplateSection( 

250 title="Side-by-Side Comparison", 

251 content_type="figure", 

252 order=20, 

253 ), 

254 ], 

255 ), 

256} 

257 

258# User-registered templates (REPORT-006) 

259_USER_TEMPLATES: dict[str, ReportTemplate] = {} 

260 

261 

262def register_template( 

263 name: str, 

264 template: ReportTemplate, 

265 *, 

266 overwrite: bool = False, 

267) -> None: 

268 """Register a user template. 

269 

270 Allows users to define custom templates or extend built-in ones. 

271 

272 Args: 

273 name: Template name for registration. 

274 template: Template definition. 

275 overwrite: If True, allows overwriting existing templates. 

276 

277 Raises: 

278 ValueError: If name exists and overwrite=False. 

279 

280 Example: 

281 >>> from tracekit.reporting.template_system import ( 

282 ... register_template, ReportTemplate, TemplateSection 

283 ... ) 

284 >>> my_template = ReportTemplate( 

285 ... name="Custom Report", 

286 ... sections=[TemplateSection(title="My Section")] 

287 ... ) 

288 >>> register_template("custom", my_template) 

289 

290 References: 

291 REPORT-006: User Template Registration 

292 """ 

293 if name in _USER_TEMPLATES and not overwrite: 

294 raise ValueError(f"Template '{name}' already registered. Use overwrite=True to replace.") 

295 

296 if name in BUILTIN_TEMPLATES and not overwrite: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 raise ValueError(f"Cannot overwrite built-in template '{name}'. Use overwrite=True.") 

298 

299 _USER_TEMPLATES[name] = template 

300 

301 

302def unregister_template(name: str) -> bool: 

303 """Unregister a user template. 

304 

305 Args: 

306 name: Template name. 

307 

308 Returns: 

309 True if template was removed, False if not found. 

310 

311 References: 

312 REPORT-006: User Template Registration 

313 """ 

314 if name in _USER_TEMPLATES: 

315 del _USER_TEMPLATES[name] 

316 return True 

317 return False 

318 

319 

320def extend_template( 

321 base_name: str, 

322 *, 

323 name: str | None = None, 

324 description: str | None = None, 

325 add_sections: list[TemplateSection] | None = None, 

326 remove_sections: list[str] | None = None, 

327 section_overrides: dict[str, dict[str, Any]] | None = None, 

328 style_overrides: dict[str, Any] | None = None, 

329) -> ReportTemplate: 

330 """Create a new template by extending an existing one. 

331 

332 Implements template inheritance with section merging and overrides. 

333 

334 Args: 

335 base_name: Name of template to extend. 

336 name: Name for new template. 

337 description: Description for new template. 

338 add_sections: New sections to add. 

339 remove_sections: Section titles to remove. 

340 section_overrides: Dict of section title -> field overrides. 

341 style_overrides: Style fields to override. 

342 

343 Returns: 

344 New ReportTemplate with inherited and modified sections. 

345 

346 Example: 

347 >>> # Create custom compliance template 

348 >>> custom = extend_template( 

349 ... "compliance", 

350 ... name="FDA Compliance", 

351 ... add_sections=[TemplateSection(title="FDA Requirements")], 

352 ... section_overrides={ 

353 ... "Certificate": {"template": "FDA Certificate: {{ cert_id }}"} 

354 ... } 

355 ... ) 

356 

357 References: 

358 REPORT-005: Template Inheritance 

359 REPORT-008: Template Overrides 

360 """ 

361 # Load base template (resolving inheritance chain) 

362 base = load_template(base_name) 

363 

364 # Deep copy to avoid modifying original 

365 new_template = copy.deepcopy(base) 

366 

367 # Update metadata 

368 if name: 

369 new_template.name = name 

370 if description: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 new_template.description = description 

372 new_template.extends = base_name 

373 

374 # Apply section removals 

375 if remove_sections: 

376 new_template.sections = [ 

377 sec for sec in new_template.sections if sec.title not in remove_sections 

378 ] 

379 

380 # Apply section overrides 

381 if section_overrides: 

382 for sec in new_template.sections: 

383 if sec.title in section_overrides: 

384 overrides = section_overrides[sec.title] 

385 for field_name, value in overrides.items(): 

386 if hasattr(sec, field_name): 386 ↛ 385line 386 didn't jump to line 385 because the condition on line 386 was always true

387 setattr(sec, field_name, value) 

388 

389 # Add new sections 

390 if add_sections: 

391 new_template.sections.extend(add_sections) 

392 

393 # Sort sections by order 

394 new_template.sections.sort(key=lambda s: s.order) 

395 

396 # Apply style overrides 

397 if style_overrides: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true

398 new_template.styles.update(style_overrides) 

399 

400 return new_template 

401 

402 

403def _resolve_inheritance( 

404 template: ReportTemplate, visited: set[str] | None = None 

405) -> ReportTemplate: 

406 """Resolve template inheritance chain. 

407 

408 Args: 

409 template: Template to resolve. 

410 visited: Set of already visited template names (for cycle detection). 

411 

412 Returns: 

413 Template with all inherited sections merged. 

414 

415 Raises: 

416 ValueError: If circular inheritance detected. 

417 

418 References: 

419 REPORT-005: Template Inheritance 

420 """ 

421 if visited is None: 

422 visited = set() 

423 

424 if template.name in visited: 

425 raise ValueError(f"Circular template inheritance detected: {template.name}") 

426 

427 if not template.extends: 

428 return template 

429 

430 visited.add(template.name) 

431 

432 # Get parent template 

433 parent_name = template.extends 

434 if parent_name in _USER_TEMPLATES: 

435 parent = copy.deepcopy(_USER_TEMPLATES[parent_name]) 

436 elif parent_name in BUILTIN_TEMPLATES: 436 ↛ 439line 436 didn't jump to line 439 because the condition on line 436 was always true

437 parent = copy.deepcopy(BUILTIN_TEMPLATES[parent_name]) 

438 else: 

439 raise ValueError(f"Parent template not found: {parent_name}") 

440 

441 # Recursively resolve parent inheritance 

442 parent = _resolve_inheritance(parent, visited) 

443 

444 # Merge sections 

445 # Child sections with override=True replace parent sections with same title 

446 parent_sections = {sec.title: sec for sec in parent.sections} 

447 

448 for child_sec in template.sections: 

449 if child_sec.override or child_sec.title in parent_sections: 

450 # Override or replace parent section 

451 parent_sections[child_sec.title] = child_sec 

452 else: 

453 # Add new section 

454 parent_sections[child_sec.title] = child_sec 

455 

456 # Sort by order 

457 merged_sections = sorted(parent_sections.values(), key=lambda s: s.order) 

458 

459 # Merge styles (child overrides parent) 

460 merged_styles = {**parent.styles, **template.styles} 

461 

462 # Merge metadata 

463 merged_metadata = {**parent.metadata, **template.metadata} 

464 

465 return ReportTemplate( 

466 name=template.name, 

467 version=template.version, 

468 description=template.description or parent.description, 

469 author=template.author or parent.author, 

470 extends=template.extends, 

471 sections=merged_sections, 

472 styles=merged_styles, 

473 metadata=merged_metadata, 

474 overrides=template.overrides, 

475 ) 

476 

477 

478def load_template(name_or_path: str, *, resolve_inheritance: bool = True) -> ReportTemplate: 

479 """Load a report template. 

480 

481 Args: 

482 name_or_path: Template name (builtin or registered) or path to YAML file. 

483 resolve_inheritance: If True, resolve template inheritance chain. 

484 

485 Returns: 

486 ReportTemplate instance. 

487 

488 Raises: 

489 ValueError: If template not found. 

490 

491 Example: 

492 >>> template = load_template("compliance") 

493 >>> template = load_template("custom_template.yaml") 

494 

495 References: 

496 REPORT-005: Template Inheritance 

497 REPORT-006: User Template Registration 

498 """ 

499 template = None 

500 

501 # Check user-registered templates first (REPORT-006) 

502 if name_or_path in _USER_TEMPLATES: 

503 template = copy.deepcopy(_USER_TEMPLATES[name_or_path]) 

504 # Then check builtin templates 

505 elif name_or_path in BUILTIN_TEMPLATES: 

506 template = copy.deepcopy(BUILTIN_TEMPLATES[name_or_path]) 

507 else: 

508 # Try loading from file 

509 path = Path(name_or_path) 

510 if path.exists(): 

511 template = _load_template_file(path) 

512 else: 

513 # Try adding .yaml extension 

514 yaml_path = Path(f"{name_or_path}.yaml") 

515 if yaml_path.exists(): 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 template = _load_template_file(yaml_path) 

517 

518 if template is None: 

519 raise ValueError(f"Template not found: {name_or_path}") 

520 

521 # Resolve inheritance if requested (REPORT-005) 

522 if resolve_inheritance and template.extends: 

523 template = _resolve_inheritance(template) 

524 

525 return template 

526 

527 

528def _load_template_file(path: Path) -> ReportTemplate: 

529 """Load template from YAML file. 

530 

531 Args: 

532 path: Path to template YAML file. 

533 

534 Returns: 

535 ReportTemplate instance loaded from file. 

536 

537 References: 

538 REPORT-007: Template Definition Format 

539 """ 

540 with open(path) as f: 

541 data = yaml.safe_load(f) 

542 

543 template_data = data.get("template", data) 

544 

545 sections = [] 

546 for idx, sec_data in enumerate(template_data.get("sections", [])): 

547 section = TemplateSection( 

548 title=sec_data.get("title", ""), 

549 content_type=sec_data.get("content_type", "text"), 

550 condition=sec_data.get("condition"), 

551 template=sec_data.get("template", sec_data.get("content", "")), 

552 order=sec_data.get("order", idx * 10), 

553 override=sec_data.get("override", False), 

554 ) 

555 sections.append(section) 

556 

557 return ReportTemplate( 

558 name=template_data.get("name", path.stem), 

559 version=template_data.get("version", "1.0"), 

560 description=template_data.get("description", ""), 

561 author=template_data.get("author", ""), 

562 extends=template_data.get("extends"), 

563 sections=sections, 

564 styles=template_data.get("styles", {}), 

565 metadata=template_data.get("metadata", {}), 

566 overrides=template_data.get("overrides", {}), 

567 ) 

568 

569 

570def list_templates(*, include_user: bool = True) -> list[str]: 

571 """List available template names. 

572 

573 Args: 

574 include_user: Include user-registered templates. 

575 

576 Returns: 

577 List of template names. 

578 

579 References: 

580 REPORT-006: User Template Registration 

581 """ 

582 names = list(BUILTIN_TEMPLATES.keys()) 

583 if include_user: 

584 names.extend(_USER_TEMPLATES.keys()) 

585 return sorted(set(names)) 

586 

587 

588def get_template_info(name: str) -> dict[str, Any]: 

589 """Get information about a template. 

590 

591 Args: 

592 name: Template name. 

593 

594 Returns: 

595 Dictionary with template info. 

596 

597 Raises: 

598 ValueError: If template name unknown. 

599 

600 References: 

601 REPORT-005: Template Inheritance 

602 REPORT-006: User Template Registration 

603 """ 

604 if name in _USER_TEMPLATES: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true

605 template = _USER_TEMPLATES[name] 

606 source = "user" 

607 elif name in BUILTIN_TEMPLATES: 

608 template = BUILTIN_TEMPLATES[name] 

609 source = "builtin" 

610 else: 

611 raise ValueError(f"Unknown template: {name}") 

612 

613 return { 

614 "name": template.name, 

615 "version": template.version, 

616 "description": template.description, 

617 "author": template.author, 

618 "extends": template.extends, 

619 "num_sections": len(template.sections), 

620 "section_titles": [sec.title for sec in template.sections], 

621 "source": source, 

622 } 

623 

624 

625def save_template(template: ReportTemplate, path: str | Path) -> None: 

626 """Save template to YAML file. 

627 

628 Args: 

629 template: Template to save. 

630 path: Output file path. 

631 

632 References: 

633 REPORT-007: Template Definition Format 

634 """ 

635 path = Path(path) 

636 

637 data = { 

638 "template": { 

639 "name": template.name, 

640 "version": template.version, 

641 "description": template.description, 

642 "author": template.author, 

643 "extends": template.extends, 

644 "sections": [ 

645 { 

646 "title": sec.title, 

647 "content_type": sec.content_type, 

648 "condition": sec.condition, 

649 "template": sec.template, 

650 "order": sec.order, 

651 "override": sec.override, 

652 } 

653 for sec in template.sections 

654 ], 

655 "styles": template.styles, 

656 "metadata": template.metadata, 

657 "overrides": template.overrides, 

658 } 

659 } 

660 

661 with open(path, "w") as f: 

662 yaml.dump(data, f, default_flow_style=False, sort_keys=False) 

663 

664 

665def create_template( 

666 name: str, 

667 sections: list[TemplateSection], 

668 *, 

669 extends: str | None = None, 

670 description: str = "", 

671 author: str = "", 

672 styles: dict[str, Any] | None = None, 

673) -> ReportTemplate: 

674 """Create a new template. 

675 

676 Convenience function for creating templates programmatically. 

677 

678 Args: 

679 name: Template name. 

680 sections: List of sections. 

681 extends: Parent template name for inheritance. 

682 description: Template description. 

683 author: Template author. 

684 styles: Style definitions. 

685 

686 Returns: 

687 New ReportTemplate. 

688 

689 Example: 

690 >>> template = create_template( 

691 ... "quick_report", 

692 ... sections=[ 

693 ... TemplateSection(title="Summary", template="{{ summary }}"), 

694 ... TemplateSection(title="Results", content_type="table"), 

695 ... ], 

696 ... description="Quick summary report" 

697 ... ) 

698 

699 References: 

700 REPORT-007: Template Definition Format 

701 """ 

702 return ReportTemplate( 

703 name=name, 

704 description=description, 

705 author=author, 

706 extends=extends, 

707 sections=sections, 

708 styles=styles or {}, 

709 ) 

710 

711 

712__all__ = [ 

713 "BUILTIN_TEMPLATES", 

714 "ReportTemplate", 

715 "TemplateSection", 

716 "create_template", 

717 "extend_template", 

718 "get_template_info", 

719 "list_templates", 

720 "load_template", 

721 "register_template", 

722 "save_template", 

723 "unregister_template", 

724]