Coverage for src / tracekit / exporters / markdown_export.py: 46%

190 statements  

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

1"""Markdown report export for TraceKit. 

2 

3This module provides Markdown report generation with measurement tables, 

4plot references, and configurable sections. 

5 

6 

7Example: 

8 >>> from tracekit.exporters.markdown_export import export_markdown 

9 >>> export_markdown(measurements, "report.md", title="Analysis Report") 

10""" 

11 

12from __future__ import annotations 

13 

14import base64 

15from datetime import datetime 

16from io import BytesIO 

17from pathlib import Path 

18from typing import Any 

19 

20 

21def export_markdown( 

22 data: dict[str, Any], 

23 path: str | Path, 

24 *, 

25 title: str = "TraceKit Analysis Report", 

26 author: str | None = None, 

27 include_plots: bool = True, 

28 embed_images: bool = True, 

29 sections: list[str] | None = None, 

30) -> None: 

31 """Export measurement results to Markdown format. 

32 

33 Args: 

34 data: Dictionary containing measurement results, plots, and metadata. 

35 Expected keys: 

36 - "measurements": dict of name -> value pairs 

37 - "plots": list of matplotlib figures or paths 

38 - "metadata": optional dict of metadata 

39 - "summary": optional executive summary text 

40 path: Output file path. 

41 title: Report title. 

42 author: Author name (optional). 

43 include_plots: Include plots in report. 

44 embed_images: Embed images as base64 (True) or save separately (False). 

45 sections: List of sections to include. If None, includes all available. 

46 Options: "metadata", "summary", "measurements", "plots", "conclusions" 

47 

48 References: 

49 EXP-006 

50 """ 

51 lines: list[str] = [] 

52 

53 # Header 

54 lines.append(f"# {title}\n") 

55 lines.append("") 

56 

57 # Metadata section 

58 if sections is None or "metadata" in sections: 

59 lines.extend(_generate_metadata_section(data, author)) 

60 

61 # Executive summary 

62 if (sections is None or "summary" in sections) and "summary" in data: 

63 lines.append("## Executive Summary\n") 

64 lines.append(data["summary"]) 

65 lines.append("") 

66 

67 # Measurements table 

68 if (sections is None or "measurements" in sections) and "measurements" in data: 68 ↛ 72line 68 didn't jump to line 72 because the condition on line 68 was always true

69 lines.extend(_generate_measurements_section(data["measurements"])) 

70 

71 # Plots 

72 if include_plots and (sections is None or "plots" in sections) and "plots" in data: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 lines.extend(_generate_plots_section(data["plots"], path, embed_images)) 

74 

75 # Conclusions 

76 if (sections is None or "conclusions" in sections) and "conclusions" in data: 

77 lines.append("## Conclusions\n") 

78 lines.append(data["conclusions"]) 

79 lines.append("") 

80 

81 # Write to file 

82 content = "\n".join(lines) 

83 Path(path).write_text(content, encoding="utf-8") 

84 

85 

86def _generate_metadata_section(data: dict[str, Any], author: str | None) -> list[str]: 

87 """Generate metadata section.""" 

88 lines = ["## Report Information\n", ""] 

89 

90 metadata = data.get("metadata", {}) 

91 

92 lines.append(f"- **Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") 

93 

94 if author: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 lines.append(f"- **Author**: {author}") 

96 

97 if "filename" in metadata: 

98 lines.append(f"- **Source File**: `{metadata['filename']}`") 

99 

100 if "sample_rate" in metadata: 

101 sr = metadata["sample_rate"] 

102 if sr >= 1e9: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was always true

103 sr_str = f"{sr / 1e9:.3f} GS/s" 

104 elif sr >= 1e6: 

105 sr_str = f"{sr / 1e6:.3f} MS/s" 

106 elif sr >= 1e3: 

107 sr_str = f"{sr / 1e3:.3f} kS/s" 

108 else: 

109 sr_str = f"{sr:.3f} S/s" 

110 lines.append(f"- **Sample Rate**: {sr_str}") 

111 

112 if "samples" in metadata: 

113 lines.append(f"- **Samples**: {metadata['samples']:,}") 

114 

115 if "duration" in metadata: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 dur = metadata["duration"] 

117 if dur >= 1.0: 

118 dur_str = f"{dur:.3f} s" 

119 elif dur >= 1e-3: 

120 dur_str = f"{dur * 1e3:.3f} ms" 

121 elif dur >= 1e-6: 

122 dur_str = f"{dur * 1e6:.3f} us" 

123 else: 

124 dur_str = f"{dur * 1e9:.3f} ns" 

125 lines.append(f"- **Duration**: {dur_str}") 

126 

127 lines.append("") 

128 return lines 

129 

130 

131def _generate_measurements_section(measurements: dict[str, Any]) -> list[str]: 

132 """Generate measurements table section.""" 

133 lines = ["## Measurement Results\n", ""] 

134 

135 if not measurements: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 lines.append("*No measurements available.*\n") 

137 return lines 

138 

139 # Create table header 

140 lines.append("| Parameter | Value | Unit | Status |") 

141 lines.append("|-----------|-------|------|--------|") 

142 

143 for name, value in measurements.items(): 

144 if isinstance(value, dict): 

145 # Structured measurement with value, unit, status 

146 val = value.get("value", "N/A") 

147 unit = value.get("unit", "") 

148 status = value.get("status", "") 

149 

150 # Format value 

151 val_str = _format_value(val, unit) if isinstance(val, float) else str(val) 

152 

153 # Format status with emoji 

154 if status.upper() == "PASS": 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true

155 status_str = "PASS" 

156 elif status.upper() == "FAIL": 

157 status_str = "FAIL" 

158 elif status.upper() == "WARNING": 

159 status_str = "WARNING" 

160 else: 

161 status_str = status 

162 

163 lines.append(f"| {name} | {val_str} | {unit} | {status_str} |") 

164 else: 

165 # Simple value 

166 val_str = f"{value:.6g}" if isinstance(value, float) else str(value) 

167 lines.append(f"| {name} | {val_str} | - | - |") 

168 

169 lines.append("") 

170 return lines 

171 

172 

173def _format_value(value: float, unit: str) -> str: 

174 """Format value with appropriate SI prefix.""" 

175 if value == 0: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 return "0" 

177 

178 abs_val = abs(value) 

179 

180 # Time units 

181 if unit in ("s", "sec", "seconds"): 

182 if abs_val >= 1.0: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 return f"{value:.6g}" 

184 elif abs_val >= 1e-3: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 return f"{value * 1e3:.6g} m" 

186 elif abs_val >= 1e-6: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 return f"{value * 1e6:.6g} u" 

188 elif abs_val >= 1e-9: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true

189 return f"{value * 1e9:.6g} n" 

190 else: 

191 return f"{value * 1e12:.6g} p" 

192 

193 # Frequency units 

194 if unit in ("Hz", "hz"): 

195 if abs_val >= 1e9: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 return f"{value / 1e9:.6g} G" 

197 elif abs_val >= 1e6: 197 ↛ 199line 197 didn't jump to line 199 because the condition on line 197 was always true

198 return f"{value / 1e6:.6g} M" 

199 elif abs_val >= 1e3: 

200 return f"{value / 1e3:.6g} k" 

201 else: 

202 return f"{value:.6g}" 

203 

204 # Voltage units 

205 if unit in ("V", "v", "volts"): 205 ↛ 216line 205 didn't jump to line 216 because the condition on line 205 was always true

206 if abs_val >= 1.0: 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was always true

207 return f"{value:.6g}" 

208 elif abs_val >= 1e-3: 

209 return f"{value * 1e3:.6g} m" 

210 elif abs_val >= 1e-6: 

211 return f"{value * 1e6:.6g} u" 

212 else: 

213 return f"{value * 1e9:.6g} n" 

214 

215 # Default formatting 

216 return f"{value:.6g}" 

217 

218 

219def _generate_plots_section( 

220 plots: list[Any], 

221 report_path: str | Path, 

222 embed_images: bool, 

223) -> list[str]: 

224 """Generate plots section.""" 

225 lines = ["## Plots and Visualizations\n", ""] 

226 

227 report_path = Path(report_path) 

228 plots_dir = report_path.parent / f"{report_path.stem}_plots" 

229 

230 for i, plot in enumerate(plots, start=1): 

231 if isinstance(plot, dict): 

232 # Plot with metadata 

233 fig = plot.get("figure") 

234 caption = plot.get("caption", f"Figure {i}") 

235 alt_text = plot.get("alt_text", caption) 

236 else: 

237 fig = plot 

238 caption = f"Figure {i}" 

239 alt_text = caption 

240 

241 if fig is None: 

242 continue 

243 

244 if isinstance(fig, str | Path): 

245 # Path to existing image 

246 if embed_images: 

247 # Read and embed as base64 

248 try: 

249 img_data = Path(fig).read_bytes() 

250 img_ext = Path(fig).suffix.lower() 

251 mime_type = { 

252 ".png": "image/png", 

253 ".jpg": "image/jpeg", 

254 ".jpeg": "image/jpeg", 

255 ".svg": "image/svg+xml", 

256 }.get(img_ext, "image/png") 

257 

258 b64 = base64.b64encode(img_data).decode("utf-8") 

259 lines.append(f"### {caption}\n") 

260 lines.append(f"![{alt_text}](data:{mime_type};base64,{b64})\n") 

261 except Exception: 

262 lines.append(f"### {caption}\n") 

263 lines.append(f"*Unable to embed image: {fig}*\n") 

264 else: 

265 lines.append(f"### {caption}\n") 

266 lines.append(f"![{alt_text}]({fig})\n") 

267 else: 

268 # Matplotlib figure 

269 try: 

270 if embed_images: 

271 # Embed as base64 PNG 

272 buf = BytesIO() 

273 fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") 

274 buf.seek(0) 

275 b64 = base64.b64encode(buf.read()).decode("utf-8") 

276 lines.append(f"### {caption}\n") 

277 lines.append(f"![{alt_text}](data:image/png;base64,{b64})\n") 

278 else: 

279 # Save to separate file 

280 plots_dir.mkdir(exist_ok=True) 

281 plot_path = plots_dir / f"figure_{i}.png" 

282 fig.savefig(plot_path, format="png", dpi=150, bbox_inches="tight") 

283 rel_path = plot_path.relative_to(report_path.parent) 

284 lines.append(f"### {caption}\n") 

285 lines.append(f"![{alt_text}]({rel_path})\n") 

286 except Exception as e: 

287 lines.append(f"### {caption}\n") 

288 lines.append(f"*Unable to render figure: {e}*\n") 

289 

290 lines.append("") 

291 

292 return lines 

293 

294 

295def generate_markdown_report( 

296 data: dict[str, Any], 

297 *, 

298 title: str = "TraceKit Analysis Report", 

299 author: str | None = None, 

300 include_plots: bool = True, 

301 embed_images: bool = True, 

302 sections: list[str] | None = None, 

303) -> str: 

304 """Generate Markdown report as string. 

305 

306 Args: 

307 data: Dictionary containing measurement results, plots, and metadata. 

308 title: Report title. 

309 author: Author name (optional). 

310 include_plots: Include plots in report. 

311 embed_images: Embed images as base64. 

312 sections: List of sections to include. 

313 

314 Returns: 

315 Markdown content as string. 

316 

317 References: 

318 EXP-006 

319 """ 

320 lines: list[str] = [] 

321 

322 # Header 

323 lines.append(f"# {title}\n") 

324 lines.append("") 

325 

326 # Metadata section 

327 if sections is None or "metadata" in sections: 327 ↛ 331line 327 didn't jump to line 331 because the condition on line 327 was always true

328 lines.extend(_generate_metadata_section(data, author)) 

329 

330 # Executive summary 

331 if (sections is None or "summary" in sections) and "summary" in data: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true

332 lines.append("## Executive Summary\n") 

333 lines.append(data["summary"]) 

334 lines.append("") 

335 

336 # Measurements table 

337 if (sections is None or "measurements" in sections) and "measurements" in data: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was always true

338 lines.extend(_generate_measurements_section(data["measurements"])) 

339 

340 # For string generation, only include plots if embed_images is True 

341 if include_plots and embed_images and (sections is None or "plots" in sections): 341 ↛ 356line 341 didn't jump to line 356 because the condition on line 341 was always true

342 if "plots" in data: 342 ↛ 344line 342 didn't jump to line 344 because the condition on line 342 was never true

343 # Simplified plot handling for string output 

344 lines.append("## Plots and Visualizations\n") 

345 lines.append("") 

346 for i, plot in enumerate(data["plots"], start=1): 

347 if isinstance(plot, dict): 

348 caption = plot.get("caption", f"Figure {i}") 

349 else: 

350 caption = f"Figure {i}" 

351 lines.append(f"### {caption}\n") 

352 lines.append("*[Embedded plot - save to file to view]*\n") 

353 lines.append("") 

354 

355 # Conclusions 

356 if (sections is None or "conclusions" in sections) and "conclusions" in data: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 lines.append("## Conclusions\n") 

358 lines.append(data["conclusions"]) 

359 lines.append("") 

360 

361 return "\n".join(lines) 

362 

363 

364__all__ = [ 

365 "export_markdown", 

366 "generate_markdown_report", 

367]