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

1"""Multi-format report export for TraceKit. 

2 

3This module provides unified export interface for generating reports in 

4multiple formats (HTML, PDF, DOCX, Markdown) from a single source. 

5 

6 

7Example: 

8 >>> from tracekit.reporting.export import export_report 

9 >>> export_report(report, "output", formats=["pdf", "html", "markdown"]) 

10""" 

11 

12from __future__ import annotations 

13 

14from pathlib import Path 

15from typing import TYPE_CHECKING, Any, Literal 

16 

17if TYPE_CHECKING: 

18 from tracekit.reporting.core import Report 

19 

20 

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. 

29 

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. 

35 

36 Returns: 

37 Dictionary mapping format to output file path. 

38 

39 Raises: 

40 ValueError: If unsupported format is specified. 

41 

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

52 

53 References: 

54 REPORT-010 

55 """ 

56 if formats is None: 

57 formats = ["pdf", "html"] 

58 

59 if format_options is None: 

60 format_options = {} 

61 

62 output_path = Path(output_path) 

63 generated_files = {} 

64 

65 for fmt in formats: 

66 fmt_opts = format_options.get(fmt, {}) 

67 

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

78 

79 generated_files[fmt] = file_path 

80 

81 return generated_files # type: ignore[return-value] 

82 

83 

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 

91 

92 path = output_path.with_suffix(".pdf") 

93 save_pdf_report(report, path, **options) 

94 return path 

95 

96 

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 

104 

105 path = output_path.with_suffix(".html") 

106 save_html_report(report, path, **options) 

107 return path 

108 

109 

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 

120 

121 

122def _export_docx( 

123 report: Report, 

124 output_path: Path, 

125 **options: Any, 

126) -> Path: 

127 """Export report as DOCX. 

128 

129 Requires python-docx library. 

130 

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

135 

136 Returns: 

137 Path to the created DOCX file. 

138 

139 Raises: 

140 ImportError: If python-docx library is not installed. 

141 

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 ) 

159 

160 path = output_path.with_suffix(".docx") 

161 doc = Document() 

162 

163 # Add title 

164 title = doc.add_heading(report.config.title, level=0) 

165 title.alignment = WD_ALIGN_PARAGRAPH.CENTER 

166 

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 

172 

173 # Add sections 

174 for section in report.sections: 

175 if not section.visible: 

176 continue 

177 

178 # Section heading 

179 doc.add_heading(section.title, level=section.level) 

180 

181 # Section content 

182 if isinstance(section.content, str): 

183 doc.add_paragraph(section.content) 

184 

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

195 

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) 

203 

204 # Save document 

205 doc.save(str(path)) 

206 return path 

207 

208 

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

213 

214 if not headers and not data: 

215 return 

216 

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) 

220 

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 

223 

224 table = doc.add_table(rows=num_rows, cols=num_cols) 

225 table.style = "Light Grid Accent 1" 

226 

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 

236 

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) 

243 

244 # Add caption 

245 if table_dict.get("caption"): 

246 doc.add_paragraph(table_dict["caption"], style="Caption") 

247 

248 

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. 

257 

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. 

263 

264 Returns: 

265 Dictionary mapping report name to output path. 

266 

267 References: 

268 REPORT-010 

269 """ 

270 output_dir = Path(output_dir) 

271 output_dir.mkdir(parents=True, exist_ok=True) 

272 

273 generated_files = {} 

274 

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] 

281 

282 return generated_files 

283 

284 

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. 

293 

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. 

299 

300 Returns: 

301 Dictionary mapping format to output path. 

302 

303 References: 

304 REPORT-010 

305 """ 

306 if formats is None: 

307 formats = ["pdf", "html", "docx", "markdown"] 

308 

309 output_dir = Path(output_dir) 

310 output_dir.mkdir(parents=True, exist_ok=True) 

311 

312 base_name = report.config.title.lower().replace(" ", "_") 

313 output_path = output_dir / base_name 

314 

315 return export_report(report, output_path, formats=formats, format_options=options) # type: ignore[arg-type] 

316 

317 

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. 

325 

326 Args: 

327 files: Dictionary of files to archive. 

328 archive_path: Output archive path. 

329 format: Archive format (zip, tar, tar.gz). 

330 

331 Returns: 

332 Path to created archive. 

333 

334 Raises: 

335 ValueError: If unsupported archive format is specified. 

336 

337 References: 

338 REPORT-010 

339 """ 

340 from pathlib import Path 

341 

342 archive_path = Path(archive_path) 

343 

344 if format == "zip": 

345 import zipfile 

346 

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) 

350 

351 return archive_path.with_suffix(".zip") 

352 

353 elif format in ("tar", "tar.gz"): 

354 import tarfile 

355 

356 mode = "w:gz" if format == "tar.gz" else "w" 

357 suffix = ".tar.gz" if format == "tar.gz" else ".tar" 

358 

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) 

362 

363 return archive_path.with_suffix(suffix) 

364 

365 else: 

366 raise ValueError(f"Unsupported archive format: {format}")