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" },