From 58566aba0b18a9b83e7530989cd19741d1945502 Mon Sep 17 00:00:00 2001 From: Nathan Castaldi Date: Wed, 15 Apr 2026 18:33:54 -0400 Subject: [PATCH] feat(reports): add multi-format rendering (html, pdf, docx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add report_templates package (_renderer.py) with MD/HTML/PDF/DOCX support using lazy optional imports (markdown, weasyprint, python-docx) - Apply Wheels brand colors (#003865/#0066cc) to HTML and DOCX output - Extend save_report MCP tool with `format` param (default: "md"); binary formats use write_bytes, text formats keep async writer - Add [report] optional dep group to pyproject.toml for one-step install - Fix ReportConfig.output_dir default: ./reports → ./output-reports to align config.py with the codified REPORT_OUTPUT_DIR env setting Ref: session 2026-04-15 — exec-friendly report output track BREAKING CHANGE: ReportConfig.output_dir default changed from ./reports to ./output-reports. Environments without REPORT_OUTPUT_DIR set in .env will write to a different path after this change. --- nexus-mcp/lib/config.py | 4 +- nexus-mcp/pyproject.toml | 8 + nexus-mcp/src/report_templates/__init__.py | 28 ++ nexus-mcp/src/report_templates/_renderer.py | 288 ++++++++++++++++++++ nexus-mcp/src/shards/reports.py | 34 ++- 5 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 nexus-mcp/src/report_templates/__init__.py create mode 100644 nexus-mcp/src/report_templates/_renderer.py diff --git a/nexus-mcp/lib/config.py b/nexus-mcp/lib/config.py index 44ec574..ed2a354 100644 --- a/nexus-mcp/lib/config.py +++ b/nexus-mcp/lib/config.py @@ -135,8 +135,8 @@ class ReportConfig(BaseSettings): env_file_encoding="utf-8", extra="ignore" ) - - output_dir: Path = Path("./reports") + + output_dir: Path = Path("./output-reports") @field_validator("output_dir", mode="before") @classmethod diff --git a/nexus-mcp/pyproject.toml b/nexus-mcp/pyproject.toml index 4f4f29e..f160dc4 100644 --- a/nexus-mcp/pyproject.toml +++ b/nexus-mcp/pyproject.toml @@ -32,6 +32,14 @@ test = [ "pytest-cov>=5.0.0", "pytest-asyncio>=0.24.0", ] +report = [ + # Markdown → HTML conversion + "markdown>=3.6", + # HTML → PDF (Windows: also needs GTK3 DLLs — see weasyprint docs) + "weasyprint>=62.0", + # Markdown → DOCX + "python-docx>=1.1.0", +] [tool.setuptools.packages.find] where = ["src"] diff --git a/nexus-mcp/src/report_templates/__init__.py b/nexus-mcp/src/report_templates/__init__.py new file mode 100644 index 0000000..7f19b00 --- /dev/null +++ b/nexus-mcp/src/report_templates/__init__.py @@ -0,0 +1,28 @@ +"""report_templates — multi-format rendering for Nexus output reports. + +Usage +----- +from report_templates import ReportFormat, render + +# Markdown (default — no extra deps) +md_str = render(markdown, ReportFormat.MD, title="My Report") + +# HTML (requires: pip install markdown) +html_str = render(markdown, ReportFormat.HTML, title="My Report") + +# PDF (requires: pip install weasyprint markdown) +# On Windows: also install GTK3 runtime DLLs +# https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows +pdf_bytes = render(markdown, ReportFormat.PDF, title="My Report") + +# DOCX (requires: pip install python-docx) +docx_bytes = render(markdown, ReportFormat.DOCX, title="My Report") + +Install all at once +------------------- +pip install "nexus-mcp[report]" +""" + +from ._renderer import ReportFormat, render + +__all__ = ["ReportFormat", "render"] diff --git a/nexus-mcp/src/report_templates/_renderer.py b/nexus-mcp/src/report_templates/_renderer.py new file mode 100644 index 0000000..2f8bb56 --- /dev/null +++ b/nexus-mcp/src/report_templates/_renderer.py @@ -0,0 +1,288 @@ +"""Multi-format report renderer for Nexus MCP. + +Converts a markdown string to the requested output format. +All non-markdown renderers have optional dependencies guarded with +clear ImportError messages — the server starts fine without them. + +Formats +------- +md — plain markdown (no extra deps) +html — standalone HTML with Wheels-branded CSS + requires: pip install markdown +pdf — PDF generated from the branded HTML + requires: pip install weasyprint markdown + Windows note: GTK3 runtime DLLs are also required + https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows +docx — Word document + requires: pip install python-docx +""" + +from __future__ import annotations + +import io +from enum import Enum + + +# ── Brand / style constants ─────────────────────────────────────────────────── + +_BRAND_PRIMARY = "#003865" # Wheels dark blue +_BRAND_ACCENT = "#0066cc" # Wheels mid blue +_BRAND_ROW_ALT = "#f5f8fc" # table row stripe + +_CSS = f""" +body {{ + font-family: 'Segoe UI', Arial, sans-serif; + margin: 40px auto; + max-width: 960px; + color: #222; + line-height: 1.55; +}} +h1 {{ + color: {_BRAND_PRIMARY}; + border-bottom: 3px solid {_BRAND_PRIMARY}; + padding-bottom: 8px; + margin-bottom: 4px; +}} +h2 {{ + color: {_BRAND_ACCENT}; + margin-top: 32px; +}} +h3 {{ color: #444; }} +table {{ + border-collapse: collapse; + width: 100%; + margin: 16px 0; + font-size: 0.92em; +}} +th {{ + background: {_BRAND_PRIMARY}; + color: #fff; + padding: 8px 12px; + text-align: left; + font-weight: 600; +}} +td {{ + padding: 7px 12px; + border-bottom: 1px solid #ddd; +}} +tr:nth-child(even) td {{ + background: {_BRAND_ROW_ALT}; +}} +code, pre {{ + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; + font-family: 'Consolas', 'Courier New', monospace; +}} +ul, ol {{ padding-left: 1.4em; }} +li {{ margin-bottom: 4px; }} +.meta {{ + color: #666; + font-size: 0.88em; + margin-bottom: 20px; +}} +""" + +_HTML_WRAPPER = """\ + + + + + + {title} + + + +{body} + + +""" + + +# ── Public API ──────────────────────────────────────────────────────────────── + +class ReportFormat(str, Enum): + MD = "md" + HTML = "html" + PDF = "pdf" + DOCX = "docx" + + @property + def extension(self) -> str: + return self.value + + @property + def is_binary(self) -> bool: + return self in (ReportFormat.PDF, ReportFormat.DOCX) + + +def render( + markdown_content: str, + fmt: ReportFormat, + title: str = "Report", +) -> bytes | str: + """Render *markdown_content* to the requested format. + + Returns: + str for MD and HTML + bytes for PDF and DOCX + """ + if fmt == ReportFormat.MD: + return markdown_content + + html = _md_to_html(markdown_content, title) + + if fmt == ReportFormat.HTML: + return html + + if fmt == ReportFormat.PDF: + return _html_to_pdf(html) + + if fmt == ReportFormat.DOCX: + return _md_to_docx(markdown_content, title) + + raise ValueError(f"Unsupported format: {fmt!r}") + + +# ── Internal renderers ──────────────────────────────────────────────────────── + +def _md_to_html(md: str, title: str) -> str: + """Convert markdown string to a full, styled HTML document.""" + try: + import markdown as _md_lib + except ImportError as exc: + raise ImportError( + "HTML/PDF output requires the 'markdown' package.\n" + " pip install markdown\n" + " or: pip install 'nexus-mcp[report]'" + ) from exc + + body = _md_lib.markdown( + md, + extensions=["tables", "fenced_code", "nl2br"], + ) + return _HTML_WRAPPER.format(title=title, css=_CSS, body=body) + + +def _html_to_pdf(html: str) -> bytes: + """Convert a full HTML string to PDF bytes using WeasyPrint.""" + try: + from weasyprint import HTML as _HTML + except ImportError as exc: + raise ImportError( + "PDF output requires WeasyPrint.\n" + " pip install weasyprint\n" + " or: pip install 'nexus-mcp[report]'\n" + "\n" + "Windows: GTK3 runtime DLLs are also required:\n" + " https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows" + ) from exc + + return _HTML(string=html).write_pdf() + + +def _md_to_docx(md: str, title: str) -> bytes: + """Convert markdown to a .docx document using python-docx. + + Handles: headings (#/##/###), bullet lists (- …), pipe tables, + key-value pairs (- Key: Value), and plain paragraphs. + """ + try: + from docx import Document + from docx.shared import Pt, RGBColor + from docx.oxml.ns import qn + from docx.oxml import OxmlElement + except ImportError as exc: + raise ImportError( + "DOCX output requires python-docx.\n" + " pip install python-docx\n" + " or: pip install 'nexus-mcp[report]'" + ) from exc + + doc = Document() + + # ── document title ──────────────────────────────────────────────────────── + heading = doc.add_heading(title, level=0) + heading.runs[0].font.color.rgb = RGBColor(0x00, 0x38, 0x65) + + lines = md.splitlines() + i = 0 + + while i < len(lines): + line = lines[i] + + # Skip blank lines + if not line.strip(): + i += 1 + continue + + # Heading 1 + if line.startswith("# "): + doc.add_heading(line[2:].strip(), level=1) + i += 1 + continue + + # Heading 2 + if line.startswith("## "): + doc.add_heading(line[3:].strip(), level=2) + i += 1 + continue + + # Heading 3 + if line.startswith("### "): + doc.add_heading(line[4:].strip(), level=3) + i += 1 + continue + + # Bullet + if line.startswith("- "): + doc.add_paragraph(line[2:].strip(), style="List Bullet") + i += 1 + continue + + # Pipe table: collect header + separator + data rows + if line.startswith("|") and i + 1 < len(lines) and lines[i + 1].startswith("|---"): + headers = [c.strip() for c in line.strip().strip("|").split("|")] + i += 2 # skip separator row + data_rows: list[list[str]] = [] + while i < len(lines) and lines[i].startswith("|"): + data_rows.append( + [c.strip() for c in lines[i].strip().strip("|").split("|")] + ) + i += 1 + + ncols = len(headers) + t = doc.add_table(rows=1 + len(data_rows), cols=ncols) + t.style = "Table Grid" + + # Header row — bold + brand colour fill + hdr_row = t.rows[0] + for j, cell_text in enumerate(headers): + cell = hdr_row.cells[j] + cell.text = cell_text + run = cell.paragraphs[0].runs[0] + run.bold = True + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + # Fill background + tc_pr = cell._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), "003865") + tc_pr.append(shd) + + for r_idx, row_data in enumerate(data_rows): + for j, cell_text in enumerate(row_data): + if j < ncols: + t.rows[r_idx + 1].cells[j].text = cell_text + + continue + + # Fallback: plain paragraph + doc.add_paragraph(line) + i += 1 + + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() diff --git a/nexus-mcp/src/shards/reports.py b/nexus-mcp/src/shards/reports.py index b449f62..349a734 100644 --- a/nexus-mcp/src/shards/reports.py +++ b/nexus-mcp/src/shards/reports.py @@ -6,10 +6,11 @@ import logging import re from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Literal, Optional import aiofiles from mcp.server.fastmcp import FastMCP +from report_templates import ReportFormat, render as _render logger = logging.getLogger("nexus-mcp.reports") @@ -55,25 +56,34 @@ def register(mcp: FastMCP) -> None: content: str, subfolder: Optional[str] = None, filename: Optional[str] = None, + format: Literal["md", "html", "pdf", "docx"] = "md", ) -> dict: """Save markdown content to the configured reports directory. - Accepts a large markdown string, writes it as a .md file, and returns - only a small metadata dict (path, filename, size_bytes) so the chat - context stays lightweight regardless of report size. + Accepts a markdown string, converts it to the requested format, writes + it to disk, and returns a small metadata dict so the chat context stays + lightweight regardless of report size. Args: title: Human-readable report title. Used to derive filename when 'filename' is not provided. - content: Full markdown body to write to disk. + content: Full markdown body to render and write to disk. subfolder: Optional subdirectory under the output dir (e.g. "identity"). Non-alphanumeric characters are replaced with hyphens. filename: Override filename base (without extension). Defaults to a slugified version of 'title' with an ISO timestamp suffix. + format: Output format — "md" (default), "html", "pdf", or "docx". + Extra packages are required for non-markdown formats: + html/pdf → pip install markdown (+ weasyprint for pdf) + pdf → also needs GTK3 DLLs on Windows + docx → pip install python-docx + Install all at once: pip install "nexus-mcp[report]" Returns: {"status": "saved", "path": str, "filename": str, "size_bytes": int} """ + fmt = ReportFormat(format) + base_dir = _output_dir if subfolder: safe_sub = re.sub(r"[^\w\-]", "-", subfolder.strip()) @@ -83,7 +93,7 @@ def register(mcp: FastMCP) -> None: safe_filename = ( re.sub(r"[^\w\-]", "-", filename.strip()) if filename else _slugify(title) ) - final_filename = f"{safe_filename}-{timestamp}.md" + final_filename = f"{safe_filename}-{timestamp}.{fmt.extension}" abs_path = (base_dir / final_filename).resolve() # Safety guard: keep all writes inside the permitted output tree @@ -97,8 +107,16 @@ def register(mcp: FastMCP) -> None: abs_path.parent.mkdir(parents=True, exist_ok=True) - async with aiofiles.open(abs_path, "w", encoding="utf-8") as f: - await f.write(content) + try: + rendered = _render(content, fmt, title=title) + except ImportError as exc: + return {"status": "error", "error": str(exc)} + + if fmt.is_binary: + abs_path.write_bytes(rendered) + else: + async with aiofiles.open(abs_path, "w", encoding="utf-8") as f: + await f.write(rendered) size_bytes = abs_path.stat().st_size logger.info("save_report: wrote %d bytes → %s", size_bytes, abs_path)