Coverage for src / tracekit / reporting / export.py: 97%
133 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"""Multi-format report export for TraceKit.
3This module provides unified export interface for generating reports in
4multiple formats (HTML, PDF, DOCX, Markdown) from a single source.
7Example:
8 >>> from tracekit.reporting.export import export_report
9 >>> export_report(report, "output", formats=["pdf", "html", "markdown"])
10"""
12from __future__ import annotations
14from pathlib import Path
15from typing import TYPE_CHECKING, Any, Literal
17if TYPE_CHECKING:
18 from tracekit.reporting.core import Report
21def export_report(
22 report: Report,
23 output_path: str | Path,
24 *,
25 formats: list[Literal["pdf", "html", "docx", "markdown"]] | None = None,
26 format_options: dict[str, Any] | None = None,
27) -> dict[str, Path]:
28 """Export report to multiple formats.
30 Args:
31 report: Report object to export.
32 output_path: Base output path (without extension).
33 formats: List of formats to generate (default: ["pdf", "html"]).
34 format_options: Format-specific options dictionary.
36 Returns:
37 Dictionary mapping format to output file path.
39 Raises:
40 ValueError: If unsupported format is specified.
42 Example:
43 >>> paths = export_report(
44 ... report,
45 ... "report",
46 ... formats=["pdf", "html", "markdown"],
47 ... format_options={
48 ... "pdf": {"dpi": 300, "pdfa_compliance": True},
49 ... "html": {"interactive": True, "dark_mode": True},
50 ... }
51 ... )
53 References:
54 REPORT-010
55 """
56 if formats is None:
57 formats = ["pdf", "html"]
59 if format_options is None:
60 format_options = {}
62 output_path = Path(output_path)
63 generated_files = {}
65 for fmt in formats:
66 fmt_opts = format_options.get(fmt, {})
68 if fmt == "pdf":
69 file_path = _export_pdf(report, output_path, **fmt_opts)
70 elif fmt == "html":
71 file_path = _export_html(report, output_path, **fmt_opts)
72 elif fmt == "docx":
73 file_path = _export_docx(report, output_path, **fmt_opts)
74 elif fmt == "markdown":
75 file_path = _export_markdown(report, output_path, **fmt_opts)
76 else:
77 raise ValueError(f"Unsupported format: {fmt}")
79 generated_files[fmt] = file_path
81 return generated_files # type: ignore[return-value]
84def _export_pdf(
85 report: Report,
86 output_path: Path,
87 **options: Any,
88) -> Path:
89 """Export report as PDF."""
90 from tracekit.reporting.pdf import save_pdf_report
92 path = output_path.with_suffix(".pdf")
93 save_pdf_report(report, path, **options)
94 return path
97def _export_html(
98 report: Report,
99 output_path: Path,
100 **options: Any,
101) -> Path:
102 """Export report as HTML."""
103 from tracekit.reporting.html import save_html_report
105 path = output_path.with_suffix(".html")
106 save_html_report(report, path, **options)
107 return path
110def _export_markdown(
111 report: Report,
112 output_path: Path,
113 **options: Any,
114) -> Path:
115 """Export report as Markdown."""
116 path = output_path.with_suffix(".md")
117 markdown_content = report.to_markdown()
118 path.write_text(markdown_content, encoding="utf-8")
119 return path
122def _export_docx(
123 report: Report,
124 output_path: Path,
125 **options: Any,
126) -> Path:
127 """Export report as DOCX.
129 Requires python-docx library.
131 Args:
132 report: Report object to export.
133 output_path: Base output path (extension will be changed to .docx).
134 **options: Format-specific options (currently unused).
136 Returns:
137 Path to the created DOCX file.
139 Raises:
140 ImportError: If python-docx library is not installed.
142 References:
143 REPORT-019
144 """
145 try:
146 from docx import Document # type: ignore[import-not-found]
147 from docx.enum.text import ( # type: ignore[import-not-found]
148 WD_ALIGN_PARAGRAPH, # type: ignore[import-not-found]
149 )
150 from docx.shared import ( # noqa: F401 # type: ignore[import-not-found]
151 Inches,
152 Pt,
153 RGBColor,
154 )
155 except ImportError:
156 raise ImportError( # noqa: B904
157 "python-docx is required for DOCX export. Install with: pip install python-docx"
158 )
160 path = output_path.with_suffix(".docx")
161 doc = Document()
163 # Add title
164 title = doc.add_heading(report.config.title, level=0)
165 title.alignment = WD_ALIGN_PARAGRAPH.CENTER
167 # Add metadata
168 if report.config.author:
169 doc.add_paragraph(f"Author: {report.config.author}")
170 doc.add_paragraph(f"Date: {report.config.created.strftime('%Y-%m-%d %H:%M')}")
171 doc.add_paragraph() # Blank line
173 # Add sections
174 for section in report.sections:
175 if not section.visible:
176 continue
178 # Section heading
179 doc.add_heading(section.title, level=section.level)
181 # Section content
182 if isinstance(section.content, str):
183 doc.add_paragraph(section.content)
185 elif isinstance(section.content, list): 185 ↛ 197line 185 didn't jump to line 197 because the condition on line 185 was always true
186 for item in section.content:
187 if isinstance(item, dict):
188 if item.get("type") == "table":
189 _add_table_to_docx(doc, item)
190 elif item.get("type") == "figure": 190 ↛ 186line 190 didn't jump to line 186 because the condition on line 190 was always true
191 # Placeholder for figures
192 doc.add_paragraph(f"[Figure: {item.get('caption', 'N/A')}]")
193 else:
194 doc.add_paragraph(str(item))
196 # Subsections
197 for subsec in section.subsections:
198 if not subsec.visible: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 continue
200 doc.add_heading(subsec.title, level=subsec.level)
201 if isinstance(subsec.content, str): 201 ↛ 197line 201 didn't jump to line 197 because the condition on line 201 was always true
202 doc.add_paragraph(subsec.content)
204 # Save document
205 doc.save(str(path))
206 return path
209def _add_table_to_docx(doc: Any, table_dict: dict[str, Any]) -> None:
210 """Add table to DOCX document."""
211 headers = table_dict.get("headers", [])
212 data = table_dict.get("data", [])
214 if not headers and not data:
215 return
217 # Create table
218 num_cols = len(headers) if headers else len(data[0]) if data else 0
219 num_rows = len(data) + (1 if headers else 0)
221 if num_rows == 0 or num_cols == 0: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 return
224 table = doc.add_table(rows=num_rows, cols=num_cols)
225 table.style = "Light Grid Accent 1"
227 # Add headers
228 if headers:
229 header_cells = table.rows[0].cells
230 for i, header in enumerate(headers):
231 header_cells[i].text = str(header)
232 # Make header bold
233 for paragraph in header_cells[i].paragraphs:
234 for run in paragraph.runs:
235 run.bold = True
237 # Add data
238 start_row = 1 if headers else 0
239 for i, row in enumerate(data):
240 row_cells = table.rows[start_row + i].cells
241 for j, cell in enumerate(row):
242 row_cells[j].text = str(cell)
244 # Add caption
245 if table_dict.get("caption"):
246 doc.add_paragraph(table_dict["caption"], style="Caption")
249def export_multiple_reports(
250 reports: dict[str, Report],
251 output_dir: str | Path,
252 *,
253 format: Literal["pdf", "html", "docx", "markdown"] = "pdf",
254 **options: Any,
255) -> dict[str, Path]:
256 """Export multiple reports to a directory.
258 Args:
259 reports: Dictionary mapping name to Report object.
260 output_dir: Output directory path.
261 format: Export format for all reports.
262 **options: Format-specific options.
264 Returns:
265 Dictionary mapping report name to output path.
267 References:
268 REPORT-010
269 """
270 output_dir = Path(output_dir)
271 output_dir.mkdir(parents=True, exist_ok=True)
273 generated_files = {}
275 for name, report in reports.items():
276 output_path = output_dir / name
277 files = export_report(
278 report, output_path, formats=[format], format_options={format: options}
279 )
280 generated_files[name] = files[format]
282 return generated_files
285def batch_export_formats(
286 report: Report,
287 output_dir: str | Path,
288 *,
289 formats: list[str] | None = None,
290 **options: Any,
291) -> dict[str, Path]:
292 """Export single report to multiple formats in a directory.
294 Args:
295 report: Report to export.
296 output_dir: Output directory.
297 formats: List of formats (default: all supported).
298 **options: Common options for all formats.
300 Returns:
301 Dictionary mapping format to output path.
303 References:
304 REPORT-010
305 """
306 if formats is None:
307 formats = ["pdf", "html", "docx", "markdown"]
309 output_dir = Path(output_dir)
310 output_dir.mkdir(parents=True, exist_ok=True)
312 base_name = report.config.title.lower().replace(" ", "_")
313 output_path = output_dir / base_name
315 return export_report(report, output_path, formats=formats, format_options=options) # type: ignore[arg-type]
318def create_archive(
319 files: dict[str, Path],
320 archive_path: str | Path,
321 *,
322 format: Literal["zip", "tar", "tar.gz"] = "zip",
323) -> Path:
324 """Create archive of exported report files.
326 Args:
327 files: Dictionary of files to archive.
328 archive_path: Output archive path.
329 format: Archive format (zip, tar, tar.gz).
331 Returns:
332 Path to created archive.
334 Raises:
335 ValueError: If unsupported archive format is specified.
337 References:
338 REPORT-010
339 """
340 from pathlib import Path
342 archive_path = Path(archive_path)
344 if format == "zip":
345 import zipfile
347 with zipfile.ZipFile(archive_path.with_suffix(".zip"), "w") as zipf:
348 for path in files.values():
349 zipf.write(path, arcname=path.name)
351 return archive_path.with_suffix(".zip")
353 elif format in ("tar", "tar.gz"):
354 import tarfile
356 mode = "w:gz" if format == "tar.gz" else "w"
357 suffix = ".tar.gz" if format == "tar.gz" else ".tar"
359 with tarfile.open(archive_path.with_suffix(suffix), mode) as tar: # type: ignore[call-overload]
360 for path in files.values():
361 tar.add(path, arcname=path.name)
363 return archive_path.with_suffix(suffix)
365 else:
366 raise ValueError(f"Unsupported archive format: {format}")