doc_renderer.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
19 hours 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> · {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
7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
19 hours ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
9 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
21 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