gabriel / muse public
doc_renderer.py python
638 lines 23.2 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
1 """Multi-format documentation renderer for ``muse code docs``.
2
3 Converts a :class:`~muse.core.doc_extractor.DocReport` into one of four
4 output formats:
5
6 ``json``
7 Machine-readable, AI-ready. The full :class:`DocReport` TypedDict
8 serialised as pretty-printed JSON. Suitable for ingestion by LLMs,
9 RAG pipelines, and downstream tooling.
10
11 ``html``
12 Standalone HTML page with an inline sidebar TOC, health bars, version
13 badges, caller/callee sections, and linked test lists. Zero external
14 dependencies — a single file that opens in any browser.
15
16 ``markdown``
17 GitHub-compatible Markdown with a generated table of contents, one
18 ``##`` heading per symbol, and structured sections for callers, callees,
19 version history, and test linkage.
20
21 ``text``
22 Terminal-friendly columnar table similar to ``muse code symbols``, with
23 health score, stale indicator, and doc-debt summary.
24
25 Security
26 --------
27 All user-visible strings (symbol names, docstrings, file paths) are escaped
28 appropriately before embedding in HTML or JSON output. The renderer never
29 evaluates or executes any string from the report.
30
31 Performance
32 -----------
33 Renderers operate in a single linear pass over the ``symbols`` list.
34 On a 400-symbol report the HTML renderer completes in <10 ms.
35 """
36
37 import html as _html_module
38 import json
39 import logging
40 from typing import Literal, TypedDict
41
42 from muse.core.doc_extractor import DocReport, DocSummary, SymbolDoc
43
44 type _BadgeMap = dict[str, str]
45 logger = logging.getLogger(__name__)
46
47 RenderFormat = Literal["json", "html", "markdown", "text"]
48 """Supported output formats for :func:`render`."""
49
50 # ---------------------------------------------------------------------------
51 # JSON renderer
52 # ---------------------------------------------------------------------------
53
54 def render_json(report: DocReport) -> str:
55 """Return the full *report* as pretty-printed JSON.
56
57 The output is structured identically to the :class:`DocReport` TypedDict —
58 every field is present, making it directly usable by LLMs and agent
59 pipelines.
60
61 Args:
62 report: The documentation report to serialise.
63 """
64 return json.dumps(report, indent=2, ensure_ascii=False)
65
66 # ---------------------------------------------------------------------------
67 # Text renderer
68 # ---------------------------------------------------------------------------
69
70 _HEALTH_BAR_WIDTH = 10
71
72 def _health_bar(score: float) -> str:
73 """Return a compact ASCII health bar, e.g. ``[████████░░]``."""
74 filled = round(score * _HEALTH_BAR_WIDTH)
75 empty = _HEALTH_BAR_WIDTH - filled
76 return f"[{'█' * filled}{'░' * empty}]"
77
78 def _stale_flag(doc: SymbolDoc) -> str:
79 return "⚠" if "stale_impl" in doc["doc_health_reasons"] else " "
80
81 def render_text(report: DocReport) -> str:
82 """Return a terminal-friendly columnar view of *report*.
83
84 Columns: HEALTH ST KIND NAME FILE
85 Health bar + score, stale flag, symbol kind, name, file path.
86
87 Args:
88 report: The documentation report to render.
89 """
90 lines: list[str] = []
91 s = report["summary"]
92
93 lines.append(
94 f"Muse docs — commit {report['commit_id']} "
95 f"generated {report['generated_at'][:19]}"
96 )
97 lines.append(
98 f" symbols={s['total_symbols']} "
99 f"documented={s['documented']} "
100 f"undocumented={s['undocumented']} "
101 f"stale={s['stale_count']} "
102 f"avg_health={s['avg_health']:.2f} "
103 f"debt={s['doc_debt_score']:.2f}"
104 )
105 lines.append("")
106
107 if not report["symbols"]:
108 lines.append(" (no symbols)")
109 return "\n".join(lines)
110
111 header = f"{'HEALTH':17} ST {'KIND':14} {'NAME':38} FILE"
112 lines.append(header)
113 lines.append("-" * len(header))
114
115 for doc in report["symbols"]:
116 bar = _health_bar(doc["doc_health"])
117 score = f"{doc['doc_health']:.2f}"
118 health_col = f"{bar} {score}"
119 stale = _stale_flag(doc)
120 kind = doc["kind"][:14]
121 name = doc["name"][:38]
122 file_col = doc["file"]
123 lines.append(f"{health_col:17} {stale} {kind:14} {name:38} {file_col}")
124
125 if report["missing"]:
126 lines.append("")
127 lines.append(f"Missing docstrings ({len(report['missing'])} public symbols):")
128 for m in report["missing"][:20]:
129 lines.append(f" [{m['caller_count']:3} callers] {m['address']}")
130 if len(report["missing"]) > 20:
131 lines.append(f" … and {len(report['missing']) - 20} more")
132
133 if report["stale"]:
134 lines.append("")
135 lines.append(f"Potentially stale docstrings ({len(report['stale'])} symbols):")
136 for st in report["stale"][:10]:
137 lines.append(f" {st['address']}")
138 if len(report["stale"]) > 10:
139 lines.append(f" … and {len(report['stale']) - 10} more")
140
141 return "\n".join(lines)
142
143 # ---------------------------------------------------------------------------
144 # Markdown renderer
145 # ---------------------------------------------------------------------------
146
147 _MD_KIND_BADGE: _BadgeMap = {
148 "function": "fn",
149 "async_function": "async fn",
150 "class": "class",
151 "method": "method",
152 "async_method": "async method",
153 "variable": "var",
154 "import": "import",
155 "section": "section",
156 "rule": "rule",
157 }
158
159 def _md_health_indicator(score: float) -> str:
160 """Return an emoji indicator for the health score."""
161 if score >= 0.85:
162 return "✅"
163 if score >= 0.60:
164 return "🟡"
165 return "🔴"
166
167 def render_markdown(report: DocReport) -> str:
168 """Return GitHub-compatible Markdown for *report*.
169
170 Structure:
171 - H1 header with commit and summary stats
172 - Auto-generated TOC for all symbols
173 - Per-symbol H2 sections with docstring, signature, callers, callees,
174 version info, linked tests, and health score
175
176 Args:
177 report: The documentation report to render.
178 """
179 lines: list[str] = []
180 s = report["summary"]
181
182 lines.append("# Muse Documentation Report")
183 lines.append("")
184 lines.append(f"**Commit:** `{report['commit_id']}` ")
185 lines.append(f"**Generated:** {report['generated_at'][:19]} ")
186 lines.append(
187 f"**Health:** avg={s['avg_health']:.2f} "
188 f"debt={s['doc_debt_score']:.2f} "
189 f"documented={s['documented']}/{s['total_symbols']}"
190 )
191 lines.append("")
192
193 if report["symbols"]:
194 lines.append("## Table of Contents")
195 lines.append("")
196 for doc in report["symbols"]:
197 anchor = doc["address"].lower().replace(" ", "-").replace("/", "").replace("::", "--").replace(".", "")
198 indicator = _md_health_indicator(doc["doc_health"])
199 lines.append(f"- {indicator} [`{doc['qualified_name']}`](#{anchor})")
200 lines.append("")
201
202 for doc in report["symbols"]:
203 anchor = doc["address"].lower().replace(" ", "-").replace("/", "").replace("::", "--").replace(".", "")
204 kind_badge = _MD_KIND_BADGE.get(doc["kind"], doc["kind"])
205 indicator = _md_health_indicator(doc["doc_health"])
206
207 lines.append(f"## `{doc['qualified_name']}` {{#{anchor}}}")
208 lines.append("")
209 lines.append(
210 f"> {indicator} **{kind_badge}** in `{doc['file']}`"
211 f" (lines {doc['lineno']}–{doc['end_lineno']})"
212 )
213 lines.append("")
214
215 if doc["signature"]:
216 lines.append("```python")
217 lines.append(doc["signature"])
218 lines.append("```")
219 lines.append("")
220
221 if doc["docstring"]:
222 lines.append(doc["docstring"])
223 else:
224 lines.append("*No docstring.*")
225 lines.append("")
226
227 if doc["since_version"] or doc["since_commit"]:
228 ver = doc["since_version"] or ""
229 cid = doc["since_commit"] or ""
230 lines.append(f"**Introduced:** {ver or cid}")
231 lines.append("")
232
233 if doc["last_changed_version"] or doc["last_changed_commit"]:
234 ver = doc["last_changed_version"] or ""
235 cid = doc["last_changed_commit"] or ""
236 lines.append(f"**Last changed:** {ver or cid}")
237 lines.append("")
238
239 if doc["breaking_changes"]:
240 lines.append("**Breaking changes:**")
241 for bc in doc["breaking_changes"]:
242 lines.append(f"- {bc}")
243 lines.append("")
244
245 if doc["callers"]:
246 lines.append(f"**Called by** ({len(doc['callers'])}):")
247 for c in doc["callers"][:10]:
248 lines.append(f"- `{c}`")
249 if len(doc["callers"]) > 10:
250 lines.append(f"- *(+{len(doc['callers']) - 10} more)*")
251 lines.append("")
252
253 if doc["callees"]:
254 lines.append(f"**Calls** ({len(doc['callees'])}):")
255 for c in doc["callees"][:10]:
256 lines.append(f"- `{c}`")
257 if len(doc["callees"]) > 10:
258 lines.append(f"- *(+{len(doc['callees']) - 10} more)*")
259 lines.append("")
260
261 if doc["linked_tests"]:
262 lines.append(f"**Tests** ({len(doc['linked_tests'])}):")
263 for t in doc["linked_tests"][:5]:
264 lines.append(f"- `{t}`")
265 if len(doc["linked_tests"]) > 5:
266 lines.append(f"- *(+{len(doc['linked_tests']) - 5} more)*")
267 lines.append("")
268
269 reasons = doc["doc_health_reasons"]
270 lines.append(
271 f"**Doc health:** {doc['doc_health']:.2f}"
272 + (f" ({', '.join(reasons)})" if reasons else "")
273 )
274 lines.append("")
275 lines.append("---")
276 lines.append("")
277
278 if report["missing"]:
279 lines.append("## Missing Docstrings")
280 lines.append("")
281 lines.append(
282 f"{len(report['missing'])} public symbol(s) lack docstrings "
283 f"(sorted by caller count):"
284 )
285 lines.append("")
286 lines.append("| Callers | Address |")
287 lines.append("|---------|---------|")
288 for m in report["missing"]:
289 lines.append(f"| {m['caller_count']} | `{m['address']}` |")
290 lines.append("")
291
292 if report["stale"]:
293 lines.append("## Stale Docstrings")
294 lines.append("")
295 lines.append(
296 f"{len(report['stale'])} symbol(s) may have stale documentation:"
297 )
298 lines.append("")
299 for st in report["stale"]:
300 changed = "signature" if st["signature_changed"] else "body"
301 lines.append(f"- `{st['address']}` — {changed} changed")
302 lines.append("")
303
304 return "\n".join(lines)
305
306 # ---------------------------------------------------------------------------
307 # HTML renderer
308 # ---------------------------------------------------------------------------
309
310 _HTML_TEMPLATE = """\
311 <!DOCTYPE html>
312 <html lang="en">
313 <head>
314 <meta charset="utf-8">
315 <meta name="viewport" content="width=device-width, initial-scale=1">
316 <title>Muse Docs — {commit_short}</title>
317 <style>
318 :root {{
319 --bg: #0d1117; --surface: #161b22; --border: #30363d;
320 --text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
321 --green: #3fb950; --yellow: #d29922; --red: #f85149;
322 --code-bg: #1f2428; --font: system-ui, -apple-system, sans-serif;
323 --mono: 'SFMono-Regular', Consolas, monospace;
324 }}
325 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
326 body {{ background: var(--bg); color: var(--text); font-family: var(--font);
327 display: flex; height: 100vh; overflow: hidden; }}
328 nav {{ width: 280px; min-width: 220px; background: var(--surface);
329 border-right: 1px solid var(--border); overflow-y: auto;
330 padding: 16px 0; flex-shrink: 0; }}
331 nav h2 {{ font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
332 color: var(--muted); padding: 0 16px 8px; }}
333 nav a {{ display: block; padding: 5px 16px; color: var(--muted);
334 text-decoration: none; font-size: 13px; white-space: nowrap;
335 overflow: hidden; text-overflow: ellipsis; }}
336 nav a:hover {{ color: var(--accent); background: rgba(88,166,255,.05); }}
337 main {{ flex: 1; overflow-y: auto; padding: 32px; }}
338 header {{ margin-bottom: 32px; }}
339 header h1 {{ font-size: 24px; margin-bottom: 8px; }}
340 .meta {{ color: var(--muted); font-size: 13px; }}
341 .stats {{ display: flex; gap: 24px; margin-top: 16px; flex-wrap: wrap; }}
342 .stat {{ background: var(--surface); border: 1px solid var(--border);
343 border-radius: 6px; padding: 12px 16px; }}
344 .stat-value {{ font-size: 22px; font-weight: 600; }}
345 .stat-label {{ font-size: 11px; color: var(--muted); text-transform: uppercase; }}
346 section.symbol {{ background: var(--surface); border: 1px solid var(--border);
347 border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
348 section.symbol h2 {{ font-size: 16px; font-family: var(--mono); margin-bottom: 6px; }}
349 .badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px;
350 font-size: 11px; font-weight: 600; text-transform: uppercase; }}
351 .badge-fn {{ background: #1f3a5f; color: var(--accent); }}
352 .badge-class {{ background: #2d1f5f; color: #d2a8ff; }}
353 .badge-method {{ background: #1a3a2a; color: var(--green); }}
354 .badge-var {{ background: #3a2a1a; color: #f0883e; }}
355 .badge-other {{ background: var(--border); color: var(--muted); }}
356 .health-bar {{ display: flex; align-items: center; gap: 8px; margin: 8px 0; }}
357 .health-track {{ flex: 1; max-width: 120px; height: 6px;
358 background: var(--border); border-radius: 3px; }}
359 .health-fill {{ height: 100%; border-radius: 3px; }}
360 .health-good {{ background: var(--green); }}
361 .health-mid {{ background: var(--yellow); }}
362 .health-bad {{ background: var(--red); }}
363 .health-score {{ font-size: 13px; color: var(--muted); }}
364 pre {{ background: var(--code-bg); border: 1px solid var(--border);
365 border-radius: 6px; padding: 12px; overflow-x: auto;
366 font-family: var(--mono); font-size: 13px; margin: 8px 0; }}
367 .docstring {{ background: var(--code-bg); border-left: 3px solid var(--accent);
368 padding: 12px; border-radius: 0 6px 6px 0; margin: 12px 0;
369 font-size: 14px; line-height: 1.6; white-space: pre-wrap; }}
370 .no-doc {{ color: var(--muted); font-style: italic; font-size: 13px; margin: 8px 0; }}
371 .section-title {{ font-size: 11px; text-transform: uppercase; color: var(--muted);
372 letter-spacing: 1px; margin: 12px 0 4px; }}
373 .pill-list {{ display: flex; flex-wrap: wrap; gap: 4px; }}
374 .pill {{ background: var(--code-bg); border: 1px solid var(--border);
375 border-radius: 4px; padding: 2px 8px; font-family: var(--mono);
376 font-size: 12px; color: var(--muted); }}
377 .version-badge {{ display: inline-block; background: #1a2a1a;
378 color: var(--green); border: 1px solid var(--green);
379 border-radius: 4px; padding: 2px 8px; font-size: 12px;
380 font-family: var(--mono); margin-right: 4px; }}
381 .stale-badge {{ background: #3a2a00; color: var(--yellow);
382 border: 1px solid var(--yellow); border-radius: 4px;
383 padding: 2px 8px; font-size: 11px; }}
384 .breaking {{ background: #3a1a1a; color: var(--red); border-radius: 4px;
385 padding: 4px 8px; font-size: 12px; margin: 2px 0; }}
386 .missing-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
387 .missing-table th, .missing-table td {{ text-align: left; padding: 6px 12px;
388 border-bottom: 1px solid var(--border); }}
389 .missing-table th {{ color: var(--muted); font-size: 11px; text-transform: uppercase; }}
390 </style>
391 </head>
392 <body>
393 <nav>
394 <h2>Symbols</h2>
395 {nav_links}
396 </nav>
397 <main>
398 <header>
399 <h1>Muse Documentation</h1>
400 <div class="meta">Commit <code>{commit_short}</code> &nbsp;·&nbsp; {generated_at}</div>
401 <div class="stats">
402 <div class="stat">
403 <div class="stat-value">{total}</div>
404 <div class="stat-label">Symbols</div>
405 </div>
406 <div class="stat">
407 <div class="stat-value">{documented}</div>
408 <div class="stat-label">Documented</div>
409 </div>
410 <div class="stat">
411 <div class="stat-value">{undocumented}</div>
412 <div class="stat-label">Missing</div>
413 </div>
414 <div class="stat">
415 <div class="stat-value">{stale}</div>
416 <div class="stat-label">Stale</div>
417 </div>
418 <div class="stat">
419 <div class="stat-value">{avg_health}</div>
420 <div class="stat-label">Avg Health</div>
421 </div>
422 <div class="stat">
423 <div class="stat-value">{debt}</div>
424 <div class="stat-label">Doc Debt</div>
425 </div>
426 </div>
427 </header>
428 {symbol_sections}
429 {missing_section}
430 </main>
431 </body>
432 </html>
433 """
434
435 _BADGE_CLASS: _BadgeMap = {
436 "function": "badge-fn",
437 "async_function": "badge-fn",
438 "class": "badge-class",
439 "method": "badge-method",
440 "async_method": "badge-method",
441 "variable": "badge-var",
442 }
443
444 def _e(s: str) -> str:
445 """HTML-escape *s*."""
446 return _html_module.escape(s, quote=True)
447
448 def _health_class(score: float) -> str:
449 if score >= 0.75:
450 return "health-good"
451 if score >= 0.50:
452 return "health-mid"
453 return "health-bad"
454
455 def _render_symbol_html(doc: SymbolDoc) -> str:
456 anchor = _e(doc["address"].replace(" ", "_"))
457 kind_badge = _BADGE_CLASS.get(doc["kind"], "badge-other")
458 kind_label = _e(doc["kind"].replace("_", " "))
459 score_pct = int(doc["doc_health"] * 100)
460 health_cls = _health_class(doc["doc_health"])
461
462 parts: list[str] = []
463 parts.append(f'<section class="symbol" id="{anchor}">')
464 parts.append(
465 f' <h2><code>{_e(doc["qualified_name"])}</code>'
466 f' <span class="badge {kind_badge}">{kind_label}</span></h2>'
467 )
468 parts.append(
469 f' <div class="meta">{_e(doc["file"])} '
470 f' lines {doc["lineno"]}–{doc["end_lineno"]}</div>'
471 )
472
473 # Health bar
474 parts.append(' <div class="health-bar">')
475 parts.append(' <div class="health-track">')
476 parts.append(
477 f' <div class="health-fill {health_cls}" '
478 f'style="width:{score_pct}%"></div>'
479 )
480 parts.append(" </div>")
481 parts.append(f' <span class="health-score">{doc["doc_health"]:.2f}</span>')
482 if "stale_impl" in doc["doc_health_reasons"]:
483 parts.append(' <span class="stale-badge">⚠ stale</span>')
484 parts.append(" </div>")
485
486 # Version badges
487 if doc["since_version"]:
488 parts.append(
489 f' <span class="version-badge">since {_e(doc["since_version"])}</span>'
490 )
491 if doc["last_changed_version"]:
492 parts.append(
493 f' <span class="version-badge">changed {_e(doc["last_changed_version"])}</span>'
494 )
495
496 # Signature
497 if doc["signature"]:
498 parts.append(f" <pre>{_e(doc['signature'])}</pre>")
499
500 # Docstring
501 if doc["docstring"]:
502 parts.append(f' <div class="docstring">{_e(doc["docstring"])}</div>')
503 else:
504 parts.append(' <div class="no-doc">No docstring.</div>')
505
506 # Breaking changes
507 if doc["breaking_changes"]:
508 parts.append(' <div class="section-title">Breaking Changes</div>')
509 for bc in doc["breaking_changes"]:
510 parts.append(f' <div class="breaking">{_e(bc)}</div>')
511
512 # Callers
513 if doc["callers"]:
514 parts.append(
515 f' <div class="section-title">Called by ({len(doc["callers"])})</div>'
516 )
517 parts.append(' <div class="pill-list">')
518 for c in doc["callers"][:15]:
519 parts.append(f' <span class="pill">{_e(c)}</span>')
520 if len(doc["callers"]) > 15:
521 parts.append(
522 f' <span class="pill">+{len(doc["callers"]) - 15} more</span>'
523 )
524 parts.append(" </div>")
525
526 # Callees
527 if doc["callees"]:
528 parts.append(
529 f' <div class="section-title">Calls ({len(doc["callees"])})</div>'
530 )
531 parts.append(' <div class="pill-list">')
532 for c in doc["callees"][:15]:
533 parts.append(f' <span class="pill">{_e(c)}</span>')
534 if len(doc["callees"]) > 15:
535 parts.append(
536 f' <span class="pill">+{len(doc["callees"]) - 15} more</span>'
537 )
538 parts.append(" </div>")
539
540 # Linked tests
541 if doc["linked_tests"]:
542 parts.append(
543 f' <div class="section-title">Tests ({len(doc["linked_tests"])})</div>'
544 )
545 parts.append(' <div class="pill-list">')
546 for t in doc["linked_tests"][:8]:
547 parts.append(f' <span class="pill">{_e(t)}</span>')
548 if len(doc["linked_tests"]) > 8:
549 parts.append(
550 f' <span class="pill">+{len(doc["linked_tests"]) - 8} more</span>'
551 )
552 parts.append(" </div>")
553
554 parts.append("</section>")
555 return "\n".join(parts)
556
557 def render_html(report: DocReport) -> str:
558 """Return a self-contained HTML page for *report*.
559
560 The page includes an inline sidebar TOC, health bars, version badges,
561 caller/callee pill lists, and linked test lists. It has zero external
562 dependencies and opens in any browser.
563
564 Args:
565 report: The documentation report to render.
566 """
567 s = report["summary"]
568
569 nav_links_parts: list[str] = []
570 for doc in report["symbols"]:
571 anchor = _e(doc["address"].replace(" ", "_"))
572 indicator = "✅" if doc["doc_health"] >= 0.75 else ("🟡" if doc["doc_health"] >= 0.50 else "🔴")
573 nav_links_parts.append(
574 f' <a href="#{anchor}" title="{_e(doc["address"])}">'
575 f'{indicator} {_e(doc["name"])}</a>'
576 )
577
578 symbol_sections = "\n".join(
579 _render_symbol_html(doc) for doc in report["symbols"]
580 )
581
582 # Missing section
583 missing_parts: list[str] = []
584 if report["missing"]:
585 missing_parts.append(
586 f'<section class="symbol">'
587 f'<h2>Missing Docstrings ({len(report["missing"])})</h2>'
588 f'<table class="missing-table">'
589 f"<tr><th>Callers</th><th>Address</th><th>Kind</th></tr>"
590 )
591 for m in report["missing"]:
592 missing_parts.append(
593 f"<tr><td>{m['caller_count']}</td>"
594 f"<td><code>{_e(m['address'])}</code></td>"
595 f"<td>{_e(m['kind'])}</td></tr>"
596 )
597 missing_parts.append("</table></section>")
598
599 return _HTML_TEMPLATE.format(
600 commit_short=_e(report["commit_id"]),
601 generated_at=_e(report["generated_at"][:19]),
602 total=s["total_symbols"],
603 documented=s["documented"],
604 undocumented=s["undocumented"],
605 stale=s["stale_count"],
606 avg_health=f"{s['avg_health']:.2f}",
607 debt=f"{s['doc_debt_score']:.2f}",
608 nav_links="\n".join(nav_links_parts),
609 symbol_sections=symbol_sections,
610 missing_section="\n".join(missing_parts),
611 )
612
613 # ---------------------------------------------------------------------------
614 # Dispatcher
615 # ---------------------------------------------------------------------------
616
617 def render(report: DocReport, fmt: RenderFormat) -> str:
618 """Render *report* in the requested *fmt*.
619
620 Args:
621 report: The documentation report from :func:`~doc_extractor.extract_docs`.
622 fmt: Output format: ``"json"``, ``"html"``, ``"markdown"``, or ``"text"``.
623
624 Returns:
625 A string in the requested format, ready to write to a file or stdout.
626
627 Raises:
628 ValueError: When *fmt* is not one of the supported values.
629 """
630 if fmt == "json":
631 return render_json(report)
632 if fmt == "html":
633 return render_html(report)
634 if fmt == "markdown":
635 return render_markdown(report)
636 if fmt == "text":
637 return render_text(report)
638 raise ValueError(f"Unsupported render format: {fmt!r}")
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago