feat(reports): add multi-format rendering (html, pdf, docx)

- 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.
This commit is contained in:
Nathan Castaldi 2026-04-15 18:33:54 -04:00
parent fa366e8b72
commit 58566aba0b
5 changed files with 352 additions and 10 deletions

View File

@ -136,7 +136,7 @@ class ReportConfig(BaseSettings):
extra="ignore" extra="ignore"
) )
output_dir: Path = Path("./reports") output_dir: Path = Path("./output-reports")
@field_validator("output_dir", mode="before") @field_validator("output_dir", mode="before")
@classmethod @classmethod

View File

@ -32,6 +32,14 @@ test = [
"pytest-cov>=5.0.0", "pytest-cov>=5.0.0",
"pytest-asyncio>=0.24.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] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -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"]

View File

@ -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 = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{css}</style>
</head>
<body>
{body}
</body>
</html>
"""
# ── 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()

View File

@ -6,10 +6,11 @@ import logging
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Literal, Optional
import aiofiles import aiofiles
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from report_templates import ReportFormat, render as _render
logger = logging.getLogger("nexus-mcp.reports") logger = logging.getLogger("nexus-mcp.reports")
@ -55,25 +56,34 @@ def register(mcp: FastMCP) -> None:
content: str, content: str,
subfolder: Optional[str] = None, subfolder: Optional[str] = None,
filename: Optional[str] = None, filename: Optional[str] = None,
format: Literal["md", "html", "pdf", "docx"] = "md",
) -> dict: ) -> dict:
"""Save markdown content to the configured reports directory. """Save markdown content to the configured reports directory.
Accepts a large markdown string, writes it as a .md file, and returns Accepts a markdown string, converts it to the requested format, writes
only a small metadata dict (path, filename, size_bytes) so the chat it to disk, and returns a small metadata dict so the chat context stays
context stays lightweight regardless of report size. lightweight regardless of report size.
Args: Args:
title: Human-readable report title. Used to derive filename when title: Human-readable report title. Used to derive filename when
'filename' is not provided. '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"). subfolder: Optional subdirectory under the output dir (e.g. "identity").
Non-alphanumeric characters are replaced with hyphens. Non-alphanumeric characters are replaced with hyphens.
filename: Override filename base (without extension). Defaults to a filename: Override filename base (without extension). Defaults to a
slugified version of 'title' with an ISO timestamp suffix. 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: Returns:
{"status": "saved", "path": str, "filename": str, "size_bytes": int} {"status": "saved", "path": str, "filename": str, "size_bytes": int}
""" """
fmt = ReportFormat(format)
base_dir = _output_dir base_dir = _output_dir
if subfolder: if subfolder:
safe_sub = re.sub(r"[^\w\-]", "-", subfolder.strip()) safe_sub = re.sub(r"[^\w\-]", "-", subfolder.strip())
@ -83,7 +93,7 @@ def register(mcp: FastMCP) -> None:
safe_filename = ( safe_filename = (
re.sub(r"[^\w\-]", "-", filename.strip()) if filename else _slugify(title) 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() abs_path = (base_dir / final_filename).resolve()
# Safety guard: keep all writes inside the permitted output tree # 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) abs_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(abs_path, "w", encoding="utf-8") as f: try:
await f.write(content) 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 size_bytes = abs_path.stat().st_size
logger.info("save_report: wrote %d bytes → %s", size_bytes, abs_path) logger.info("save_report: wrote %d bytes → %s", size_bytes, abs_path)