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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Template system for TraceKit reports.
3This module provides template loading, management, inheritance, and built-in
4report templates.
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"""
15from __future__ import annotations
17import copy
18from dataclasses import dataclass, field
19from pathlib import Path
20from typing import Any
22import yaml
25@dataclass
26class TemplateSection:
27 """A section definition in a template.
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.
38 References:
39 REPORT-007: Template Definition Format
40 """
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
51@dataclass
52class ReportTemplate:
53 """A report template definition.
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).
66 References:
67 REPORT-005: Template Inheritance
68 REPORT-007: Template Definition Format
69 REPORT-008: Template Overrides
70 """
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)
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}
258# User-registered templates (REPORT-006)
259_USER_TEMPLATES: dict[str, ReportTemplate] = {}
262def register_template(
263 name: str,
264 template: ReportTemplate,
265 *,
266 overwrite: bool = False,
267) -> None:
268 """Register a user template.
270 Allows users to define custom templates or extend built-in ones.
272 Args:
273 name: Template name for registration.
274 template: Template definition.
275 overwrite: If True, allows overwriting existing templates.
277 Raises:
278 ValueError: If name exists and overwrite=False.
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)
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.")
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.")
299 _USER_TEMPLATES[name] = template
302def unregister_template(name: str) -> bool:
303 """Unregister a user template.
305 Args:
306 name: Template name.
308 Returns:
309 True if template was removed, False if not found.
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
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.
332 Implements template inheritance with section merging and overrides.
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.
343 Returns:
344 New ReportTemplate with inherited and modified sections.
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 ... )
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)
364 # Deep copy to avoid modifying original
365 new_template = copy.deepcopy(base)
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
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 ]
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)
389 # Add new sections
390 if add_sections:
391 new_template.sections.extend(add_sections)
393 # Sort sections by order
394 new_template.sections.sort(key=lambda s: s.order)
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)
400 return new_template
403def _resolve_inheritance(
404 template: ReportTemplate, visited: set[str] | None = None
405) -> ReportTemplate:
406 """Resolve template inheritance chain.
408 Args:
409 template: Template to resolve.
410 visited: Set of already visited template names (for cycle detection).
412 Returns:
413 Template with all inherited sections merged.
415 Raises:
416 ValueError: If circular inheritance detected.
418 References:
419 REPORT-005: Template Inheritance
420 """
421 if visited is None:
422 visited = set()
424 if template.name in visited:
425 raise ValueError(f"Circular template inheritance detected: {template.name}")
427 if not template.extends:
428 return template
430 visited.add(template.name)
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}")
441 # Recursively resolve parent inheritance
442 parent = _resolve_inheritance(parent, visited)
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}
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
456 # Sort by order
457 merged_sections = sorted(parent_sections.values(), key=lambda s: s.order)
459 # Merge styles (child overrides parent)
460 merged_styles = {**parent.styles, **template.styles}
462 # Merge metadata
463 merged_metadata = {**parent.metadata, **template.metadata}
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 )
478def load_template(name_or_path: str, *, resolve_inheritance: bool = True) -> ReportTemplate:
479 """Load a report template.
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.
485 Returns:
486 ReportTemplate instance.
488 Raises:
489 ValueError: If template not found.
491 Example:
492 >>> template = load_template("compliance")
493 >>> template = load_template("custom_template.yaml")
495 References:
496 REPORT-005: Template Inheritance
497 REPORT-006: User Template Registration
498 """
499 template = None
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)
518 if template is None:
519 raise ValueError(f"Template not found: {name_or_path}")
521 # Resolve inheritance if requested (REPORT-005)
522 if resolve_inheritance and template.extends:
523 template = _resolve_inheritance(template)
525 return template
528def _load_template_file(path: Path) -> ReportTemplate:
529 """Load template from YAML file.
531 Args:
532 path: Path to template YAML file.
534 Returns:
535 ReportTemplate instance loaded from file.
537 References:
538 REPORT-007: Template Definition Format
539 """
540 with open(path) as f:
541 data = yaml.safe_load(f)
543 template_data = data.get("template", data)
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)
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 )
570def list_templates(*, include_user: bool = True) -> list[str]:
571 """List available template names.
573 Args:
574 include_user: Include user-registered templates.
576 Returns:
577 List of template names.
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))
588def get_template_info(name: str) -> dict[str, Any]:
589 """Get information about a template.
591 Args:
592 name: Template name.
594 Returns:
595 Dictionary with template info.
597 Raises:
598 ValueError: If template name unknown.
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}")
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 }
625def save_template(template: ReportTemplate, path: str | Path) -> None:
626 """Save template to YAML file.
628 Args:
629 template: Template to save.
630 path: Output file path.
632 References:
633 REPORT-007: Template Definition Format
634 """
635 path = Path(path)
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 }
661 with open(path, "w") as f:
662 yaml.dump(data, f, default_flow_style=False, sort_keys=False)
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.
676 Convenience function for creating templates programmatically.
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.
686 Returns:
687 New ReportTemplate.
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 ... )
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 )
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]