From 71dfdc8d9424dcd809fb86972690e710c78b8006 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 15 Feb 2026 18:05:49 -0700 Subject: [PATCH] Add SpiceBook integration -- publish circuits as interactive notebooks Bridge mcltspice to SpiceBook (spicebook.warehack.ing) with 5 new MCP tools (publish, list, get, delete, simulate), a status resource, and a guided publish prompt. Includes LTspice-to-ngspice netlist conversion (strips .backanno, Rser=, Windows paths) with structured warnings. Path traversal protection on file reads (extension allowlist + resolve), notebook ID validation, narrowed exception handling, and size limits. 35 unit tests + 4 live integration tests. --- src/mcltspice/config.py | 17 ++ src/mcltspice/server.py | 272 ++++++++++++++++++++++++++++ src/mcltspice/spicebook.py | 231 ++++++++++++++++++++++++ tests/test_spicebook.py | 361 +++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 src/mcltspice/spicebook.py create mode 100644 tests/test_spicebook.py diff --git a/src/mcltspice/config.py b/src/mcltspice/config.py index 6905410..800bb5e 100644 --- a/src/mcltspice/config.py +++ b/src/mcltspice/config.py @@ -20,6 +20,23 @@ WINE_DEBUG = os.environ.get("WINE_DEBUG", "-all") DEFAULT_TIMEOUT = 300 # 5 minutes MAX_RAW_FILE_SIZE = 500 * 1024 * 1024 # 500MB +# SpiceBook integration +SPICEBOOK_URL = os.environ.get("SPICEBOOK_URL", "https://spicebook.warehack.ing") + + +def _parse_timeout(env_var: str, default: float) -> float: + raw = os.environ.get(env_var, "") + if not raw: + return default + try: + val = float(raw) + return val if val > 0 else default + except ValueError: + return default + + +SPICEBOOK_TIMEOUT = _parse_timeout("SPICEBOOK_TIMEOUT", 30.0) + def get_wine_env() -> dict[str, str]: """Get environment variables for Wine execution.""" diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py index 0aa8c26..b0a4bf9 100644 --- a/src/mcltspice/server.py +++ b/src/mcltspice/server.py @@ -16,6 +16,7 @@ import math import tempfile from pathlib import Path +import httpx import numpy as np from fastmcp import FastMCP from fastmcp.prompts import Message @@ -73,6 +74,7 @@ from .batch import ( from .config import ( LTSPICE_EXAMPLES, LTSPICE_LIB, + SPICEBOOK_URL, validate_installation, ) from .diff import diff_schematics as _diff_schematics @@ -113,6 +115,11 @@ from .power_analysis import compute_efficiency, compute_power_metrics from .raw_parser import parse_raw_file from .runner import run_netlist, run_simulation from .schematic import modify_component_value, parse_schematic +from .spicebook import ( + SpiceBookClient, + build_notebook_cells, + ltspice_to_ngspice, +) from .stability import compute_stability_metrics from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi from .touchstone import parse_touchstone, s_param_to_db @@ -155,9 +162,14 @@ mcp = FastMCP( - Run parameter sweeps, temperature sweeps, and Monte Carlo analysis - Handle stepped simulations: list runs, extract per-run data - Parse Touchstone (.s2p) S-parameter files + - Publish circuits as interactive notebooks on SpiceBook LTspice runs via Wine on Linux. Simulations execute in batch mode and results are parsed from binary .raw files. + + SpiceBook integration lets you share simulation results as runnable + notebooks at spicebook.warehack.ing. Use spicebook_publish to convert + an LTspice netlist to ngspice format and create a shareable notebook. """, ) @@ -2842,6 +2854,266 @@ Common failure modes: """)] +# ============================================================================ +# SPICEBOOK INTEGRATION TOOLS +# ============================================================================ + + +_NETLIST_EXTENSIONS = {".cir", ".net", ".sp", ".spice", ".asc"} +_MAX_NETLIST_SIZE = 10 * 1024 * 1024 # 10 MB + + +def _spicebook_error(e: Exception) -> dict: + """Format a SpiceBook error into a consistent response dict.""" + if isinstance(e, httpx.ConnectError): + return { + "error": f"SpiceBook not reachable at {SPICEBOOK_URL}", + "hint": "Check connectivity or set SPICEBOOK_URL env var", + } + if isinstance(e, httpx.TimeoutException): + return {"error": "SpiceBook request timed out"} + if isinstance(e, httpx.HTTPStatusError): + body = e.response.text[:500] if e.response else "" + return {"error": f"HTTP {e.response.status_code}", "detail": body} + return {"error": str(e)} + + +def _read_netlist( + netlist_text: str | None, netlist_path: str | None +) -> tuple[str | None, dict | None]: + """Resolve netlist from text or file path. Returns (text, error_dict).""" + if netlist_text and netlist_path: + return None, {"error": "Provide netlist_text or netlist_path, not both"} + if netlist_text: + if len(netlist_text) > _MAX_NETLIST_SIZE: + return None, {"error": f"Netlist text too large (max {_MAX_NETLIST_SIZE} bytes)"} + return netlist_text, None + if netlist_path: + p = Path(netlist_path).resolve() + if p.suffix.lower() not in _NETLIST_EXTENSIONS: + return None, { + "error": f"Unsupported file type '{p.suffix}'. " + f"Expected: {', '.join(sorted(_NETLIST_EXTENSIONS))}" + } + if not p.exists(): + return None, {"error": f"File not found: {p}"} + try: + size = p.stat().st_size + if size > _MAX_NETLIST_SIZE: + return None, {"error": f"File too large ({size} bytes, max {_MAX_NETLIST_SIZE})"} + return p.read_text(encoding="utf-8"), None + except (OSError, UnicodeDecodeError) as e: + return None, {"error": f"Cannot read {p}: {e}"} + return None, {"error": "Provide netlist_text or netlist_path"} + + +@mcp.tool() +async def spicebook_publish( + netlist_text: str | None = None, + netlist_path: str | None = None, + title: str = "Untitled Circuit", + description: str | None = None, + tags: list[str] | None = None, + run: bool = False, +) -> dict: + """Publish an LTspice netlist as an interactive SpiceBook notebook. + + Converts the netlist from LTspice dialect to ngspice, builds + notebook cells, and creates the notebook on SpiceBook. Returns + the notebook ID and URL. + + Args: + netlist_text: Netlist as a string (mutually exclusive with netlist_path) + netlist_path: Path to a .cir/.net file on disk + title: Notebook title + description: Optional description shown in the intro cell + tags: Optional list of tags for categorization + run: If True, SpiceBook runs the simulation on publish + """ + text, err = _read_netlist(netlist_text, netlist_path) + if err: + return err + assert text is not None # _read_netlist guarantees text when err is None + + converted, warnings = ltspice_to_ngspice(text) + cells = build_notebook_cells( + title=title, + description=description, + netlist=converted, + warnings=warnings if warnings else None, + ) + + client = SpiceBookClient() + try: + result = await client.compose( + title=title, + cells=cells, + tags=tags, + run=run, + ) + except (httpx.HTTPError, OSError, ValueError) as e: + return _spicebook_error(e) + + notebook_id = result.get("id", "") + return { + "notebook_id": notebook_id, + "url": f"{SPICEBOOK_URL}/notebooks/{notebook_id}", + "cell_count": len(cells), + "conversion_warnings": warnings, + } + + +@mcp.tool() +async def spicebook_list() -> dict: + """List all notebooks on SpiceBook. + + Returns notebook summaries including id, title, tags, and modified date. + """ + client = SpiceBookClient() + try: + notebooks = await client.list_notebooks() + except (httpx.HTTPError, OSError) as e: + return _spicebook_error(e) + return {"notebooks": notebooks, "count": len(notebooks)} + + +@mcp.tool() +async def spicebook_get( + notebook_id: str, +) -> dict: + """Get a full SpiceBook notebook with all cells and outputs. + + Args: + notebook_id: The notebook ID to retrieve + """ + client = SpiceBookClient() + try: + return await client.get_notebook(notebook_id) + except (httpx.HTTPError, OSError, ValueError) as e: + return _spicebook_error(e) + + +@mcp.tool() +async def spicebook_delete( + notebook_id: str, +) -> dict: + """Delete a SpiceBook notebook. + + Args: + notebook_id: The notebook ID to delete + """ + client = SpiceBookClient() + try: + await client.delete_notebook(notebook_id) + except (httpx.HTTPError, OSError, ValueError) as e: + return _spicebook_error(e) + return {"deleted": True, "notebook_id": notebook_id} + + +@mcp.tool() +async def spicebook_simulate( + netlist_text: str | None = None, + netlist_path: str | None = None, +) -> dict: + """Run a netlist via SpiceBook's ngspice engine without creating a notebook. + + Useful for testing ngspice compatibility before publishing. + Converts the netlist from LTspice dialect to ngspice first. + + Args: + netlist_text: Netlist as a string (mutually exclusive with netlist_path) + netlist_path: Path to a .cir/.net file on disk + """ + text, err = _read_netlist(netlist_text, netlist_path) + if err: + return err + assert text is not None + + converted, warnings = ltspice_to_ngspice(text) + + client = SpiceBookClient() + try: + result = await client.simulate(converted) + except (httpx.HTTPError, OSError) as e: + return _spicebook_error(e) + + if warnings: + result["conversion_warnings"] = warnings + return result + + +# ============================================================================ +# SPICEBOOK RESOURCE & PROMPT +# ============================================================================ + + +@mcp.resource("spicebook://status") +async def spicebook_status() -> str: + """SpiceBook service status -- URL and reachability.""" + client = SpiceBookClient() + ok = await client.health() + status = "reachable" if ok else "unreachable" + return f"SpiceBook URL: {SPICEBOOK_URL}\nStatus: {status}" + + +@mcp.prompt() +def publish_to_spicebook( + circuit_description: str = "", + netlist_path: str = "", +) -> list: + """Step-by-step guide for publishing a circuit to SpiceBook. + + Walks through: verify locally in LTspice, test ngspice compatibility + via SpiceBook, then publish as an interactive notebook. + + Args: + circuit_description: What circuit you want to publish + netlist_path: Path to the netlist or schematic + """ + path_note = ( + f"Netlist/schematic: {netlist_path}" + if netlist_path + else "First, identify or create the netlist." + ) + desc_note = ( + f"Circuit: {circuit_description}" + if circuit_description + else "No description given -- describe the circuit for the notebook." + ) + + return [Message(role="user", content=f"""Publish a circuit to SpiceBook as an interactive notebook. + +{path_note} +{desc_note} + +Workflow: +1. **Verify locally** -- simulate the circuit with LTspice: + - Use simulate or simulate_netlist to confirm it works + - Use get_waveform / analyze_waveform to check results + - Fix any issues before publishing + +2. **Test ngspice compatibility** -- dry run on SpiceBook: + - Use spicebook_simulate with the netlist + - Check for conversion warnings (LTspice -> ngspice differences) + - If errors occur, adjust the netlist (remove LTspice-specific constructs) + +3. **Publish** -- create the notebook: + - Use spicebook_publish with a descriptive title and tags + - Include a description explaining the circuit purpose and key specs + - Set run=True if you want SpiceBook to execute the simulation + +4. **Verify** -- check the published notebook: + - Use spicebook_get to confirm the notebook was created + - Visit the URL to see the interactive version + +Tips: +- Add tags like "filter", "amplifier", "power" for discoverability +- The converter handles .backanno, Rser=, Windows paths automatically +- Use spicebook_list to see all published notebooks +- Use spicebook_delete to remove test notebooks +""")] + + # ============================================================================ # ENTRY POINT # ============================================================================ diff --git a/src/mcltspice/spicebook.py b/src/mcltspice/spicebook.py new file mode 100644 index 0000000..542c41b --- /dev/null +++ b/src/mcltspice/spicebook.py @@ -0,0 +1,231 @@ +"""SpiceBook integration -- publish LTspice circuits as interactive notebooks. + +SpiceBook (spicebook.warehack.ing) is a web notebook service for SPICE circuits. +This module provides: + - SpiceBookClient: async HTTP client for the SpiceBook REST API + - ltspice_to_ngspice: best-effort netlist dialect conversion + - build_notebook_cells: assemble notebook cells for the compose endpoint +""" + +import re + +import httpx + +from .config import SPICEBOOK_TIMEOUT, SPICEBOOK_URL + +# --------------------------------------------------------------------------- +# Netlist conversion: LTspice -> ngspice +# --------------------------------------------------------------------------- + +# Directives that are LTspice-only and should be stripped (precomputed lowercase) +_LTSPICE_ONLY_DIRECTIVES_LOWER = { + ".backanno", + ".options plotwinsize=0", +} + +# Notebook IDs must be alphanumeric with dashes/underscores +_NOTEBOOK_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") + +# Regex for .lib/.include with Windows-style paths (backslashes or drive letters) +_WINDOWS_PATH_RE = re.compile( + r"^\s*\.(lib|include)\s+.*([A-Za-z]:\\|\\\\)", re.IGNORECASE +) + +# Regex for Rser= on capacitor or inductor lines +_RSER_RE = re.compile(r"\s+Rser=\S+", re.IGNORECASE) + + +def ltspice_to_ngspice(netlist_text: str) -> tuple[str, list[str]]: + """Convert an LTspice netlist to ngspice-compatible form. + + Returns (cleaned_netlist, warnings) where warnings describe any + constructs that were modified or removed. + """ + lines = netlist_text.splitlines() + out: list[str] = [] + warnings: list[str] = [] + + for line in lines: + stripped = line.strip() + + # Strip LTspice-only directives + if stripped.lower() in _LTSPICE_ONLY_DIRECTIVES_LOWER: + continue + + # Comment out .lib/.include with Windows paths + if _WINDOWS_PATH_RE.match(line): + out.append(f"* {line} ; commented out -- Windows path") + warnings.append(f"Commented out Windows path: {stripped}") + continue + + # Strip Rser= from component lines (C or L) + if stripped and stripped[0].upper() in ("C", "L") and "Rser=" in line: + cleaned = _RSER_RE.sub("", line) + out.append(cleaned) + warnings.append(f"Stripped Rser= from: {stripped}") + continue + + out.append(line) + + return "\n".join(out), warnings + + +# --------------------------------------------------------------------------- +# Cell builder for SpiceBook compose endpoint +# --------------------------------------------------------------------------- + + +def build_notebook_cells( + title: str, + description: str | None, + netlist: str, + warnings: list[str] | None = None, + analysis: dict | None = None, + svgs: list[str] | None = None, +) -> list[dict]: + """Build the cell list for SpiceBook's POST /api/notebooks/compose. + + Args: + title: Notebook title (also used in the intro cell heading). + description: Optional circuit description. + netlist: ngspice-compatible netlist text. + warnings: Conversion warnings from ltspice_to_ngspice. + analysis: Optional dict of analysis results (rendered as a table). + svgs: Optional list of SVG strings to embed as images. + + Returns: + List of cell dicts ready for the compose endpoint. + """ + cells: list[dict] = [] + + # 1. Intro markdown cell + intro_parts = [f"# {title}"] + if description: + intro_parts.append(description) + if warnings: + intro_parts.append("### Conversion notes") + for w in warnings: + intro_parts.append(f"- {w}") + cells.append({"type": "markdown", "source": "\n\n".join(intro_parts)}) + + # 2. SPICE netlist cell + cells.append({"type": "spice", "source": netlist}) + + # 3. Optional analysis results as markdown table + if analysis: + rows = ["| Metric | Value |", "|--------|-------|"] + for key, val in analysis.items(): + rows.append(f"| {key} | {val} |") + cells.append({"type": "markdown", "source": "\n".join(rows)}) + + # 4. Optional SVG plot cells + if svgs: + for i, svg in enumerate(svgs): + label = f"### Plot {i + 1}" if len(svgs) > 1 else "### Plot" + cells.append({"type": "markdown", "source": f"{label}\n\n{svg}"}) + + return cells + + +# --------------------------------------------------------------------------- +# SpiceBook async HTTP client +# --------------------------------------------------------------------------- + + +class SpiceBookClient: + """Thin async wrapper around the SpiceBook REST API.""" + + def __init__( + self, + base_url: str = SPICEBOOK_URL, + timeout: float = SPICEBOOK_TIMEOUT, + ): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + ) + + async def health(self) -> bool: + """Check if SpiceBook is reachable. Returns False on any network error.""" + try: + async with self._client() as client: + resp = await client.get("/health") + return resp.status_code == 200 + except (httpx.HTTPError, OSError): + return False + + @staticmethod + def _validate_notebook_id(notebook_id: str) -> str | None: + """Return an error message if notebook_id is unsafe for URL interpolation.""" + if not notebook_id or not _NOTEBOOK_ID_RE.match(notebook_id): + return f"Invalid notebook_id: expected alphanumeric/dash/underscore, got '{notebook_id[:80]}'" + return None + + async def compose( + self, + title: str, + cells: list[dict], + tags: list[str] | None = None, + run: bool = False, + engine: str = "ngspice", + ) -> dict: + """Create a notebook with cells in one atomic call.""" + payload: dict = { + "title": title, + "cells": cells, + "engine": engine, + } + if tags: + payload["tags"] = tags + if run: + payload["run"] = True + + async with self._client() as client: + resp = await client.post("/api/notebooks/compose", json=payload) + resp.raise_for_status() + return resp.json() + + async def list_notebooks(self) -> list[dict]: + """List all notebooks.""" + async with self._client() as client: + resp = await client.get("/api/notebooks") + resp.raise_for_status() + return resp.json() + + async def get_notebook(self, notebook_id: str) -> dict: + """Get full notebook by ID.""" + err = self._validate_notebook_id(notebook_id) + if err: + raise ValueError(err) + async with self._client() as client: + resp = await client.get(f"/api/notebooks/{notebook_id}") + resp.raise_for_status() + return resp.json() + + async def delete_notebook(self, notebook_id: str) -> bool: + """Delete a notebook. Returns True on success.""" + err = self._validate_notebook_id(notebook_id) + if err: + raise ValueError(err) + async with self._client() as client: + resp = await client.delete(f"/api/notebooks/{notebook_id}") + resp.raise_for_status() + return True + + async def simulate( + self, + netlist: str, + engine: str = "ngspice", + ) -> dict: + """Run a stateless simulation without creating a notebook.""" + async with self._client() as client: + resp = await client.post( + "/api/simulate", + json={"netlist": netlist, "engine": engine}, + ) + resp.raise_for_status() + return resp.json() diff --git a/tests/test_spicebook.py b/tests/test_spicebook.py new file mode 100644 index 0000000..7d559c8 --- /dev/null +++ b/tests/test_spicebook.py @@ -0,0 +1,361 @@ +"""Tests for SpiceBook integration (converter, cell builder, and live API).""" + +import pytest + +from mcltspice.server import _read_netlist +from mcltspice.spicebook import ( + SpiceBookClient, + build_notebook_cells, + ltspice_to_ngspice, +) + +# --------------------------------------------------------------------------- +# ltspice_to_ngspice converter tests +# --------------------------------------------------------------------------- + + +class TestLtspiceToNgspice: + """Unit tests for the netlist dialect converter.""" + + def test_strips_backanno(self): + netlist = "V1 in 0 AC 1\n.backanno\n.end\n" + result, warnings = ltspice_to_ngspice(netlist) + assert ".backanno" not in result + assert ".end" in result + + def test_strips_plotwinsize(self): + netlist = "R1 a b 1k\n.options plotwinsize=0\n.end\n" + result, warnings = ltspice_to_ngspice(netlist) + assert "plotwinsize" not in result + + def test_comments_out_windows_lib(self): + netlist = '.lib C:\\LTspice\\lib\\cmp\\standard.bjt\n.end\n' + result, warnings = ltspice_to_ngspice(netlist) + assert result.startswith("* .lib") + assert len(warnings) == 1 + assert "Windows path" in warnings[0] + + def test_comments_out_windows_include(self): + netlist = '.include C:\\Users\\test\\model.sub\n.end\n' + result, warnings = ltspice_to_ngspice(netlist) + assert "* .include" in result + assert len(warnings) == 1 + + def test_strips_rser_from_capacitor(self): + netlist = "C1 out 0 100n Rser=0.1\n.end\n" + result, warnings = ltspice_to_ngspice(netlist) + assert "Rser" not in result + assert "C1 out 0 100n" in result + assert len(warnings) == 1 + assert "Rser" in warnings[0] + + def test_strips_rser_from_inductor(self): + netlist = "L1 a b 10u Rser=0.5\n.end\n" + result, warnings = ltspice_to_ngspice(netlist) + assert "Rser" not in result + assert "L1 a b 10u" in result + + def test_preserves_standard_spice(self): + netlist = ( + "* RC Lowpass\n" + "V1 in 0 AC 1\n" + "R1 in out 1k\n" + "C1 out 0 100n\n" + ".ac dec 100 1 1meg\n" + ".end\n" + ) + result, warnings = ltspice_to_ngspice(netlist) + assert result == netlist.rstrip("\n") + assert warnings == [] + + def test_multiple_conversions(self): + netlist = ( + "V1 in 0 AC 1\n" + "C1 out 0 100n Rser=0.1\n" + ".backanno\n" + ".lib C:\\LTspice\\lib\\standard.bjt\n" + ".options plotwinsize=0\n" + ".end\n" + ) + result, warnings = ltspice_to_ngspice(netlist) + assert ".backanno" not in result + assert "plotwinsize" not in result + assert "Rser" not in result + assert result.count("*") >= 1 # at least the .lib line commented + assert len(warnings) == 2 # Rser + Windows path (backanno/plotwinsize stripped silently) + + def test_empty_netlist(self): + result, warnings = ltspice_to_ngspice("") + assert result == "" + assert warnings == [] + + def test_case_insensitive_backanno(self): + netlist = ".BACKANNO\n.end\n" + result, warnings = ltspice_to_ngspice(netlist) + assert "BACKANNO" not in result + + +# --------------------------------------------------------------------------- +# build_notebook_cells tests +# --------------------------------------------------------------------------- + + +class TestBuildNotebookCells: + """Unit tests for the cell builder.""" + + def test_minimal_cells(self): + cells = build_notebook_cells( + title="Test", + description=None, + netlist="V1 in 0 AC 1\n.end", + ) + assert len(cells) == 2 + assert cells[0]["type"] == "markdown" + assert "# Test" in cells[0]["source"] + assert cells[1]["type"] == "spice" + assert "V1 in 0 AC 1" in cells[1]["source"] + + def test_with_description(self): + cells = build_notebook_cells( + title="RC Filter", + description="A simple RC lowpass filter.", + netlist=".end", + ) + assert "A simple RC lowpass filter." in cells[0]["source"] + + def test_with_warnings(self): + cells = build_notebook_cells( + title="Test", + description=None, + netlist=".end", + warnings=["Stripped Rser= from C1", "Commented out Windows path"], + ) + intro = cells[0]["source"] + assert "Conversion notes" in intro + assert "Stripped Rser" in intro + assert "Windows path" in intro + + def test_with_analysis(self): + cells = build_notebook_cells( + title="Test", + description=None, + netlist=".end", + analysis={"Bandwidth": "1.59 kHz", "Gain": "-20 dB"}, + ) + assert len(cells) == 3 + table_cell = cells[2] + assert table_cell["type"] == "markdown" + assert "Bandwidth" in table_cell["source"] + assert "1.59 kHz" in table_cell["source"] + + def test_with_svgs(self): + svg1 = '' + svg2 = '' + cells = build_notebook_cells( + title="Test", + description=None, + netlist=".end", + svgs=[svg1, svg2], + ) + # intro + spice + 2 svg cells + assert len(cells) == 4 + assert "Plot 1" in cells[2]["source"] + assert "Plot 2" in cells[3]["source"] + assert "" in cells[2]["source"] + + def test_single_svg_no_number(self): + svg = '' + cells = build_notebook_cells( + title="Test", + description=None, + netlist=".end", + svgs=[svg], + ) + assert len(cells) == 3 + assert "### Plot\n" in cells[2]["source"] + assert "Plot 1" not in cells[2]["source"] + + def test_full_build(self): + cells = build_notebook_cells( + title="RC Lowpass Filter", + description="First-order RC filter with fc = 1.59 kHz", + netlist="V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 100n\n.ac dec 100 1 1meg\n.end", + warnings=["Stripped Rser= from C1"], + analysis={"Bandwidth (-3dB)": "1.59 kHz"}, + svgs=["..."], + ) + # intro + spice + analysis + 1 svg = 4 cells + assert len(cells) == 4 + assert cells[0]["type"] == "markdown" + assert cells[1]["type"] == "spice" + assert cells[2]["type"] == "markdown" + assert cells[3]["type"] == "markdown" + + +# --------------------------------------------------------------------------- +# SpiceBookClient unit tests (no network) +# --------------------------------------------------------------------------- + + +class TestSpiceBookClient: + """Test client construction, defaults, and input validation.""" + + def test_default_url(self): + client = SpiceBookClient() + assert client.base_url == "https://spicebook.warehack.ing" + + def test_custom_url(self): + client = SpiceBookClient(base_url="http://localhost:8000/") + assert client.base_url == "http://localhost:8000" + + def test_custom_timeout(self): + client = SpiceBookClient(timeout=60) + assert client.timeout == 60 + + def test_validate_notebook_id_valid(self): + assert SpiceBookClient._validate_notebook_id("rc-lowpass-abc123") is None + assert SpiceBookClient._validate_notebook_id("test_notebook") is None + assert SpiceBookClient._validate_notebook_id("a") is None + + def test_validate_notebook_id_rejects_traversal(self): + err = SpiceBookClient._validate_notebook_id("../admin") + assert err is not None + assert "Invalid" in err + + def test_validate_notebook_id_rejects_slashes(self): + err = SpiceBookClient._validate_notebook_id("foo/../../bar") + assert err is not None + + def test_validate_notebook_id_rejects_empty(self): + err = SpiceBookClient._validate_notebook_id("") + assert err is not None + + async def test_get_notebook_rejects_bad_id(self): + client = SpiceBookClient() + with pytest.raises(ValueError, match="Invalid notebook_id"): + await client.get_notebook("../admin") + + async def test_delete_notebook_rejects_bad_id(self): + client = SpiceBookClient() + with pytest.raises(ValueError, match="Invalid notebook_id"): + await client.delete_notebook("foo/bar") + + +# --------------------------------------------------------------------------- +# _read_netlist validation tests (server-level helper) +# --------------------------------------------------------------------------- + + +class TestReadNetlist: + """Tests for the _read_netlist path validation.""" + + def test_rejects_both_text_and_path(self): + _, err = _read_netlist("some text", "/some/path.cir") + assert err is not None + assert "not both" in err["error"] + + def test_rejects_neither(self): + _, err = _read_netlist(None, None) + assert err is not None + + def test_accepts_text(self): + text, err = _read_netlist("V1 in 0 AC 1\n.end", None) + assert err is None + assert "V1" in text + + def test_rejects_bad_extension(self): + _, err = _read_netlist(None, "/etc/passwd") + assert err is not None + assert "Unsupported file type" in err["error"] + + def test_rejects_shadow_file(self): + _, err = _read_netlist(None, "/etc/shadow") + assert err is not None + assert "Unsupported file type" in err["error"] + + def test_rejects_nonexistent_file(self, tmp_path): + path = str(tmp_path / "missing.cir") + _, err = _read_netlist(None, path) + assert err is not None + assert "not found" in err["error"] + + def test_reads_valid_cir_file(self, tmp_path): + cir = tmp_path / "test.cir" + cir.write_text("V1 in 0 AC 1\n.end\n") + text, err = _read_netlist(None, str(cir)) + assert err is None + assert "V1" in text + + def test_reads_valid_net_file(self, tmp_path): + net = tmp_path / "test.net" + net.write_text("R1 a b 1k\n.end\n") + text, err = _read_netlist(None, str(net)) + assert err is None + assert "R1" in text + + def test_rejects_traversal_with_bad_extension(self): + _, err = _read_netlist(None, "/tmp/../../etc/passwd") + assert err is not None + assert "Unsupported file type" in err["error"] + + +# --------------------------------------------------------------------------- +# Integration tests (require live SpiceBook) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestSpiceBookIntegration: + """Live API tests against spicebook.warehack.ing. + + Run with: uv run pytest tests/test_spicebook.py -m integration + """ + + @pytest.fixture + def client(self): + return SpiceBookClient() + + @pytest.fixture + def rc_netlist(self): + return ( + "* RC Lowpass Filter\n" + "V1 in 0 AC 1\n" + "R1 in out 1k\n" + "C1 out 0 100n\n" + ".ac dec 100 1 1meg\n" + ".end\n" + ) + + async def test_health(self, client): + assert await client.health() is True + + async def test_compose_and_get_and_delete(self, client, rc_netlist): + cells = build_notebook_cells( + title="Integration Test - RC Lowpass", + description="Auto-generated by mcltspice test suite", + netlist=rc_netlist, + ) + result = await client.compose( + title="Integration Test - RC Lowpass", + cells=cells, + tags=["test", "mcltspice", "auto"], + ) + notebook_id = result.get("id") + if not notebook_id: + pytest.fail(f"compose returned no 'id': {result}") + + try: + # Verify we can retrieve it (title is under metadata) + notebook = await client.get_notebook(notebook_id) + assert notebook["metadata"]["title"] == "Integration Test - RC Lowpass" + assert len(notebook["cells"]) >= 2 + finally: + await client.delete_notebook(notebook_id) + + async def test_list_notebooks(self, client): + notebooks = await client.list_notebooks() + assert isinstance(notebooks, list) + + async def test_simulate(self, client, rc_netlist): + result = await client.simulate(rc_netlist) + assert isinstance(result, dict) diff --git a/uv.lock b/uv.lock index 3346ea6..8f57302 100644 --- a/uv.lock +++ b/uv.lock @@ -1028,7 +1028,7 @@ wheels = [ [[package]] name = "mcltspice" -version = "2026.2.14" +version = "2026.2.14.1" source = { editable = "." } dependencies = [ { name = "fastmcp" },