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.
This commit is contained in:
Ryan Malloy 2026-02-15 18:05:49 -07:00
parent 4c75c76284
commit 71dfdc8d94
5 changed files with 882 additions and 1 deletions

View File

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

View File

@ -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
# ============================================================================

231
src/mcltspice/spicebook.py Normal file
View File

@ -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()

361
tests/test_spicebook.py Normal file
View File

@ -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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
svg2 = '<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>'
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 "<rect/>" in cells[2]["source"]
def test_single_svg_no_number(self):
svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></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=["<svg>...</svg>"],
)
# 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)

2
uv.lock generated
View File

@ -1028,7 +1028,7 @@ wheels = [
[[package]]
name = "mcltspice"
version = "2026.2.14"
version = "2026.2.14.1"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },