Initial SpiceBook MVP: notebook interface for circuit simulation

Phase 1 implementation with ngspice backend and Astro/React frontend:

Backend (FastAPI):
- ngspice subprocess engine with custom .raw file parser
- Notebook CRUD with .spicebook JSON format (filesystem storage)
- Simulation endpoints (standalone + cell-in-notebook)
- SVG waveform export endpoint
- 18 REST API routes, 5 passing tests

Frontend (Astro 5 + React 19):
- Notebook editor as React island with Zustand state management
- CodeMirror 6 with custom SPICE language mode (syntax highlighting
  for dot commands, components, engineering notation, comments)
- uPlot waveform viewer with transient and AC/Bode plot modes
- Markdown cells with edit/preview toggle
- Notebook list with card grid UI
- Dark theme, Tailwind CSS 4, Lucide icons

Infrastructure:
- Docker Compose with dev/prod targets
- Caddy-based frontend prod serving
- 3 example notebooks (RC filter, voltage divider, CE amplifier)
This commit is contained in:
Ryan Malloy 2026-02-13 01:44:38 -07:00
commit 8abd7719bf
66 changed files with 14578 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.venv/
*.egg
# Node
node_modules/
.astro/
dist/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yml
# Simulation artifacts
*.raw
*.log
*.op.raw
*.net
# Notebook user data (tracked separately)
notebooks/user/
# Coverage
htmlcov/
.coverage

48
Makefile Normal file
View File

@ -0,0 +1,48 @@
.PHONY: dev prod down logs restart clean build
# Default target
dev: ## Start development environment
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
@echo "SpiceBook dev started"
@echo " Frontend: http://localhost:4321"
@echo " Backend: http://localhost:$${BACKEND_PORT:-8099}"
@echo " API docs: http://localhost:$${BACKEND_PORT:-8099}/docs"
@sleep 3
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs --tail=30
prod: ## Start production environment
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
@echo "SpiceBook production started"
@sleep 3
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs --tail=30
down: ## Stop all containers
docker compose down
logs: ## Show container logs
docker compose logs -f --tail=50
restart: ## Restart all containers
docker compose restart
@sleep 3
docker compose logs --tail=30
clean: ## Remove containers, volumes, and build artifacts
docker compose down -v --remove-orphans
rm -rf frontend/node_modules frontend/dist frontend/.astro
rm -rf backend/dist backend/build
build: ## Build containers without starting
docker compose -f docker-compose.yml -f docker-compose.dev.yml build
backend-logs: ## Show backend logs only
docker compose logs -f backend
frontend-logs: ## Show frontend logs only
docker compose logs -f frontend
backend-shell: ## Open shell in backend container
docker compose exec backend bash
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

54
backend/Dockerfile Normal file
View File

@ -0,0 +1,54 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
# System dependencies -- ngspice is required for simulation
RUN apt-get update && \
apt-get install -y --no-install-recommends ngspice && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy dependency metadata first for layer caching
COPY pyproject.toml ./
# ---------------------------------------------------------------------------
# Development target
# ---------------------------------------------------------------------------
FROM base AS dev
# Install in editable mode (source mounted via docker-compose volume)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv pip install --system -e ".[dev]"
# Copy source for initial build (overridden by volume mount in dev)
COPY src/ ./src/
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# ---------------------------------------------------------------------------
# Production target
# ---------------------------------------------------------------------------
FROM base AS prod
ENV UV_COMPILE_BYTECODE=1
# Install dependencies first (no source yet -- better caching)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv pip install --system --no-editable .
COPY src/ ./src/
# Re-install with source to get the package registered
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-editable .
# Run as non-root
RUN useradd --create-home --shell /bin/bash spicebook
USER spicebook
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000"]

44
backend/pyproject.toml Normal file
View File

@ -0,0 +1,44 @@
[project]
name = "spicebook"
version = "2026.02.13"
description = "Notebook interface for SPICE circuit simulation"
readme = "README.md"
requires-python = ">=3.12"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"pydantic>=2.0",
"numpy>=1.24.0",
"websockets>=12.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"ruff>=0.8",
]
[project.scripts]
spicebook = "spicebook.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/spicebook"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@ -0,0 +1,3 @@
"""SpiceBook - Notebook interface for SPICE circuit simulation."""
__version__ = "2026.02.13"

View File

@ -0,0 +1,29 @@
"""Application settings loaded from environment variables."""
import os
from pathlib import Path
class Settings:
"""Runtime configuration for SpiceBook backend."""
def __init__(self) -> None:
self.notebook_dir: Path = Path(os.environ.get("NOTEBOOK_DIR", "./notebooks"))
self.ngspice_path: Path = Path(os.environ.get("NGSPICE_PATH", "/usr/bin/ngspice"))
self.ltspice_dir: Path | None = (
Path(d) if (d := os.environ.get("LTSPICE_DIR")) else None
)
self.backend_host: str = os.environ.get("BACKEND_HOST", "0.0.0.0")
self.backend_port: int = int(os.environ.get("BACKEND_PORT", "8000"))
@property
def examples_dir(self) -> Path:
return self.notebook_dir / "examples"
@property
def user_dir(self) -> Path:
return self.notebook_dir / "user"
# Singleton used across the application
settings = Settings()

View File

View File

@ -0,0 +1,22 @@
"""Abstract interface for SPICE simulation engines."""
from abc import ABC, abstractmethod
from pathlib import Path
from spicebook.models.simulation import SimulationResponse
class SpiceEngine(ABC):
"""Base class for SPICE simulation backends (ngspice, LTspice, etc.)."""
@abstractmethod
async def run(self, netlist: str, work_dir: Path) -> SimulationResponse:
"""Run a SPICE simulation and return results.
Args:
netlist: Complete SPICE netlist text.
work_dir: Temporary directory for input/output files.
Returns:
SimulationResponse with waveform data or error details.
"""

View File

@ -0,0 +1,381 @@
"""ngspice simulation engine -- subprocess-based."""
import asyncio
import logging
import struct
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from spicebook.config import settings
from spicebook.engine.base import SpiceEngine
from spicebook.models.simulation import (
SimulationResponse,
WaveformData,
WaveformVariable,
)
logger = logging.getLogger(__name__)
SIMULATION_TIMEOUT_SECONDS = 60
# ---------------------------------------------------------------------------
# ngspice .raw file parser
# ---------------------------------------------------------------------------
@dataclass
class RawVariable:
index: int
name: str
type: str # "voltage", "current", "time", "frequency"
@dataclass
class RawFile:
title: str
date: str
plotname: str
flags: list[str]
variables: list[RawVariable]
points: int
data: np.ndarray # shape (n_vars, n_points)
def parse_ngspice_raw(path: Path) -> RawFile:
"""Parse an ngspice binary .raw file.
ngspice raw files use:
- ASCII (not UTF-16) header
- All float64 for real data (no mixed precision)
- Complex128 (float64 pairs) for AC analysis
"""
with open(path, "rb") as f:
content = f.read()
# Locate data section marker
binary_marker = b"Binary:\n"
values_marker = b"Values:\n"
binary_offset = content.find(binary_marker)
values_offset = content.find(values_marker)
if binary_offset != -1:
header_end = binary_offset + len(binary_marker)
is_binary = True
elif values_offset != -1:
header_end = values_offset + len(values_marker)
is_binary = False
else:
raise ValueError("No data section marker found in .raw file")
# Parse header
header = content[:header_end].decode("ascii", errors="replace")
header_lines = header.strip().split("\n")
title = ""
date = ""
plotname = ""
flags: list[str] = []
variables: list[RawVariable] = []
points = 0
in_variables = False
for line in header_lines:
line = line.strip()
if line.startswith("Title:"):
title = line[6:].strip()
elif line.startswith("Date:"):
date = line[5:].strip()
elif line.startswith("Plotname:"):
plotname = line[9:].strip()
elif line.startswith("Flags:"):
flags = line[6:].strip().split()
elif line.startswith("No. Variables:"):
pass # Count derived from Variables section
elif line.startswith("No. Points:"):
points = int(line[11:].strip())
elif line.startswith("Variables:"):
in_variables = True
elif in_variables and line and not line.startswith(("Binary", "Values")):
parts = line.split()
if len(parts) >= 3:
idx = int(parts[0])
name = parts[1]
vtype = parts[2]
variables.append(RawVariable(idx, name, vtype))
if not variables:
raise ValueError("No variables found in .raw file header")
n_vars = len(variables)
is_complex = "complex" in flags
data_bytes = content[header_end:]
if is_binary:
data = _parse_binary(data_bytes, n_vars, points, is_complex)
else:
decoded = data_bytes.decode("ascii", errors="replace")
data = _parse_ascii(decoded, n_vars, points, is_complex)
actual_points = data.shape[1]
return RawFile(
title=title,
date=date,
plotname=plotname,
flags=flags,
variables=variables,
points=actual_points,
data=data,
)
def _parse_binary(data: bytes, n_vars: int, points: int, is_complex: bool) -> np.ndarray:
"""Parse binary data section from ngspice raw file.
ngspice always uses float64 for all variables (no mixed precision).
Complex values are stored as consecutive (real, imag) float64 pairs.
"""
if is_complex:
# Each point: n_vars * 2 * 8 bytes (real + imag per variable)
bytes_per_point = n_vars * 16
actual_points = len(data) // bytes_per_point
flat = np.frombuffer(data[: actual_points * bytes_per_point], dtype=np.complex128)
return flat.reshape(actual_points, n_vars).T.copy()
# Real data: all float64
bytes_per_point = n_vars * 8
actual_points = len(data) // bytes_per_point
if actual_points == 0:
# Try mixed precision as fallback (some ngspice builds use it)
mixed_bytes = 8 + (n_vars - 1) * 4
actual_points = len(data) // mixed_bytes
if actual_points > 0:
return _parse_binary_mixed(data, n_vars, actual_points)
raise ValueError(f"Cannot parse binary data: {len(data)} bytes for {n_vars} variables")
flat = np.frombuffer(data[: actual_points * bytes_per_point], dtype=np.float64)
return flat.reshape(actual_points, n_vars).T.copy()
def _parse_binary_mixed(data: bytes, n_vars: int, points: int) -> np.ndarray:
"""Fallback: mixed precision (float64 sweep var + float32 others)."""
result = np.zeros((n_vars, points), dtype=np.float64)
offset = 0
for p in range(points):
result[0, p] = struct.unpack("<d", data[offset : offset + 8])[0]
offset += 8
for v in range(1, n_vars):
result[v, p] = struct.unpack("<f", data[offset : offset + 4])[0]
offset += 4
return result
def _parse_ascii(text: str, n_vars: int, points: int, is_complex: bool) -> np.ndarray:
"""Parse ASCII data section from ngspice raw file."""
lines = text.strip().split("\n")
if is_complex:
result = np.zeros((n_vars, points), dtype=np.complex128)
else:
result = np.zeros((n_vars, points), dtype=np.float64)
point_idx = 0
var_idx = 0
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 2:
try:
idx = int(parts[0])
if idx == 0 and var_idx > 0:
point_idx += 1
if point_idx >= points:
break
var_idx = idx
value_str = parts[1]
if "," in value_str:
real_s, imag_s = value_str.split(",")
result[var_idx, point_idx] = complex(float(real_s), float(imag_s))
else:
result[var_idx, point_idx] = float(value_str)
except (ValueError, IndexError):
continue
return result
# ---------------------------------------------------------------------------
# NgspiceEngine
# ---------------------------------------------------------------------------
class NgspiceEngine(SpiceEngine):
"""Run simulations via ngspice in batch mode."""
def __init__(self, ngspice_path: Path | None = None) -> None:
self.ngspice_path = ngspice_path or settings.ngspice_path
async def run(self, netlist: str, work_dir: Path) -> SimulationResponse:
t0 = time.monotonic()
# Ensure netlist ends with .end
netlist_stripped = netlist.rstrip()
if not netlist_stripped.lower().endswith(".end"):
netlist_stripped += "\n.end\n"
else:
netlist_stripped += "\n"
cir_path = work_dir / "input.cir"
raw_path = work_dir / "output.raw"
cir_path.write_text(netlist_stripped)
cmd = [
str(self.ngspice_path),
"-b", # batch mode
"-r", str(raw_path), # raw output
str(cir_path),
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(work_dir),
)
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(),
timeout=SIMULATION_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
proc.kill()
return SimulationResponse(
success=False,
error=f"Simulation timed out after {SIMULATION_TIMEOUT_SECONDS}s",
elapsed_seconds=time.monotonic() - t0,
)
except FileNotFoundError:
return SimulationResponse(
success=False,
error=f"ngspice not found at {self.ngspice_path}",
elapsed_seconds=time.monotonic() - t0,
)
log_text = (stdout_bytes.decode("utf-8", errors="replace")
+ stderr_bytes.decode("utf-8", errors="replace"))
# Check for fatal errors in the log (ngspice can exit 0 even with errors)
if proc.returncode != 0 or not raw_path.exists():
error_lines = _extract_error_lines(log_text)
return SimulationResponse(
success=False,
log=log_text,
error=error_lines or f"ngspice exited with code {proc.returncode}",
elapsed_seconds=time.monotonic() - t0,
)
# Parse the raw file
try:
raw = parse_ngspice_raw(raw_path)
except Exception as exc:
return SimulationResponse(
success=False,
log=log_text,
error=f"Failed to parse simulation output: {exc}",
elapsed_seconds=time.monotonic() - t0,
)
waveform = _raw_to_waveform(raw)
return SimulationResponse(
success=True,
waveform=waveform,
log=log_text,
elapsed_seconds=time.monotonic() - t0,
)
def _extract_error_lines(log: str) -> str:
"""Pull error/warning lines from ngspice output."""
error_lines = []
for line in log.splitlines():
lower = line.lower()
if "error" in lower or "fatal" in lower or "cannot" in lower:
error_lines.append(line.strip())
return "\n".join(error_lines) if error_lines else ""
def _raw_to_waveform(raw: RawFile) -> WaveformData:
"""Convert parsed RawFile into a WaveformData response model."""
is_complex = "complex" in raw.flags
# Determine x-axis variable (first variable is always the sweep)
x_var = raw.variables[0]
x_type = "frequency" if "frequency" in x_var.type.lower() else "time"
# Build variable list (skip x-axis variable)
variables = []
for v in raw.variables[1:]:
variables.append(WaveformVariable(name=v.name, type=v.type))
# Convert x data
x_array = raw.data[0]
if is_complex:
x_data = np.real(x_array).tolist()
else:
x_data = x_array.tolist()
# Convert y data
y_data: dict[str, list[float]] = {}
y_magnitude_db: dict[str, list[float]] | None = None
y_phase_deg: dict[str, list[float]] | None = None
if is_complex:
y_magnitude_db = {}
y_phase_deg = {}
for v in raw.variables[1:]:
arr = raw.data[v.index]
# Real part as the default y_data
y_data[v.name] = np.real(arr).tolist()
# Magnitude in dB
magnitude = np.abs(arr)
# Avoid log10(0) -- clamp to a very small value
magnitude = np.where(magnitude > 0, magnitude, 1e-30)
mag_db = 20.0 * np.log10(magnitude)
y_magnitude_db[v.name] = mag_db.tolist()
# Phase in degrees
phase = np.degrees(np.angle(arr))
y_phase_deg[v.name] = phase.tolist()
else:
for v in raw.variables[1:]:
arr = raw.data[v.index]
y_data[v.name] = arr.tolist()
return WaveformData(
variables=variables,
points=raw.points,
x_data=x_data,
y_data=y_data,
x_type=x_type,
is_complex=is_complex,
y_magnitude_db=y_magnitude_db,
y_phase_deg=y_phase_deg,
)

View File

@ -0,0 +1,97 @@
"""FastAPI application entry point for SpiceBook."""
import logging
import shutil
import sys
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from spicebook.config import settings
from spicebook.routers import notebooks, simulation, waveforms
logger = logging.getLogger("spicebook")
def create_app() -> FastAPI:
application = FastAPI(
title="SpiceBook",
description="Notebook interface for SPICE circuit simulation",
version="2026.02.13",
)
# CORS -- allow dev frontends
application.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:4321",
"http://localhost:3000",
"http://127.0.0.1:4321",
"http://127.0.0.1:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
application.include_router(notebooks.router)
application.include_router(simulation.router)
application.include_router(waveforms.router)
@application.get("/health")
async def health():
return {"status": "ok", "version": "2026.02.13"}
@application.on_event("startup")
async def startup():
_configure_logging()
_validate_ngspice()
_ensure_directories()
return application
def _configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
stream=sys.stderr,
)
def _validate_ngspice() -> None:
"""Check that ngspice is installed and reachable."""
ngspice = shutil.which(str(settings.ngspice_path))
if ngspice is None:
ngspice = shutil.which("ngspice")
if ngspice:
logger.info("ngspice found at %s", ngspice)
else:
logger.warning(
"ngspice not found at '%s' and not on PATH. "
"Simulations will fail until ngspice is installed.",
settings.ngspice_path,
)
def _ensure_directories() -> None:
"""Create notebook directories if they don't exist."""
settings.notebook_dir.mkdir(parents=True, exist_ok=True)
settings.user_dir.mkdir(parents=True, exist_ok=True)
settings.examples_dir.mkdir(parents=True, exist_ok=True)
logger.info("Notebook directory: %s", settings.notebook_dir.resolve())
app = create_app()
def main() -> None:
"""Entry point for `spicebook` CLI command."""
uvicorn.run(
"spicebook.main:app",
host=settings.backend_host,
port=settings.backend_port,
reload=False,
)

View File

View File

@ -0,0 +1,69 @@
"""Pydantic models for the .spicebook notebook format."""
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class CellType(str, Enum):
MARKDOWN = "markdown"
SPICE = "spice"
PYTHON = "python"
SCHEMATIC = "schematic"
class CellOutput(BaseModel):
output_type: str # "simulation_result", "text", "error", "waveform_data"
data: dict[str, Any]
timestamp: str | None = None
class Cell(BaseModel):
id: str
type: CellType
source: str = ""
outputs: list[CellOutput] = Field(default_factory=list)
class NotebookMetadata(BaseModel):
title: str = "Untitled Notebook"
engine: str = "ngspice"
tags: list[str] = Field(default_factory=list)
created: str # ISO datetime
modified: str # ISO datetime
class Notebook(BaseModel):
spicebook_version: str = "2026-02-13"
metadata: NotebookMetadata
cells: list[Cell] = Field(default_factory=list)
class NotebookSummary(BaseModel):
id: str
title: str
engine: str
tags: list[str]
cell_count: int
modified: str
class CreateNotebookRequest(BaseModel):
title: str = "Untitled Notebook"
engine: str = "ngspice"
class AddCellRequest(BaseModel):
type: CellType
source: str = ""
after_cell_id: str | None = None # Insert after this cell; None = append
class UpdateCellRequest(BaseModel):
source: str | None = None
type: CellType | None = None
class ReorderCellsRequest(BaseModel):
cell_ids: list[str] # Ordered list of all cell IDs

View File

@ -0,0 +1,45 @@
"""Pydantic models for simulation requests and responses."""
from pydantic import BaseModel
class SimulationRequest(BaseModel):
netlist: str # SPICE netlist text
engine: str = "ngspice" # "ngspice" or "ltspice"
class WaveformVariable(BaseModel):
name: str
type: str # "voltage", "current", "time", "frequency"
class WaveformData(BaseModel):
variables: list[WaveformVariable]
points: int
x_data: list[float] # Time or frequency axis
y_data: dict[str, list[float]] # signal_name -> values
x_type: str # "time" or "frequency"
is_complex: bool = False
# For complex (AC) data, also include magnitude/phase
y_magnitude_db: dict[str, list[float]] | None = None
y_phase_deg: dict[str, list[float]] | None = None
class SimulationResponse(BaseModel):
success: bool
waveform: WaveformData | None = None
log: str = ""
error: str | None = None
elapsed_seconds: float = 0.0
class SvgPlotRequest(BaseModel):
waveform: WaveformData
title: str = "Waveform Plot"
width: int = 800
height: int = 500
signals: list[str] | None = None # Which signals to plot; None = all
class SvgPlotResponse(BaseModel):
svg: str

View File

@ -0,0 +1,152 @@
"""REST endpoints for notebook CRUD operations."""
import uuid
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.models.notebook import (
AddCellRequest,
Cell,
CreateNotebookRequest,
Notebook,
NotebookSummary,
ReorderCellsRequest,
UpdateCellRequest,
)
from spicebook.storage.filesystem import (
create_notebook,
delete_notebook,
list_notebooks,
load_notebook,
save_notebook,
)
router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
def _get_or_404(notebook_id: str) -> Notebook:
nb = load_notebook(settings.notebook_dir, notebook_id)
if nb is None:
raise HTTPException(status_code=404, detail=f"Notebook '{notebook_id}' not found")
return nb
# ---- Notebook CRUD ---------------------------------------------------------
@router.get("", response_model=list[NotebookSummary])
async def get_notebooks():
"""List all available notebooks."""
return list_notebooks(settings.notebook_dir)
@router.post("", status_code=201)
async def post_notebook(req: CreateNotebookRequest):
"""Create a new notebook. Returns {id, notebook}."""
nb_id, nb = create_notebook(settings.notebook_dir, req.title, req.engine)
return {"id": nb_id, **nb.model_dump()}
@router.get("/{notebook_id}", response_model=Notebook)
async def get_notebook(notebook_id: str):
"""Get a single notebook by ID."""
return _get_or_404(notebook_id)
@router.put("/{notebook_id}", response_model=Notebook)
async def put_notebook(notebook_id: str, notebook: Notebook):
"""Replace an entire notebook."""
# Verify it exists first (or allow upsert into user dir)
save_notebook(settings.notebook_dir, notebook_id, notebook)
return notebook
@router.delete("/{notebook_id}", status_code=204)
async def del_notebook(notebook_id: str):
"""Delete a notebook (user notebooks only)."""
deleted = delete_notebook(settings.notebook_dir, notebook_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Notebook '{notebook_id}' not found")
# ---- Cell operations -------------------------------------------------------
@router.post("/{notebook_id}/cells", response_model=Cell, status_code=201)
async def add_cell(notebook_id: str, req: AddCellRequest):
"""Add a new cell to a notebook."""
nb = _get_or_404(notebook_id)
cell = Cell(
id=f"cell-{uuid.uuid4().hex[:12]}",
type=req.type,
source=req.source,
)
if req.after_cell_id is not None:
# Insert after the specified cell
idx = _find_cell_index(nb, req.after_cell_id)
nb.cells.insert(idx + 1, cell)
else:
nb.cells.append(cell)
save_notebook(settings.notebook_dir, notebook_id, nb)
return cell
@router.put("/{notebook_id}/cells/{cell_id}", response_model=Cell)
async def update_cell(notebook_id: str, cell_id: str, req: UpdateCellRequest):
"""Update a cell's source or type."""
nb = _get_or_404(notebook_id)
idx = _find_cell_index(nb, cell_id)
cell = nb.cells[idx]
if req.source is not None:
cell.source = req.source
if req.type is not None:
cell.type = req.type
save_notebook(settings.notebook_dir, notebook_id, nb)
return cell
@router.delete("/{notebook_id}/cells/{cell_id}", status_code=204)
async def del_cell(notebook_id: str, cell_id: str):
"""Delete a cell from a notebook."""
nb = _get_or_404(notebook_id)
idx = _find_cell_index(nb, cell_id)
nb.cells.pop(idx)
save_notebook(settings.notebook_dir, notebook_id, nb)
@router.put("/{notebook_id}/cells/reorder", response_model=list[Cell])
async def reorder_cells(notebook_id: str, req: ReorderCellsRequest):
"""Reorder cells by providing the full ordered list of cell IDs."""
nb = _get_or_404(notebook_id)
existing_ids = {c.id for c in nb.cells}
requested_ids = set(req.cell_ids)
if existing_ids != requested_ids:
missing = existing_ids - requested_ids
extra = requested_ids - existing_ids
parts = []
if missing:
parts.append(f"missing: {missing}")
if extra:
parts.append(f"unknown: {extra}")
raise HTTPException(status_code=400, detail=f"Cell ID mismatch: {', '.join(parts)}")
cell_map = {c.id: c for c in nb.cells}
nb.cells = [cell_map[cid] for cid in req.cell_ids]
save_notebook(settings.notebook_dir, notebook_id, nb)
return nb.cells
def _find_cell_index(nb: Notebook, cell_id: str) -> int:
for i, c in enumerate(nb.cells):
if c.id == cell_id:
return i
raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found")

View File

@ -0,0 +1,86 @@
"""Simulation execution endpoints."""
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine.ngspice import NgspiceEngine
from spicebook.models.notebook import CellOutput, CellType
from spicebook.models.simulation import SimulationRequest, SimulationResponse
from spicebook.storage.filesystem import load_notebook, save_notebook
router = APIRouter(prefix="/api", tags=["simulation"])
def _get_engine(engine_name: str) -> NgspiceEngine:
"""Resolve engine by name. Only ngspice is supported in Phase 1."""
if engine_name == "ngspice":
return NgspiceEngine()
raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
@router.post("/simulate", response_model=SimulationResponse)
async def simulate(req: SimulationRequest):
"""Run a standalone SPICE simulation."""
engine = _get_engine(req.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(req.netlist, Path(tmpdir))
return result
@router.post(
"/notebooks/{notebook_id}/cells/{cell_id}/run",
response_model=SimulationResponse,
)
async def run_cell(notebook_id: str, cell_id: str):
"""Run a SPICE cell within a notebook and save the output."""
nb = load_notebook(settings.notebook_dir, notebook_id)
if nb is None:
raise HTTPException(status_code=404, detail=f"Notebook '{notebook_id}' not found")
cell = None
for c in nb.cells:
if c.id == cell_id:
cell = c
break
if cell is None:
raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found")
if cell.type != CellType.SPICE:
raise HTTPException(
status_code=400,
detail=f"Cell is type '{cell.type.value}', not 'spice'",
)
if not cell.source.strip():
raise HTTPException(status_code=400, detail="Cell source is empty")
engine = _get_engine(nb.metadata.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))
# Save output to the cell in same format the frontend expects
now_iso = datetime.now(timezone.utc).isoformat()
cell.outputs = [CellOutput(
output_type="simulation_result" if result.success else "error",
data={
"success": result.success,
"waveform": result.waveform.model_dump() if result.waveform else None,
"log": result.log,
"error": result.error,
"elapsed_seconds": result.elapsed_seconds,
},
timestamp=now_iso,
)]
save_notebook(settings.notebook_dir, notebook_id, nb)
return result

View File

@ -0,0 +1,337 @@
"""Waveform visualization endpoints."""
import logging
import sys
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import Response
from spicebook.models.simulation import SvgPlotRequest, SvgPlotResponse
router = APIRouter(prefix="/api/waveforms", tags=["waveforms"])
logger = logging.getLogger(__name__)
# Try importing mcltspice's svg_plot for high-quality rendering.
# The dev docker-compose mounts it at /app/mcltspice-lib.
_svg_plot = None
try:
# Attempt import from installed package first
from mcltspice import svg_plot as _svg_plot
except ImportError:
# Try the docker volume mount path
_mcltspice_lib = Path("/app/mcltspice-lib")
if _mcltspice_lib.is_dir():
sys.path.insert(0, str(_mcltspice_lib.parent))
try:
from mcltspice import svg_plot as _svg_plot
except ImportError:
pass
if _svg_plot is None:
logger.info("mcltspice.svg_plot not available; using built-in SVG generation")
@router.post("/svg", response_model=SvgPlotResponse)
async def generate_svg(req: SvgPlotRequest):
"""Generate an SVG plot from waveform data."""
wf = req.waveform
signals = req.signals or list(wf.y_data.keys())
# Filter to requested signals
signals = [s for s in signals if s in wf.y_data]
if not signals:
return SvgPlotResponse(svg=_empty_svg(req.title, req.width, req.height))
if wf.is_complex and wf.y_magnitude_db and wf.y_phase_deg:
svg = _render_bode(wf, signals, req.title, req.width, req.height)
else:
svg = _render_timeseries(wf, signals, req.title, req.width, req.height)
return SvgPlotResponse(svg=svg)
@router.post("/svg/raw", responses={200: {"content": {"image/svg+xml": {}}}})
async def generate_svg_raw(req: SvgPlotRequest):
"""Generate SVG and return it directly as image/svg+xml."""
resp = await generate_svg(req)
return Response(content=resp.svg, media_type="image/svg+xml")
# ---------------------------------------------------------------------------
# Rendering helpers
# ---------------------------------------------------------------------------
def _render_bode(wf, signals: list[str], title: str, width: int, height: int) -> str:
"""Render a Bode plot (magnitude + phase) for AC analysis data."""
first_signal = signals[0]
if _svg_plot is not None:
return _svg_plot.plot_bode(
freq=wf.x_data,
mag_db=wf.y_magnitude_db[first_signal],
phase_deg=wf.y_phase_deg[first_signal],
title=f"{title} - {first_signal}",
width=width,
height=height,
)
return _builtin_bode(wf, first_signal, title, width, height)
def _render_timeseries(wf, signals: list[str], title: str, width: int, height: int) -> str:
"""Render a time-domain plot for transient analysis data."""
first_signal = signals[0]
if _svg_plot is not None:
return _svg_plot.plot_timeseries(
time=wf.x_data,
values=wf.y_data[first_signal],
title=f"{title} - {first_signal}",
width=width,
height=height,
)
return _builtin_timeseries(wf, first_signal, title, width, height)
# ---------------------------------------------------------------------------
# Built-in SVG generation (no external dependencies)
# ---------------------------------------------------------------------------
_FONT = "system-ui, -apple-system, sans-serif"
_COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
def _empty_svg(title: str, width: int, height: int) -> str:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"'
f' viewBox="0 0 {width} {height}">'
f'<rect width="{width}" height="{height}" fill="white"/>'
f'<text x="{width // 2}" y="{height // 2}" text-anchor="middle"'
f' font-family="{_FONT}" font-size="14" fill="#888">'
f'No data to plot</text></svg>'
)
def _map_to_pixel(
val: float,
v_min: float,
v_max: float,
px_min: float,
px_max: float,
invert: bool = False,
) -> float:
denom = v_max - v_min if v_max != v_min else 1.0
frac = (val - v_min) / denom
if invert:
frac = 1.0 - frac
return px_min + frac * (px_max - px_min)
def _polyline_points(
x_data: list[float],
y_data: list[float],
x_min: float,
x_max: float,
y_min: float,
y_max: float,
plot_x: float,
plot_y: float,
plot_w: float,
plot_h: float,
) -> str:
"""Build SVG polyline points attribute from data arrays."""
parts = []
for x, y in zip(x_data, y_data):
px = _map_to_pixel(x, x_min, x_max, plot_x, plot_x + plot_w)
py = _map_to_pixel(y, y_min, y_max, plot_y, plot_y + plot_h, invert=True)
parts.append(f"{px:.2f},{py:.2f}")
return " ".join(parts)
def _format_eng(value: float) -> str:
"""Simple engineering notation formatter."""
if value == 0:
return "0"
abs_val = abs(value)
prefixes = [
(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k"),
(1e0, ""), (1e-3, "m"), (1e-6, "u"), (1e-9, "n"), (1e-12, "p"),
]
for threshold, prefix in prefixes:
if abs_val >= threshold * 0.9999:
return f"{value / threshold:.3g}{prefix}"
return f"{value:.2e}"
def _axis_ticks(v_min: float, v_max: float, n: int = 5) -> list[float]:
"""Generate ~n evenly spaced tick values."""
if v_min == v_max:
return [v_min]
step = (v_max - v_min) / n
return [v_min + step * i for i in range(n + 1)]
def _builtin_timeseries(wf, signal: str, title: str, width: int, height: int) -> str:
"""Generate a simple time-domain SVG plot."""
ml, mr, mt, mb = 80, 20, 40, 60
pw, ph = width - ml - mr, height - mt - mb
x = wf.x_data
y = wf.y_data[signal]
x_min, x_max = min(x), max(x)
y_min, y_max = min(y), max(y)
# Add padding to y range
y_span = y_max - y_min if y_max != y_min else 1.0
y_min -= y_span * 0.05
y_max += y_span * 0.05
pts = _polyline_points(x, y, x_min, x_max, y_min, y_max, ml, mt, pw, ph)
lines = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"'
f' viewBox="0 0 {width} {height}">',
f'<rect width="{width}" height="{height}" fill="white"/>',
# Plot background
f'<rect x="{ml}" y="{mt}" width="{pw}" height="{ph}" fill="white" stroke="#ccc"/>',
]
# Y-axis ticks
for tv in _axis_ticks(y_min, y_max, 5):
py = _map_to_pixel(tv, y_min, y_max, mt, mt + ph, invert=True)
lines.append(
f'<line x1="{ml}" y1="{py:.1f}" x2="{ml + pw}" y2="{py:.1f}"'
f' stroke="#eee" stroke-width="0.5"/>'
)
lines.append(
f'<text x="{ml - 8}" y="{py:.1f}" text-anchor="end"'
f' dominant-baseline="middle" font-size="11"'
f' font-family="{_FONT}" fill="#444">{_format_eng(tv)}</text>'
)
# X-axis ticks
for tv in _axis_ticks(x_min, x_max, 5):
px = _map_to_pixel(tv, x_min, x_max, ml, ml + pw)
lines.append(
f'<text x="{px:.1f}" y="{mt + ph + 16}" text-anchor="middle"'
f' font-size="11" font-family="{_FONT}" fill="#444">{_format_eng(tv)}s</text>'
)
# Data trace
lines.append(
f'<polyline points="{pts}" fill="none" stroke="{_COLORS[0]}"'
f' stroke-width="1.5" stroke-linejoin="round"/>'
)
# Title
lines.append(
f'<text x="{width // 2}" y="{mt - 12}" text-anchor="middle"'
f' font-size="14" font-weight="600" font-family="{_FONT}"'
f' fill="#111">{title} - {signal}</text>'
)
# Axis labels
mid_y = mt + ph // 2
lines.append(
f'<text x="{ml - 55}" y="{mid_y}" text-anchor="middle"'
f' font-size="12" font-family="{_FONT}" fill="#333"'
f' transform="rotate(-90, {ml - 55}, {mid_y})">Voltage (V)</text>'
)
lines.append(
f'<text x="{ml + pw // 2}" y="{mt + ph + 42}" text-anchor="middle"'
f' font-size="12" font-family="{_FONT}" fill="#333">Time (s)</text>'
)
lines.append("</svg>")
return "\n".join(lines)
def _builtin_bode(wf, signal: str, title: str, width: int, height: int) -> str:
"""Generate a simple Bode plot SVG (magnitude + phase)."""
ml, mr, mt, mb = 80, 20, 40, 60
pw = width - ml - mr
gap = 30
available_h = height - mt - mb - gap
mag_h = int(available_h * 0.55)
phase_h = available_h - mag_h
freq = wf.x_data
mag_db = wf.y_magnitude_db[signal]
phase_deg = wf.y_phase_deg[signal]
# Use log scale for frequency
import math
freq_log = [math.log10(max(f, 1e-30)) for f in freq]
fl_min, fl_max = min(freq_log), max(freq_log)
m_min, m_max = min(mag_db), max(mag_db)
m_span = m_max - m_min if m_max != m_min else 1.0
m_min -= m_span * 0.05
m_max += m_span * 0.05
p_min, p_max = min(phase_deg), max(phase_deg)
p_span = p_max - p_min if p_max != p_min else 1.0
p_min -= p_span * 0.05
p_max += p_span * 0.05
phase_y = mt + mag_h + gap
mag_pts = _polyline_points(freq_log, mag_db, fl_min, fl_max, m_min, m_max, ml, mt, pw, mag_h)
phase_pts = _polyline_points(
freq_log, phase_deg, fl_min, fl_max, p_min, p_max, ml, phase_y, pw, phase_h
)
lines = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"'
f' viewBox="0 0 {width} {height}">',
f'<rect width="{width}" height="{height}" fill="white"/>',
# Magnitude plot area
f'<rect x="{ml}" y="{mt}" width="{pw}" height="{mag_h}" fill="white" stroke="#ccc"/>',
# Phase plot area
f'<rect x="{ml}" y="{phase_y}" width="{pw}" height="{phase_h}"'
f' fill="white" stroke="#ccc"/>',
]
# Magnitude trace
lines.append(
f'<polyline points="{mag_pts}" fill="none" stroke="{_COLORS[0]}"'
f' stroke-width="1.5" stroke-linejoin="round"/>'
)
# Phase trace
lines.append(
f'<polyline points="{phase_pts}" fill="none" stroke="{_COLORS[1]}"'
f' stroke-width="1.5" stroke-linejoin="round"/>'
)
# Title
lines.append(
f'<text x="{width // 2}" y="{mt - 12}" text-anchor="middle"'
f' font-size="14" font-weight="600" font-family="{_FONT}"'
f' fill="#111">{title} - {signal}</text>'
)
# Y-axis labels
mag_mid = mt + mag_h // 2
lines.append(
f'<text x="{ml - 55}" y="{mag_mid}" text-anchor="middle"'
f' font-size="12" font-family="{_FONT}" fill="#333"'
f' transform="rotate(-90, {ml - 55}, {mag_mid})">Magnitude (dB)</text>'
)
phase_mid = phase_y + phase_h // 2
lines.append(
f'<text x="{ml - 55}" y="{phase_mid}" text-anchor="middle"'
f' font-size="12" font-family="{_FONT}" fill="#333"'
f' transform="rotate(-90, {ml - 55}, {phase_mid})">Phase (deg)</text>'
)
# X-axis label
lines.append(
f'<text x="{ml + pw // 2}" y="{phase_y + phase_h + 42}" text-anchor="middle"'
f' font-size="12" font-family="{_FONT}" fill="#333">Frequency (Hz)</text>'
)
lines.append("</svg>")
return "\n".join(lines)

View File

@ -0,0 +1,147 @@
"""File-based notebook storage using .spicebook (JSON) files."""
import json
import logging
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from spicebook.models.notebook import (
Cell,
CellType,
Notebook,
NotebookMetadata,
NotebookSummary,
)
logger = logging.getLogger(__name__)
SPICEBOOK_EXT = ".spicebook"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _slugify(text: str) -> str:
"""Convert text to a filesystem-safe slug."""
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s_]+", "-", slug)
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or "notebook"
def _generate_notebook_id(title: str) -> str:
"""Generate a notebook ID from title, with short UUID suffix for uniqueness."""
slug = _slugify(title)
short_uid = uuid.uuid4().hex[:8]
return f"{slug}-{short_uid}"
def _scan_directory(directory: Path) -> list[Path]:
"""Find all .spicebook files in a directory (non-recursive)."""
if not directory.is_dir():
return []
return sorted(directory.glob(f"*{SPICEBOOK_EXT}"))
def _notebook_id_from_path(path: Path) -> str:
return path.stem
def list_notebooks(directory: Path) -> list[NotebookSummary]:
"""List all notebooks in the directory and its examples/ subdirectory."""
summaries: list[NotebookSummary] = []
seen_ids: set[str] = set()
# Scan user notebooks first, then examples
for scan_dir in [directory / "user", directory / "examples"]:
for path in _scan_directory(scan_dir):
nb_id = _notebook_id_from_path(path)
if nb_id in seen_ids:
continue
seen_ids.add(nb_id)
try:
nb = _read_notebook_file(path)
summaries.append(NotebookSummary(
id=nb_id,
title=nb.metadata.title,
engine=nb.metadata.engine,
tags=nb.metadata.tags,
cell_count=len(nb.cells),
modified=nb.metadata.modified,
))
except Exception:
logger.warning("Skipping corrupt notebook: %s", path)
return summaries
def load_notebook(directory: Path, notebook_id: str) -> Notebook | None:
"""Load a notebook by ID, searching user/ then examples/ subdirectories."""
for sub in ["user", "examples"]:
path = directory / sub / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
return _read_notebook_file(path)
return None
def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None:
"""Save a notebook to the user/ subdirectory (updates modified timestamp)."""
user_dir = directory / "user"
user_dir.mkdir(parents=True, exist_ok=True)
notebook.metadata.modified = _now_iso()
path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}"
path.write_text(
notebook.model_dump_json(indent=2),
encoding="utf-8",
)
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
"""Create a new notebook and save it. Returns (notebook_id, notebook)."""
nb_id = _generate_notebook_id(title)
now = _now_iso()
notebook = Notebook(
metadata=NotebookMetadata(
title=title,
engine=engine,
created=now,
modified=now,
),
cells=[
Cell(
id=f"cell-{uuid.uuid4().hex[:12]}",
type=CellType.MARKDOWN,
source=f"# {title}\n\nAdd SPICE cells below to begin simulating.",
),
],
)
save_notebook(directory, nb_id, notebook)
return nb_id, notebook
def delete_notebook(directory: Path, notebook_id: str) -> bool:
"""Delete a notebook file. Only allows deleting from user/ subdirectory.
Returns True if deleted, False if not found.
"""
path = directory / "user" / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
path.unlink()
return True
return False
def _read_notebook_file(path: Path) -> Notebook:
"""Read and validate a .spicebook JSON file."""
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
return Notebook.model_validate(data)

View File

@ -0,0 +1,150 @@
"""Integration test for NgspiceEngine with a simple RC circuit."""
import shutil
import tempfile
from pathlib import Path
import pytest
from spicebook.engine.ngspice import NgspiceEngine, parse_ngspice_raw
# Skip the entire module if ngspice is not installed
pytestmark = pytest.mark.skipif(
shutil.which("ngspice") is None,
reason="ngspice not found on PATH",
)
RC_TRANSIENT_NETLIST = """\
RC Low-Pass Filter - Transient
V1 in 0 PULSE(0 5 0 1n 1n 5m 10m)
R1 in out 1k
C1 out 0 1u
.tran 100u 20m
.end
"""
RC_AC_NETLIST = """\
RC Low-Pass Filter - AC
V1 in 0 AC 1
R1 in out 1k
C1 out 0 1u
.ac dec 50 1 1Meg
.end
"""
@pytest.fixture
def engine():
return NgspiceEngine()
@pytest.fixture
def work_dir():
d = tempfile.mkdtemp(prefix="spicebook-test-")
yield Path(d)
# Cleanup handled by OS for temp dirs; explicit cleanup optional
import shutil as _shutil
_shutil.rmtree(d, ignore_errors=True)
@pytest.mark.asyncio
async def test_transient_simulation(engine, work_dir):
"""Run a transient analysis on an RC circuit and verify waveform output."""
result = await engine.run(RC_TRANSIENT_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "time"
assert result.waveform.points > 0
assert len(result.waveform.x_data) == result.waveform.points
assert result.elapsed_seconds > 0
# Should have at least one signal in y_data
assert len(result.waveform.y_data) > 0
# Check that V(out) or similar exists
signal_names = list(result.waveform.y_data.keys())
has_out = any("out" in name.lower() for name in signal_names)
assert has_out, f"Expected 'out' signal in {signal_names}"
# Verify data length matches
for name, values in result.waveform.y_data.items():
assert len(values) == result.waveform.points, f"Length mismatch for {name}"
@pytest.mark.asyncio
async def test_ac_simulation(engine, work_dir):
"""Run an AC analysis and verify complex waveform output with mag/phase."""
result = await engine.run(RC_AC_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "frequency"
assert result.waveform.is_complex
assert result.waveform.points > 0
# AC analysis should produce magnitude and phase data
assert result.waveform.y_magnitude_db is not None
assert result.waveform.y_phase_deg is not None
assert len(result.waveform.y_magnitude_db) > 0
assert len(result.waveform.y_phase_deg) > 0
@pytest.mark.asyncio
async def test_netlist_without_end_directive(engine, work_dir):
"""Engine should auto-append .end if missing."""
netlist_no_end = """\
Simple Resistor Divider
V1 in 0 DC 10
R1 in out 1k
R2 out 0 1k
.op
"""
# .op (operating point) does not produce a .raw file with waveforms,
# but the simulation should still run without error from a missing .end.
result = await engine.run(netlist_no_end, work_dir)
# .op may or may not produce a raw file depending on ngspice version,
# but it should not crash with a "missing .end" error
assert result.error is None or ".end" not in (result.error or "").lower()
@pytest.mark.asyncio
async def test_invalid_netlist(engine, work_dir):
"""Malformed netlist should return success=False with error info."""
bad_netlist = """\
This is not a valid SPICE netlist
.tran 1m 10m
.end
"""
result = await engine.run(bad_netlist, work_dir)
# Should either fail or produce empty output -- not crash
# ngspice may still exit 0 for some "invalid" netlists, so we just
# verify we get a response at all
assert isinstance(result.success, bool)
def test_raw_parser_on_transient(work_dir):
"""Verify the raw parser handles a real ngspice output file."""
cir = work_dir / "test.cir"
cir.write_text(RC_TRANSIENT_NETLIST)
raw_path = work_dir / "output.raw"
import subprocess
proc = subprocess.run(
["ngspice", "-b", "-r", str(raw_path), str(cir)],
capture_output=True,
timeout=30,
)
assert raw_path.exists(), f"ngspice did not produce raw file. stderr: {proc.stderr.decode()}"
raw = parse_ngspice_raw(raw_path)
assert raw.points > 0
assert len(raw.variables) > 0
assert raw.data.shape[0] == len(raw.variables)
assert raw.data.shape[1] == raw.points
# First variable should be time
assert "time" in raw.variables[0].name.lower()

686
backend/uv.lock generated Normal file
View File

@ -0,0 +1,686 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.129.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
name = "spicebook"
version = "2026.2.13"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "numpy" },
{ name = "pydantic" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
{ name = "websockets", specifier = ">=12.0" },
]
provides-extras = ["dev"]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]

26
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,26 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
ports:
- "${BACKEND_PORT:-8099}:8000"
volumes:
- ./backend/src:/app/src
- ./notebooks:/app/notebooks
# Mount mcltspice for live development
- ../mcp-ltspice/src/mcltspice:/app/mcltspice-lib:ro
command: ["uv", "run", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
ports:
- "${FRONTEND_PORT:-4321}:4321"
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

29
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,29 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: prod
expose:
- "8000"
networks:
- default
- caddy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: prod
expose:
- "4321"
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.reverse_proxy: "{{upstreams 4321}}"
networks:
caddy:
external: true

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
services:
backend:
build:
context: ./backend
env_file: .env
volumes:
- ./notebooks:/app/notebooks
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
frontend:
build:
context: ./frontend
env_file: .env
depends_on:
backend:
condition: service_healthy

48
frontend/Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# ── Dev stage ────────────────────────────────────────────────────────
FROM node:20-slim AS dev
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 4321
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# ── Build stage ──────────────────────────────────────────────────────
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ── Prod stage ───────────────────────────────────────────────────────
FROM caddy:2-alpine AS prod
COPY --from=build /app/dist /srv
# Simple Caddyfile for serving the static Astro build
RUN echo ':4321 {\n\
root * /srv\n\
encode gzip\n\
try_files {path} {path}/index.html /index.html\n\
file_server\n\
header {\n\
X-Content-Type-Options nosniff\n\
X-Frame-Options DENY\n\
Referrer-Policy strict-origin-when-cross-origin\n\
}\n\
}' > /etc/caddy/Caddyfile
EXPOSE 4321
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]

26
frontend/astro.config.mjs Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
integrations: [react()],
telemetry: false,
devToolbar: { enabled: false },
vite: {
plugins: [tailwindcss()],
build: {
// CodeMirror + uPlot + React naturally exceeds 500kB minified
chunkSizeWarningLimit: 700,
},
server: {
host: '0.0.0.0',
...(process.env.VITE_HMR_HOST && {
hmr: {
host: process.env.VITE_HMR_HOST,
protocol: 'wss',
clientPort: 443
}
})
}
}
});

8457
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "spicebook-frontend",
"version": "2026.02.13",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/react": "^4.0.0",
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/lang-python": "^6.1.0",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0",
"astro": "^5.0.0",
"clsx": "^2.1.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"uplot": "^1.6.31",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<path d="M6 22 L10 10 L14 18 L18 8 L22 20 L26 12" stroke="#2563eb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="10" cy="10" r="1.5" fill="#2563eb"/>
<circle cx="18" cy="8" r="1.5" fill="#2563eb"/>
<circle cx="26" cy="12" r="1.5" fill="#2563eb"/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { listNotebooks } from '../lib/api';
import type { NotebookSummary } from '../lib/types';
import {
Plus,
AlertTriangle,
FileText,
LayoutGrid,
Loader2,
} from 'lucide-react';
function formatDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
const engineColors: Record<string, string> = {
ngspice: 'bg-blue-600/20 text-blue-400 border-blue-500/30',
ltspice: 'bg-amber-600/20 text-amber-400 border-amber-500/30',
};
export default function NotebookList() {
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const data = await listNotebooks();
if (!cancelled) {
setNotebooks(data);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: 'Could not connect to the SpiceBook backend.',
);
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-3" />
<span className="text-slate-400 text-sm">Loading notebooks...</span>
</div>
);
}
return (
<>
{/* Error state */}
{error && (
<div className="rounded-lg border border-amber-500/30 bg-amber-600/10 p-6 mb-8">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 shrink-0" />
<div>
<h3 className="text-amber-400 font-medium">
Backend Unavailable
</h3>
<p className="text-amber-200/70 text-sm mt-1">{error}</p>
<p className="text-slate-400 text-xs mt-3">
Start the backend with{' '}
<code className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-300">
make dev
</code>{' '}
from the project root.
</p>
</div>
</div>
</div>
)}
{/* Empty state */}
{!error && notebooks.length === 0 && (
<div className="text-center py-20">
<FileText className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-300 mb-2">
No notebooks yet
</h2>
<p className="text-slate-500 mb-6">
Create your first SpiceBook notebook to get started.
</p>
<a
href="/notebook/new"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors"
>
<Plus className="w-4 h-4" />
Create Notebook
</a>
</div>
)}
{/* Notebook grid */}
{notebooks.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{notebooks.map((nb) => (
<a
key={nb.id}
href={`/notebook/?id=${encodeURIComponent(nb.id)}`}
className="group block rounded-lg border border-slate-700 bg-slate-800/50 p-5 hover:border-blue-500/50 hover:bg-slate-800 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
{nb.title}
</h3>
<span
className={`text-xs px-2 py-0.5 rounded-full border font-medium shrink-0 ml-2 ${
engineColors[nb.engine] ||
'bg-slate-700 text-slate-400 border-slate-600'
}`}
>
{nb.engine}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<LayoutGrid className="w-3 h-3" />
{nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
</span>
<span>{formatDate(nb.modified)}</span>
</div>
{nb.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{nb.tags.map((tag) => (
<span
key={tag}
className="text-xs bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded"
>
{tag}
</span>
))}
</div>
)}
</a>
))}
</div>
)}
</>
);
}

View File

@ -0,0 +1,100 @@
import { FileText, Zap, Code, Plus } from 'lucide-react';
import type { CellType } from '../../lib/types';
import { useNotebookStore } from '../../lib/notebook-store';
import { MarkdownCell } from './cells/MarkdownCell';
import { SpiceCell } from './cells/SpiceCell';
import { PythonCell } from './cells/PythonCell';
import { SchematicCell } from './cells/SchematicCell';
import { Dropdown } from '../ui/Dropdown';
import { Button } from '../ui/Button';
function AddCellButton({ afterId }: { afterId?: string }) {
const { addCell } = useNotebookStore();
const items = [
{
label: 'Markdown',
icon: <FileText className="w-4 h-4" />,
onClick: () => addCell('markdown' as CellType, afterId),
},
{
label: 'SPICE',
icon: <Zap className="w-4 h-4" />,
onClick: () => addCell('spice' as CellType, afterId),
},
{
label: 'Python',
icon: <Code className="w-4 h-4" />,
onClick: () => addCell('python' as CellType, afterId),
},
];
return (
<div className="flex justify-center py-1 opacity-0 hover:opacity-100 focus-within:opacity-100 transition-opacity">
<Dropdown
trigger={
<Button variant="ghost" size="sm" className="text-slate-600 hover:text-slate-400">
<Plus className="w-3 h-3" />
<span className="text-xs">Add cell</span>
</Button>
}
items={items}
/>
</div>
);
}
export function CellList() {
const { notebook } = useNotebookStore();
if (!notebook) return null;
const cells = notebook.cells;
if (cells.length === 0) {
return (
<div className="text-center py-16">
<p className="text-slate-500 mb-4">This notebook is empty.</p>
<AddCellButton />
</div>
);
}
return (
<div className="space-y-0">
{cells.map((cell, index) => {
const isFirst = index === 0;
const isLast = index === cells.length - 1;
let CellComponent;
switch (cell.type) {
case 'markdown':
CellComponent = MarkdownCell;
break;
case 'spice':
CellComponent = SpiceCell;
break;
case 'python':
CellComponent = PythonCell;
break;
case 'schematic':
CellComponent = SchematicCell;
break;
default:
return null;
}
return (
<div key={cell.id}>
<CellComponent
cell={cell}
isFirst={isFirst}
isLast={isLast}
/>
<AddCellButton afterId={cell.id} />
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { createNotebook } from '../../lib/api';
import { Loader2 } from 'lucide-react';
/**
* Client-side component that creates a new notebook via the API
* and redirects to its editor page.
*/
export default function NewNotebookRedirect() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function create() {
try {
const nb = await createNotebook('Untitled Notebook', 'ngspice');
if (cancelled) return;
// Try to extract the notebook ID from the response.
// The API may return the ID at the top level or nested in metadata.
const data = nb as unknown as Record<string, unknown>;
const id =
(data.id as string) ||
((data.metadata as Record<string, unknown>)?.id as string);
if (id) {
window.location.href = `/notebook/?id=${encodeURIComponent(id)}`;
} else {
// Fallback: go back to the list where the new notebook should appear
window.location.href = '/';
}
} catch (err) {
if (cancelled) return;
setError(
err instanceof Error
? err.message
: 'Failed to create notebook. Is the backend running?',
);
}
}
create();
return () => {
cancelled = true;
};
}, []);
if (error) {
return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="max-w-md w-full">
<div className="rounded-lg border border-red-500/30 bg-red-600/10 p-6 text-center">
<h2 className="text-lg font-semibold text-red-400 mb-2">
Could not create notebook
</h2>
<p className="text-sm text-slate-400 mb-4">{error}</p>
<a
href="/"
className="inline-block text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2"
>
Back to notebook list
</a>
</div>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mx-auto mb-3" />
<p className="text-slate-400 text-sm">Creating notebook...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
import { useEffect, useCallback } from 'react';
import { useNotebookStore } from '../../lib/notebook-store';
import { NotebookToolbar } from './toolbar/NotebookToolbar';
import { CellList } from './CellList';
import { Loader2 } from 'lucide-react';
interface NotebookEditorProps {
notebookId: string;
}
export default function NotebookEditor({ notebookId }: NotebookEditorProps) {
const { notebook, loading, error, loadNotebook, saveNotebook } =
useNotebookStore();
// Load notebook on mount
useEffect(() => {
loadNotebook(notebookId);
}, [notebookId, loadNotebook]);
// Keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Ctrl+S / Cmd+S: Save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveNotebook();
}
},
[saveNotebook],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// Loading state
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mx-auto mb-3" />
<p className="text-slate-400 text-sm">Loading notebook...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="max-w-md w-full">
<div className="rounded-lg border border-red-500/30 bg-red-600/10 p-6 text-center">
<h2 className="text-lg font-semibold text-red-400 mb-2">
Failed to load notebook
</h2>
<p className="text-sm text-slate-400 mb-4">{error}</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => loadNotebook(notebookId)}
className="px-4 py-2 text-sm bg-slate-700 hover:bg-slate-600 text-slate-200 rounded-lg transition-colors"
>
Retry
</button>
<a
href="/"
className="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-slate-400 rounded-lg transition-colors"
>
Back to list
</a>
</div>
</div>
</div>
</div>
);
}
// No notebook loaded (shouldn't normally happen)
if (!notebook) {
return null;
}
return (
<div className="min-h-screen flex flex-col">
<NotebookToolbar />
<main className="flex-1 max-w-5xl w-full mx-auto px-4 py-6">
<CellList />
</main>
{/* Status bar at bottom */}
<footer className="border-t border-slate-800 bg-slate-950 px-4 py-1.5 text-[11px] text-slate-600 flex items-center gap-4">
<span>
{notebook.cells.length} cell
{notebook.cells.length !== 1 ? 's' : ''}
</span>
<span>{notebook.metadata.engine}</span>
<span className="ml-auto">SpiceBook v{notebook.spicebook_version}</span>
</footer>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import NotebookEditor from './NotebookEditor';
/**
* Wrapper that extracts the notebook ID from the URL query string
* and passes it to the main editor component.
*
* URL format: /notebook/?id=<notebook-id>
*/
export default function NotebookEditorPage() {
const notebookId = useMemo(() => {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
return params.get('id');
}, []);
if (!notebookId) {
return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="max-w-md w-full text-center">
<h2 className="text-lg font-semibold text-slate-300 mb-2">
No notebook selected
</h2>
<p className="text-sm text-slate-500 mb-4">
Open a notebook from the list to start editing.
</p>
<a
href="/"
className="inline-block px-4 py-2 text-sm bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
Browse notebooks
</a>
</div>
</div>
);
}
return <NotebookEditor notebookId={notebookId} />;
}

View File

@ -0,0 +1,88 @@
import { useState, useCallback } from 'react';
import type { Cell } from '../../../lib/types';
import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar';
import { MarkdownEditor } from '../editor/MarkdownEditor';
import { renderMarkdown } from '../../../lib/markdown';
import { cn } from '../../../lib/cn';
interface MarkdownCellProps {
cell: Cell;
isFirst: boolean;
isLast: boolean;
}
export function MarkdownCell({ cell, isFirst, isLast }: MarkdownCellProps) {
const {
activeCell,
setActiveCell,
updateCellSource,
deleteCell,
moveCell,
} = useNotebookStore();
const isActive = activeCell === cell.id;
const [editing, setEditing] = useState(false);
const handleChange = useCallback(
(value: string) => {
updateCellSource(cell.id, value);
},
[cell.id, updateCellSource],
);
const handleBlur = useCallback(() => {
setEditing(false);
}, []);
function handleClick() {
setActiveCell(cell.id);
if (!editing) {
setEditing(true);
}
}
return (
<div
className={cn(
'rounded-lg border transition-colors',
isActive
? 'border-blue-500/50 ring-1 ring-blue-500/20'
: 'border-slate-700/50 hover:border-slate-600',
)}
onClick={() => setActiveCell(cell.id)}
>
<CellToolbar
cellId={cell.id}
cellType="markdown"
isRunning={false}
isFirst={isFirst}
isLast={isLast}
onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')}
onMoveDown={() => moveCell(cell.id, 'down')}
/>
{editing && isActive ? (
<div className="min-h-[60px]">
<MarkdownEditor
value={cell.source}
onChange={handleChange}
onBlur={handleBlur}
/>
</div>
) : (
<div
className="markdown-output px-4 py-3 min-h-[40px] cursor-text"
onClick={handleClick}
dangerouslySetInnerHTML={{
__html:
cell.source.trim().length > 0
? renderMarkdown(cell.source)
: '<p class="text-slate-600 italic">Click to edit markdown...</p>',
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,79 @@
import { useCallback } from 'react';
import type { Cell } from '../../../lib/types';
import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar';
import { PythonEditor } from '../editor/PythonEditor';
import { TextOutput } from '../output/TextOutput';
import { cn } from '../../../lib/cn';
interface PythonCellProps {
cell: Cell;
isFirst: boolean;
isLast: boolean;
}
export function PythonCell({ cell, isFirst, isLast }: PythonCellProps) {
const {
activeCell,
setActiveCell,
updateCellSource,
deleteCell,
moveCell,
} = useNotebookStore();
const isActive = activeCell === cell.id;
const handleChange = useCallback(
(value: string) => {
updateCellSource(cell.id, value);
},
[cell.id, updateCellSource],
);
// Check for pre-existing outputs
const lastOutput = cell.outputs.length > 0 ? cell.outputs[cell.outputs.length - 1] : null;
const textData =
lastOutput?.data?.text ||
lastOutput?.data?.['text/plain'] ||
'';
return (
<div
className={cn(
'rounded-lg border transition-colors overflow-hidden',
isActive
? 'border-blue-500/50 ring-1 ring-blue-500/20'
: 'border-slate-700/50 hover:border-slate-600',
)}
onClick={() => setActiveCell(cell.id)}
>
<CellToolbar
cellId={cell.id}
cellType="python"
isRunning={false}
isFirst={isFirst}
isLast={isLast}
onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')}
onMoveDown={() => moveCell(cell.id, 'down')}
/>
{/* Editor */}
<div className="min-h-[60px]">
<PythonEditor value={cell.source} onChange={handleChange} />
</div>
{/* Coming soon banner */}
<div className="border-t border-slate-700/50 px-4 py-2 bg-amber-600/5">
<span className="text-xs text-amber-500/80">
Python execution coming in Phase 2
</span>
</div>
{/* Pre-existing outputs */}
{typeof textData === 'string' && textData.trim() && (
<TextOutput text={textData as string} />
)}
</div>
);
}

View File

@ -0,0 +1,67 @@
import type { Cell } from '../../../lib/types';
import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar';
import { cn } from '../../../lib/cn';
import { Image } from 'lucide-react';
interface SchematicCellProps {
cell: Cell;
isFirst: boolean;
isLast: boolean;
}
export function SchematicCell({ cell, isFirst, isLast }: SchematicCellProps) {
const { activeCell, setActiveCell, deleteCell, moveCell } =
useNotebookStore();
const isActive = activeCell === cell.id;
// Check for SVG output
const svgOutput = cell.outputs.find(
(o) => o.data?.['image/svg+xml'] || o.data?.svg,
);
const svgContent =
(svgOutput?.data?.['image/svg+xml'] as string) ||
(svgOutput?.data?.svg as string) ||
null;
return (
<div
className={cn(
'rounded-lg border transition-colors overflow-hidden',
isActive
? 'border-blue-500/50 ring-1 ring-blue-500/20'
: 'border-slate-700/50 hover:border-slate-600',
)}
onClick={() => setActiveCell(cell.id)}
>
<CellToolbar
cellId={cell.id}
cellType="schematic"
isRunning={false}
isFirst={isFirst}
isLast={isLast}
onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')}
onMoveDown={() => moveCell(cell.id, 'down')}
/>
{svgContent ? (
<div
className="p-4 bg-white/5 flex items-center justify-center"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
) : (
<div className="px-6 py-10 text-center">
<Image className="w-10 h-10 text-slate-600 mx-auto mb-3" />
<p className="text-sm text-slate-500 font-medium">
Schematic Editor
</p>
<p className="text-xs text-slate-600 mt-1">
Visual schematic capture coming in a future release.
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,123 @@
import { useCallback } from 'react';
import type { Cell } from '../../../lib/types';
import type { WaveformData } from '../../../lib/types';
import { useNotebookStore } from '../../../lib/notebook-store';
import { CellToolbar } from '../toolbar/CellToolbar';
import { SpiceEditor } from '../editor/SpiceEditor';
import { WaveformViewer } from '../output/WaveformViewer';
import { SimulationLog } from '../output/SimulationLog';
import { cn } from '../../../lib/cn';
interface SpiceCellProps {
cell: Cell;
isFirst: boolean;
isLast: boolean;
}
export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
const {
activeCell,
runningCells,
setActiveCell,
updateCellSource,
deleteCell,
moveCell,
runCell,
} = useNotebookStore();
const isActive = activeCell === cell.id;
const isRunning = runningCells.has(cell.id);
const handleChange = useCallback(
(value: string) => {
updateCellSource(cell.id, value);
},
[cell.id, updateCellSource],
);
const handleRun = useCallback(() => {
runCell(cell.id);
}, [cell.id, runCell]);
// Extract output data
const lastOutput = cell.outputs.length > 0 ? cell.outputs[cell.outputs.length - 1] : null;
const outputData = lastOutput?.data as {
success?: boolean;
waveform?: WaveformData | null;
log?: string;
error?: string | null;
elapsed_seconds?: number;
} | null;
return (
<div
className={cn(
'rounded-lg border transition-colors overflow-hidden',
isActive
? 'border-blue-500/50 ring-1 ring-blue-500/20'
: 'border-slate-700/50 hover:border-slate-600',
)}
onClick={() => setActiveCell(cell.id)}
>
<CellToolbar
cellId={cell.id}
cellType="spice"
isRunning={isRunning}
isFirst={isFirst}
isLast={isLast}
onRun={handleRun}
onDelete={() => deleteCell(cell.id)}
onMoveUp={() => moveCell(cell.id, 'up')}
onMoveDown={() => moveCell(cell.id, 'down')}
/>
{/* Editor */}
<div className="min-h-[80px]">
<SpiceEditor
value={cell.source}
onChange={handleChange}
onRun={handleRun}
/>
</div>
{/* Running indicator */}
{isRunning && (
<div className="border-t border-slate-700/50 px-4 py-3">
<div className="flex items-center gap-2 text-sm text-blue-400">
<div className="w-4 h-4 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
Running simulation...
</div>
</div>
)}
{/* Outputs */}
{!isRunning && outputData && (
<div className="border-t border-slate-700/50">
{/* Error banner */}
{!outputData.success && outputData.error && (
<div className="px-4 py-3 bg-red-600/10 border-b border-red-500/20">
<div className="text-sm text-red-400 font-mono whitespace-pre-wrap">
{outputData.error}
</div>
</div>
)}
{/* Waveform plot */}
{outputData.waveform && (
<div className="p-3 bg-slate-900/50">
<WaveformViewer waveform={outputData.waveform} />
</div>
)}
{/* Simulation log */}
<SimulationLog
log={outputData.log || ''}
error={outputData.success ? null : (outputData.error ?? null)}
elapsedSeconds={outputData.elapsed_seconds || 0}
defaultOpen={!outputData.success}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,138 @@
import { useRef, useEffect, useMemo } from 'react';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
className?: string;
}
const darkTheme = EditorView.theme(
{
'&': {
backgroundColor: '#0f172a',
color: '#e2e8f0',
},
'.cm-content': {
caretColor: '#2563eb',
fontFamily:
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace",
fontSize: '14px',
lineHeight: '1.6',
padding: '8px 0',
},
'.cm-gutters': {
backgroundColor: '#0f172a',
color: '#475569',
border: 'none',
borderRight: '1px solid #1e293b',
},
'.cm-activeLineGutter': {
backgroundColor: '#1e293b',
},
'.cm-activeLine': {
backgroundColor: 'rgba(30, 41, 59, 0.4)',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: '#2563eb',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'rgba(37, 99, 235, 0.2)',
},
},
{ dark: true },
);
export function MarkdownEditor({
value,
onChange,
onBlur,
className,
}: MarkdownEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onBlurRef = useRef(onBlur);
onChangeRef.current = onChange;
onBlurRef.current = onBlur;
const handleUpdate: Extension = useMemo(
() =>
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
// Detect focus loss
if (update.focusChanged && !update.view.hasFocus) {
onBlurRef.current?.();
}
}),
[],
);
useEffect(() => {
if (!containerRef.current) return;
const escapeKeymap = keymap.of([
{
key: 'Escape',
run: () => {
onBlurRef.current?.();
return true;
},
},
]);
const state = EditorState.create({
doc: value,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
escapeKeymap,
markdown(),
syntaxHighlighting(defaultHighlightStyle),
darkTheme,
handleUpdate,
EditorView.lineWrapping,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
// Auto-focus when the editor mounts
view.focus();
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className={className}
/>
);
}

View File

@ -0,0 +1,119 @@
import { useRef, useEffect, useMemo } from 'react';
import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { python } from '@codemirror/lang-python';
interface PythonEditorProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
const darkTheme = EditorView.theme(
{
'&': {
backgroundColor: '#0f172a',
color: '#e2e8f0',
},
'.cm-content': {
caretColor: '#2563eb',
fontFamily:
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace",
fontSize: '14px',
lineHeight: '1.6',
padding: '8px 0',
},
'.cm-gutters': {
backgroundColor: '#0f172a',
color: '#475569',
border: 'none',
borderRight: '1px solid #1e293b',
},
'.cm-activeLineGutter': {
backgroundColor: '#1e293b',
},
'.cm-activeLine': {
backgroundColor: 'rgba(30, 41, 59, 0.4)',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: '#2563eb',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'rgba(37, 99, 235, 0.2)',
},
'.cm-matchingBracket': {
backgroundColor: 'rgba(37, 99, 235, 0.3)',
color: '#f1f5f9',
},
},
{ dark: true },
);
export function PythonEditor({ value, onChange, className }: PythonEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const handleUpdate: Extension = useMemo(
() =>
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
[],
);
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
highlightActiveLine(),
history(),
bracketMatching(),
keymap.of([...defaultKeymap, ...historyKeymap]),
python(),
syntaxHighlighting(defaultHighlightStyle),
darkTheme,
handleUpdate,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className={className}
/>
);
}

View File

@ -0,0 +1,143 @@
import { useRef, useEffect, useMemo } from 'react';
import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching } from '@codemirror/language';
import { spiceSupport } from '../../../lib/spice-language';
interface SpiceEditorProps {
value: string;
onChange: (value: string) => void;
onRun?: () => void;
className?: string;
}
const darkTheme = EditorView.theme(
{
'&': {
backgroundColor: '#0f172a',
color: '#e2e8f0',
},
'.cm-content': {
caretColor: '#2563eb',
fontFamily:
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace",
fontSize: '14px',
lineHeight: '1.6',
padding: '8px 0',
},
'.cm-gutters': {
backgroundColor: '#0f172a',
color: '#475569',
border: 'none',
borderRight: '1px solid #1e293b',
},
'.cm-activeLineGutter': {
backgroundColor: '#1e293b',
},
'.cm-activeLine': {
backgroundColor: 'rgba(30, 41, 59, 0.4)',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: '#2563eb',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'rgba(37, 99, 235, 0.2)',
},
'.cm-matchingBracket': {
backgroundColor: 'rgba(37, 99, 235, 0.3)',
color: '#f1f5f9',
},
},
{ dark: true },
);
export function SpiceEditor({ value, onChange, onRun, className }: SpiceEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onRunRef = useRef(onRun);
// Keep callback refs current without recreating the editor
onChangeRef.current = onChange;
onRunRef.current = onRun;
const handleUpdate: Extension = useMemo(
() =>
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
[],
);
useEffect(() => {
if (!containerRef.current) return;
const runKeymap = keymap.of([
{
key: 'Shift-Enter',
run: () => {
onRunRef.current?.();
return true;
},
},
{
key: 'Ctrl-Enter',
run: () => {
onRunRef.current?.();
return true;
},
},
]);
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
highlightActiveLine(),
history(),
bracketMatching(),
keymap.of([...defaultKeymap, ...historyKeymap]),
runKeymap,
darkTheme,
...spiceSupport(),
handleUpdate,
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// Only create the editor once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes into the editor (if they differ)
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className={className}
/>
);
}

View File

@ -0,0 +1,75 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
interface SimulationLogProps {
log: string;
error?: string | null;
elapsedSeconds: number;
defaultOpen?: boolean;
}
function highlightLine(line: string): { text: string; className: string } {
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('fatal')) {
return { text: line, className: 'text-red-400' };
}
if (lower.includes('warning') || lower.includes('warn')) {
return { text: line, className: 'text-amber-400' };
}
return { text: line, className: 'text-slate-400' };
}
export function SimulationLog({
log,
error,
elapsedSeconds,
defaultOpen = false,
}: SimulationLogProps) {
const [open, setOpen] = useState(defaultOpen || !!error);
const hasContent = log.trim().length > 0 || !!error;
if (!hasContent) return null;
const lines = log.split('\n').filter((l) => l.trim().length > 0);
return (
<div className="border-t border-slate-700/50">
<button
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-slate-500 hover:text-slate-400 transition-colors"
onClick={() => setOpen(!open)}
>
{open ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>Simulation Log</span>
<span className="ml-auto tabular-nums">
{elapsedSeconds.toFixed(3)}s
</span>
</button>
{open && (
<div className="px-3 pb-3">
{error && (
<div className="mb-2 px-3 py-2 rounded bg-red-600/10 border border-red-500/20 text-red-400 text-xs font-mono whitespace-pre-wrap">
{error}
</div>
)}
{lines.length > 0 && (
<pre className="text-xs font-mono leading-relaxed overflow-x-auto max-h-48 overflow-y-auto">
{lines.map((line, i) => {
const { text, className } = highlightLine(line);
return (
<div key={i} className={className}>
{text}
</div>
);
})}
</pre>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,16 @@
interface TextOutputProps {
text: string;
className?: string;
}
export function TextOutput({ text, className }: TextOutputProps) {
if (!text.trim()) return null;
return (
<div className={className}>
<pre className="px-4 py-3 text-xs font-mono text-slate-300 bg-slate-900/50 rounded-b-lg overflow-x-auto leading-relaxed whitespace-pre-wrap">
{text}
</pre>
</div>
);
}

View File

@ -0,0 +1,236 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
// uPlot is a vanilla JS lib; import it and its CSS
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
interface WaveformViewerProps {
waveform: WaveformData;
className?: string;
}
function buildOpts(
waveform: WaveformData,
width: number,
height: number,
xType: string,
yLabel: string,
): uPlot.Options {
const traceNames = Object.keys(waveform.y_data);
const series: uPlot.Series[] = [
{ label: xType === 'frequency' ? 'Frequency' : 'Time' },
...traceNames.map((name, i) => ({
label: name,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
})),
];
const axes: uPlot.Axis[] = [
{
stroke: '#475569',
grid: { stroke: 'rgba(51, 65, 85, 0.5)', width: 1 },
ticks: { stroke: '#334155', width: 1 },
font: '11px system-ui, sans-serif',
values: (_u: uPlot, vals: number[]) =>
vals.map((v) => formatAxisValue(v, xType)),
},
{
stroke: '#475569',
grid: { stroke: 'rgba(51, 65, 85, 0.5)', width: 1 },
ticks: { stroke: '#334155', width: 1 },
font: '11px system-ui, sans-serif',
values: (_u: uPlot, vals: number[]) =>
vals.map((v) => formatAxisValue(v, yLabel)),
},
];
return {
width,
height,
series,
axes,
scales: {
x: xType === 'frequency' ? { distr: 3 } : {},
},
cursor: {
drag: { x: true, y: true, setScale: true },
},
legend: {
show: true,
},
};
}
function TransientPlot({
waveform,
width,
}: {
waveform: WaveformData;
width: number;
}) {
const plotRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!plotRef.current || width <= 0) return;
const traceNames = Object.keys(waveform.y_data);
const xData = new Float64Array(waveform.x_data);
const data: uPlot.AlignedData = [
xData as unknown as number[],
...traceNames.map(
(name) =>
new Float64Array(waveform.y_data[name]) as unknown as number[],
),
];
// Determine y-axis type from variable types
const yType =
waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage';
const opts = buildOpts(waveform, width, 280, waveform.x_type, yType);
const u = new uPlot(opts, data, plotRef.current);
uplotRef.current = u;
return () => {
u.destroy();
uplotRef.current = null;
};
}, [waveform, width]);
return <div ref={plotRef} />;
}
function ACPlot({
waveform,
width,
}: {
waveform: WaveformData;
width: number;
}) {
const magRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef<HTMLDivElement>(null);
const magPlotRef = useRef<uPlot | null>(null);
const phasePlotRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!magRef.current || !phaseRef.current || width <= 0) return;
const xData = new Float64Array(waveform.x_data);
const magData = waveform.y_magnitude_db || {};
const phaseData = waveform.y_phase_deg || {};
const magTraces = Object.keys(magData);
const phaseTraces = Object.keys(phaseData);
// Magnitude plot
if (magTraces.length > 0) {
const magSeries: uPlot.AlignedData = [
xData as unknown as number[],
...magTraces.map(
(name) =>
new Float64Array(magData[name]) as unknown as number[],
),
];
const magOpts = buildOpts(waveform, width, 220, 'frequency', 'dB');
magOpts.series = [
{ label: 'Frequency' },
...magTraces.map((name, i) => ({
label: `|${name}| (dB)`,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
})),
];
const u1 = new uPlot(magOpts, magSeries, magRef.current);
magPlotRef.current = u1;
}
// Phase plot
if (phaseTraces.length > 0) {
const phaseSeries: uPlot.AlignedData = [
xData as unknown as number[],
...phaseTraces.map(
(name) =>
new Float64Array(phaseData[name]) as unknown as number[],
),
];
const phaseOpts = buildOpts(
waveform,
width,
180,
'frequency',
'degrees',
);
phaseOpts.series = [
{ label: 'Frequency' },
...phaseTraces.map((name, i) => ({
label: `Phase(${name})`,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
dash: [6, 3],
})),
];
const u2 = new uPlot(phaseOpts, phaseSeries, phaseRef.current);
phasePlotRef.current = u2;
}
return () => {
magPlotRef.current?.destroy();
phasePlotRef.current?.destroy();
magPlotRef.current = null;
phasePlotRef.current = null;
};
}, [waveform, width]);
return (
<div className="space-y-2">
<div ref={magRef} />
<div ref={phaseRef} />
</div>
);
}
export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const updateWidth = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
}, []);
useEffect(() => {
updateWidth();
const observer = new ResizeObserver(updateWidth);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [updateWidth]);
const traceCount = Object.keys(waveform.y_data).length;
const isEmpty =
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
return (
<div ref={containerRef} className={className}>
{isEmpty ? (
<div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display.
</div>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={width} />
) : (
<TransientPlot waveform={waveform} width={width} />
)}
</div>
);
}

View File

@ -0,0 +1,133 @@
import {
Play,
Trash2,
ChevronUp,
ChevronDown,
Loader2,
FileText,
Zap,
Code,
Image,
} from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import type { CellType } from '../../../lib/types';
interface CellToolbarProps {
cellId: string;
cellType: CellType;
isRunning: boolean;
isFirst: boolean;
isLast: boolean;
onRun?: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
const cellTypeConfig: Record<
CellType,
{ label: string; variant: 'blue' | 'green' | 'amber' | 'cyan'; icon: React.ReactNode }
> = {
markdown: {
label: 'Markdown',
variant: 'blue',
icon: <FileText className="w-3 h-3" />,
},
spice: {
label: 'SPICE',
variant: 'green',
icon: <Zap className="w-3 h-3" />,
},
python: {
label: 'Python',
variant: 'amber',
icon: <Code className="w-3 h-3" />,
},
schematic: {
label: 'Schematic',
variant: 'cyan',
icon: <Image className="w-3 h-3" />,
},
};
export function CellToolbar({
cellId,
cellType,
isRunning,
isFirst,
isLast,
onRun,
onDelete,
onMoveUp,
onMoveDown,
}: CellToolbarProps) {
const config = cellTypeConfig[cellType];
return (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/50 border-b border-slate-700/50">
{/* Cell type badge */}
<Badge variant={config.variant}>
<span className="flex items-center gap-1">
{config.icon}
{config.label}
</span>
</Badge>
{/* Spacer */}
<div className="flex-1" />
{/* Cell ID */}
<span className="text-[10px] text-slate-600 font-mono mr-2">{cellId}</span>
{/* Run button (SPICE cells only) */}
{onRun && (
<Button
variant="ghost"
size="icon"
onClick={onRun}
disabled={isRunning}
title={isRunning ? 'Running...' : 'Run cell (Shift+Enter)'}
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-400" />
) : (
<Play className="w-3.5 h-3.5 text-green-400" />
)}
</Button>
)}
{/* Move up */}
<Button
variant="ghost"
size="icon"
onClick={onMoveUp}
disabled={isFirst}
title="Move cell up"
>
<ChevronUp className="w-3.5 h-3.5" />
</Button>
{/* Move down */}
<Button
variant="ghost"
size="icon"
onClick={onMoveDown}
disabled={isLast}
title="Move cell down"
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
{/* Delete */}
<Button
variant="ghost"
size="icon"
onClick={onDelete}
title="Delete cell"
>
<Trash2 className="w-3.5 h-3.5 text-slate-500 hover:text-red-400" />
</Button>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useState, useRef, useEffect } from 'react';
import {
Save,
Play,
Plus,
ArrowLeft,
FileText,
Zap,
Code,
Loader2,
} from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { Dropdown } from '../../ui/Dropdown';
import { useNotebookStore } from '../../../lib/notebook-store';
import type { CellType } from '../../../lib/types';
export function NotebookToolbar() {
const {
notebook,
dirty,
saving,
runningCells,
saveNotebook,
addCell,
runAllCells,
updateTitle,
updateEngine,
} = useNotebookStore();
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
if (!notebook) return null;
const isRunning = runningCells.size > 0;
function handleTitleClick() {
setTitleDraft(notebook!.metadata.title);
setEditingTitle(true);
}
function commitTitle() {
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== notebook!.metadata.title) {
updateTitle(trimmed);
}
setEditingTitle(false);
}
function handleTitleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
commitTitle();
} else if (e.key === 'Escape') {
setEditingTitle(false);
}
}
const addCellItems = [
{
label: 'Markdown',
icon: <FileText className="w-4 h-4" />,
onClick: () => addCell('markdown' as CellType),
},
{
label: 'SPICE',
icon: <Zap className="w-4 h-4" />,
onClick: () => addCell('spice' as CellType),
},
{
label: 'Python',
icon: <Code className="w-4 h-4" />,
onClick: () => addCell('python' as CellType),
},
];
return (
<div className="sticky top-0 z-40 border-b border-slate-700 bg-slate-900/95 backdrop-blur supports-[backdrop-filter]:bg-slate-900/80">
<div className="max-w-5xl mx-auto px-4 py-2 flex items-center gap-3">
{/* Back link */}
<a
href="/"
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
title="Back to notebooks"
>
<ArrowLeft className="w-4 h-4" />
</a>
{/* Editable title */}
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={handleTitleKeyDown}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 w-full max-w-md focus:outline-none focus:border-blue-500"
/>
) : (
<button
onClick={handleTitleClick}
className="text-sm font-semibold text-slate-200 hover:text-slate-100 transition-colors truncate max-w-md block text-left"
title="Click to edit title"
>
{notebook.metadata.title}
</button>
)}
</div>
{/* Engine badge */}
<select
value={notebook.metadata.engine}
onChange={(e) => updateEngine(e.target.value)}
className="bg-slate-800 border border-slate-600 rounded text-xs px-2 py-1 text-slate-300 focus:outline-none focus:border-blue-500 cursor-pointer"
>
<option value="ngspice">ngspice</option>
<option value="ltspice" disabled>
ltspice (soon)
</option>
</select>
{/* Dirty indicator */}
{dirty && (
<Badge variant="amber">unsaved</Badge>
)}
{/* Add Cell dropdown */}
<Dropdown
trigger={
<Button variant="ghost" size="sm">
<Plus className="w-3.5 h-3.5" />
Add Cell
</Button>
}
items={addCellItems}
/>
{/* Run All */}
<Button
variant="ghost"
size="sm"
onClick={runAllCells}
disabled={isRunning}
title="Run all SPICE cells"
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
Run All
</Button>
{/* Save */}
<Button
variant={dirty ? 'primary' : 'ghost'}
size="sm"
onClick={saveNotebook}
disabled={saving || !dirty}
title="Save notebook (Ctrl+S)"
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
type BadgeVariant = 'default' | 'blue' | 'green' | 'amber' | 'red' | 'cyan';
interface BadgeProps {
variant?: BadgeVariant;
children: ReactNode;
className?: string;
}
const variantStyles: Record<BadgeVariant, string> = {
default: 'bg-slate-700/50 text-slate-400 border-slate-600/50',
blue: 'bg-blue-600/15 text-blue-400 border-blue-500/25',
green: 'bg-green-600/15 text-green-400 border-green-500/25',
amber: 'bg-amber-600/15 text-amber-400 border-amber-500/25',
red: 'bg-red-600/15 text-red-400 border-red-500/25',
cyan: 'bg-cyan-600/15 text-cyan-400 border-cyan-500/25',
};
export function Badge({ variant = 'default', children, className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border',
variantStyles[variant],
className,
)}
>
{children}
</span>
);
}

View File

@ -0,0 +1,55 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/cn';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
'bg-blue-600 hover:bg-blue-500 text-white border-transparent',
secondary:
'bg-slate-700 hover:bg-slate-600 text-slate-200 border-slate-600',
ghost:
'bg-transparent hover:bg-slate-700/50 text-slate-400 hover:text-slate-200 border-transparent',
danger:
'bg-red-600/10 hover:bg-red-600/20 text-red-400 hover:text-red-300 border-red-500/20',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1 text-xs gap-1',
md: 'px-3.5 py-1.5 text-sm gap-1.5',
lg: 'px-5 py-2.5 text-sm gap-2',
icon: 'p-1.5',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = 'secondary', size = 'md', className, disabled, children, ...props },
ref,
) => {
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md border font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900',
'disabled:opacity-50 disabled:pointer-events-none',
variantStyles[variant],
sizeStyles[size],
className,
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
},
);
Button.displayName = 'Button';

View File

@ -0,0 +1,86 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { cn } from '../../lib/cn';
interface DropdownItem {
label: string;
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface DropdownProps {
trigger: ReactNode;
items: DropdownItem[];
align?: 'left' | 'right';
}
export function Dropdown({ trigger, items, align = 'left' }: DropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [open]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
if (open) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [open]);
return (
<div ref={containerRef} className="relative inline-block">
<div onClick={() => setOpen(!open)}>{trigger}</div>
{open && (
<div
className={cn(
'absolute z-50 mt-1 min-w-[160px] rounded-lg border border-slate-700 bg-slate-800 py-1 shadow-xl shadow-black/30',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{items.map((item, i) => (
<button
key={i}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors',
item.disabled
? 'text-slate-600 cursor-not-allowed'
: 'text-slate-300 hover:bg-slate-700/50 hover:text-slate-100',
)}
onClick={() => {
if (!item.disabled) {
item.onClick();
setOpen(false);
}
}}
disabled={item.disabled}
>
{item.icon && (
<span className="shrink-0 w-4 h-4">{item.icon}</span>
)}
{item.label}
</button>
))}
</div>
)}
</div>
);
}

9
frontend/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,25 @@
---
interface Props {
title?: string;
}
const { title = 'SpiceBook' } = Astro.props;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SpiceBook — Notebook interface for SPICE circuit simulation" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
<slot />
</body>
</html>
<style is:global>
@import '../styles/globals.css';
</style>

171
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,171 @@
import type {
Notebook,
NotebookSummary,
Cell,
CellType,
SimulationResponse,
CreateNotebookResponse,
} from './types';
const API_BASE = (() => {
// In SSR context, import.meta.env may not have PUBLIC_ vars populated the same way.
// Prefer the PUBLIC_ env var, fall back to localhost for dev.
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_API_URL) {
return import.meta.env.PUBLIC_API_URL;
}
return 'http://localhost:8099';
})();
class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE}${path}`;
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
});
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const body = await res.json();
message = body.detail || body.message || message;
} catch {
// ignore parse failures
}
throw new ApiError(res.status, message);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
// ── Notebook CRUD ──────────────────────────────────────────────
export async function listNotebooks(): Promise<NotebookSummary[]> {
return request<NotebookSummary[]>('/api/notebooks');
}
export async function getNotebook(id: string): Promise<Notebook> {
return request<Notebook>(`/api/notebooks/${encodeURIComponent(id)}`);
}
export async function createNotebook(
title: string,
engine: string = 'ngspice',
): Promise<CreateNotebookResponse> {
return request<CreateNotebookResponse>('/api/notebooks', {
method: 'POST',
body: JSON.stringify({ title, engine }),
});
}
export async function updateNotebook(
id: string,
notebook: Notebook,
): Promise<Notebook> {
return request<Notebook>(`/api/notebooks/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(notebook),
});
}
export async function deleteNotebook(id: string): Promise<void> {
return request<void>(`/api/notebooks/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
// ── Cell Operations ────────────────────────────────────────────
export async function addCell(
notebookId: string,
type: CellType,
index?: number,
): Promise<Cell> {
return request<Cell>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells`,
{
method: 'POST',
body: JSON.stringify({ type, index }),
},
);
}
export async function updateCell(
notebookId: string,
cellId: string,
source: string,
): Promise<Cell> {
return request<Cell>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/${encodeURIComponent(cellId)}`,
{
method: 'PUT',
body: JSON.stringify({ source }),
},
);
}
export async function deleteCell(
notebookId: string,
cellId: string,
): Promise<void> {
return request<void>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/${encodeURIComponent(cellId)}`,
{
method: 'DELETE',
},
);
}
export async function reorderCells(
notebookId: string,
cellIds: string[],
): Promise<void> {
return request<void>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/reorder`,
{
method: 'PUT',
body: JSON.stringify({ cell_ids: cellIds }),
},
);
}
// ── Simulation ─────────────────────────────────────────────────
export async function runSimulation(
netlist: string,
engine: string = 'ngspice',
): Promise<SimulationResponse> {
return request<SimulationResponse>('/api/simulate', {
method: 'POST',
body: JSON.stringify({ netlist, engine }),
});
}
export async function runCell(
notebookId: string,
cellId: string,
): Promise<SimulationResponse> {
return request<SimulationResponse>(
`/api/notebooks/${encodeURIComponent(notebookId)}/cells/${encodeURIComponent(cellId)}/run`,
{
method: 'POST',
},
);
}

6
frontend/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,154 @@
/**
* Minimal markdown-to-HTML renderer for notebook markdown cells.
* Handles the essentials: headings, bold, italic, code, links, lists,
* blockquotes, horizontal rules, and code blocks.
*
* For a production app you would use a real parser (remark/rehype), but
* this keeps the bundle lean and avoids an extra dependency for MVP.
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function renderMarkdown(source: string): string {
const lines = source.split('\n');
const html: string[] = [];
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let inList: 'ul' | 'ol' | null = null;
function closeList() {
if (inList) {
html.push(inList === 'ul' ? '</ul>' : '</ol>');
inList = null;
}
}
function processInline(text: string): string {
let result = escapeHtml(text);
// Code spans (backtick) -- process first to protect inner content
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold + italic
result = result.replace(
/\*\*\*(.+?)\*\*\*/g,
'<strong><em>$1</em></strong>',
);
// Bold
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
result = result.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
result = result.replace(/_(.+?)_/g, '<em>$1</em>');
// Links
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
return result;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Code block fences
if (line.trimStart().startsWith('```')) {
if (inCodeBlock) {
html.push(
`<pre><code>${codeBlockContent.map(escapeHtml).join('\n')}</code></pre>`,
);
codeBlockContent = [];
inCodeBlock = false;
} else {
closeList();
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Empty line
if (line.trim() === '') {
closeList();
continue;
}
// Horizontal rule
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line.trim())) {
closeList();
html.push('<hr>');
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
closeList();
const level = headingMatch[1].length;
html.push(
`<h${level}>${processInline(headingMatch[2])}</h${level}>`,
);
continue;
}
// Blockquote
if (line.startsWith('>')) {
closeList();
const content = line.replace(/^>\s?/, '');
html.push(`<blockquote><p>${processInline(content)}</p></blockquote>`);
continue;
}
// Unordered list
const ulMatch = line.match(/^(\s*)[*\-+]\s+(.+)$/);
if (ulMatch) {
if (inList !== 'ul') {
closeList();
html.push('<ul>');
inList = 'ul';
}
html.push(`<li>${processInline(ulMatch[2])}</li>`);
continue;
}
// Ordered list
const olMatch = line.match(/^(\s*)\d+[.)]\s+(.+)$/);
if (olMatch) {
if (inList !== 'ol') {
closeList();
html.push('<ol>');
inList = 'ol';
}
html.push(`<li>${processInline(olMatch[2])}</li>`);
continue;
}
// Regular paragraph
closeList();
html.push(`<p>${processInline(line)}</p>`);
}
// Close any open code block
if (inCodeBlock) {
html.push(
`<pre><code>${codeBlockContent.map(escapeHtml).join('\n')}</code></pre>`,
);
}
closeList();
return html.join('\n');
}

View File

@ -0,0 +1,314 @@
import { create } from 'zustand';
import type {
Notebook,
Cell,
CellType,
CellOutput,
SimulationResponse,
} from './types';
import * as api from './api';
function generateId(): string {
return crypto.randomUUID().slice(0, 8);
}
function makeCell(type: CellType): Cell {
const templates: Record<CellType, string> = {
markdown: '## New Section\n\nDescribe your circuit here.',
spice: '* SPICE Netlist\n\nR1 in out 1k\nV1 in 0 DC 5\n\n.op\n.end',
python: '# Python analysis\nimport numpy as np\n',
schematic: '',
};
return {
id: generateId(),
type,
source: templates[type],
outputs: [],
};
}
interface NotebookStore {
// State
notebook: Notebook | null;
notebookId: string | null;
activeCell: string | null;
runningCells: Set<string>;
dirty: boolean;
loading: boolean;
error: string | null;
saving: boolean;
// Actions
loadNotebook: (id: string) => Promise<void>;
saveNotebook: () => Promise<void>;
updateCellSource: (cellId: string, source: string) => void;
addCell: (type: CellType, afterId?: string) => void;
deleteCell: (cellId: string) => void;
moveCell: (cellId: string, direction: 'up' | 'down') => void;
setActiveCell: (cellId: string | null) => void;
runCell: (cellId: string) => Promise<void>;
runAllCells: () => Promise<void>;
setCellOutput: (cellId: string, output: CellOutput) => void;
updateTitle: (title: string) => void;
updateEngine: (engine: string) => void;
clearError: () => void;
}
export const useNotebookStore = create<NotebookStore>((set, get) => ({
notebook: null,
notebookId: null,
activeCell: null,
runningCells: new Set(),
dirty: false,
loading: false,
error: null,
saving: false,
loadNotebook: async (id: string) => {
set({ loading: true, error: null });
try {
const notebook = await api.getNotebook(id);
set({
notebook,
notebookId: id,
loading: false,
dirty: false,
activeCell: notebook.cells.length > 0 ? notebook.cells[0].id : null,
});
} catch (err) {
set({
loading: false,
error: err instanceof Error ? err.message : 'Failed to load notebook',
});
}
},
saveNotebook: async () => {
const { notebook, notebookId } = get();
if (!notebook || !notebookId) return;
set({ saving: true });
try {
const updated = await api.updateNotebook(notebookId, notebook);
set({ notebook: updated, dirty: false, saving: false });
} catch (err) {
set({
saving: false,
error: err instanceof Error ? err.message : 'Failed to save notebook',
});
}
},
updateCellSource: (cellId: string, source: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
cells: notebook.cells.map((cell) =>
cell.id === cellId ? { ...cell, source } : cell,
),
},
dirty: true,
});
},
addCell: (type: CellType, afterId?: string) => {
const { notebook } = get();
if (!notebook) return;
const newCell = makeCell(type);
let cells: Cell[];
if (afterId) {
const idx = notebook.cells.findIndex((c) => c.id === afterId);
cells = [
...notebook.cells.slice(0, idx + 1),
newCell,
...notebook.cells.slice(idx + 1),
];
} else {
cells = [...notebook.cells, newCell];
}
set({
notebook: { ...notebook, cells },
activeCell: newCell.id,
dirty: true,
});
},
deleteCell: (cellId: string) => {
const { notebook, activeCell } = get();
if (!notebook) return;
const cells = notebook.cells.filter((c) => c.id !== cellId);
const newActive =
activeCell === cellId
? cells.length > 0
? cells[0].id
: null
: activeCell;
set({
notebook: { ...notebook, cells },
activeCell: newActive,
dirty: true,
});
},
moveCell: (cellId: string, direction: 'up' | 'down') => {
const { notebook } = get();
if (!notebook) return;
const idx = notebook.cells.findIndex((c) => c.id === cellId);
if (idx === -1) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= notebook.cells.length) return;
const cells = [...notebook.cells];
[cells[idx], cells[targetIdx]] = [cells[targetIdx], cells[idx]];
set({
notebook: { ...notebook, cells },
dirty: true,
});
},
setActiveCell: (cellId: string | null) => {
set({ activeCell: cellId });
},
runCell: async (cellId: string) => {
const { notebook, notebookId, runningCells } = get();
if (!notebook || !notebookId) return;
const cell = notebook.cells.find((c) => c.id === cellId);
if (!cell || cell.type !== 'spice') return;
const newRunning = new Set(runningCells);
newRunning.add(cellId);
set({ runningCells: newRunning });
try {
// Try the cell-specific endpoint first, fall back to raw simulation
let result: SimulationResponse;
try {
result = await api.runCell(notebookId, cellId);
} catch {
result = await api.runSimulation(
cell.source,
notebook.metadata.engine,
);
}
const output: CellOutput = {
output_type: result.success ? 'simulation_result' : 'error',
data: {
success: result.success,
waveform: result.waveform || null,
log: result.log,
error: result.error || null,
elapsed_seconds: result.elapsed_seconds,
},
timestamp: new Date().toISOString(),
};
const updatedNotebook = get().notebook;
if (updatedNotebook) {
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
},
});
}
} catch (err) {
const output: CellOutput = {
output_type: 'error',
data: {
success: false,
log: '',
error: err instanceof Error ? err.message : 'Simulation failed',
elapsed_seconds: 0,
},
timestamp: new Date().toISOString(),
};
const updatedNotebook = get().notebook;
if (updatedNotebook) {
set({
notebook: {
...updatedNotebook,
cells: updatedNotebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [output] } : c,
),
},
});
}
} finally {
const currentRunning = new Set(get().runningCells);
currentRunning.delete(cellId);
set({ runningCells: currentRunning });
}
},
runAllCells: async () => {
const { notebook } = get();
if (!notebook) return;
const spiceCells = notebook.cells.filter((c) => c.type === 'spice');
for (const cell of spiceCells) {
await get().runCell(cell.id);
}
},
setCellOutput: (cellId: string, output: CellOutput) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
cells: notebook.cells.map((c) =>
c.id === cellId ? { ...c, outputs: [...c.outputs, output] } : c,
),
},
});
},
updateTitle: (title: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
metadata: { ...notebook.metadata, title },
},
dirty: true,
});
},
updateEngine: (engine: string) => {
const { notebook } = get();
if (!notebook) return;
set({
notebook: {
...notebook,
metadata: { ...notebook.metadata, engine },
},
dirty: true,
});
},
clearError: () => {
set({ error: null });
},
}));

View File

@ -0,0 +1,190 @@
/**
* Custom CodeMirror 6 language support for SPICE netlists.
*
* Highlights dot commands, component instance lines, engineering-notation
* numbers, node names, brace expressions, parameter assignments, and comments.
*/
import { StreamLanguage, type StreamParser } from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { tags as t } from '@lezer/highlight';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
// ── Token types mapped to Lezer highlight tags ────────────────────────
interface SpiceState {
inBrace: boolean;
lineStart: boolean;
seenComponent: boolean;
}
// Engineering suffixes that can follow a number
const ENG_SUFFIX =
/^(meg|mil|[tTgGkKmMuUnNpPfF])((?=[^a-zA-Z0-9_])|$)/;
const DOT_COMMANDS = new Set([
'tran',
'ac',
'dc',
'op',
'model',
'param',
'include',
'lib',
'subckt',
'ends',
'meas',
'measure',
'step',
'ic',
'nodeset',
'options',
'func',
'function',
'global',
'end',
'four',
'noise',
'tf',
'sens',
'pz',
'temp',
'save',
'print',
'plot',
'probe',
'backanno',
'control',
'endc',
'alter',
'width',
]);
const COMPONENT_PREFIXES = 'RCLVIMQDJXEFGHKSTW';
const spiceParser: StreamParser<SpiceState> = {
name: 'spice',
startState(): SpiceState {
return { inBrace: false, lineStart: true, seenComponent: false };
},
token(stream, state): string | null {
// Start of line
if (stream.sol()) {
state.lineStart = true;
state.seenComponent = false;
}
// Inside a brace expression
if (state.inBrace) {
if (stream.eat('}' as unknown as RegExp)) {
state.inBrace = false;
return 'string';
}
stream.next();
return 'string';
}
// Whitespace
if (stream.eatSpace()) {
state.lineStart = false;
return null;
}
// Full-line comment: line starts with * or $
if (state.lineStart && (stream.peek() === '*' || stream.peek() === '$')) {
stream.skipToEnd();
state.lineStart = false;
return 'comment';
}
// Inline comment: semicolon to end of line
if (stream.peek() === ';') {
stream.skipToEnd();
return 'comment';
}
state.lineStart = false;
// Brace expression
if (stream.eat('{' as unknown as RegExp)) {
state.inBrace = true;
return 'string';
}
// Dot command at start of line (we check sol was recently true)
if (stream.sol() || (!state.seenComponent && stream.column() === 0)) {
// Already handled above via lineStart
}
if (stream.match(/^\.[a-zA-Z]+/)) {
const word = stream.current().slice(1).toLowerCase();
if (DOT_COMMANDS.has(word)) {
return 'keyword';
}
return 'keyword';
}
// Component instance name at start of line
if (
stream.column() === 0 &&
stream.peek() &&
COMPONENT_PREFIXES.includes(stream.peek()!.toUpperCase())
) {
stream.match(/^[a-zA-Z][a-zA-Z0-9_:]*/);
state.seenComponent = true;
return 'typeName';
}
// Numbers with optional engineering suffix
if (stream.match(/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?/)) {
// Try to eat an engineering suffix
stream.match(ENG_SUFFIX);
return 'number';
}
// Parameter assignment: name=value
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*(?==)/)) {
return 'propertyName';
}
// Equals sign (after param name)
if (stream.eat('=' as unknown as RegExp)) {
return 'operator';
}
// Operators
if (stream.match(/^[+\-*/()]/)) {
return 'operator';
}
// General identifier (node name, model name, etc.)
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_:.]*/)) {
return 'variableName';
}
// Fallback: advance one character
stream.next();
return null;
},
};
export const spiceLanguage = StreamLanguage.define(spiceParser);
// ── Syntax highlighting theme (dark, matches SpiceBook) ───────────────
export const spiceHighlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: '#60a5fa' }, // blue-400 (dot commands)
{ tag: t.typeName, color: '#34d399', fontWeight: '600' }, // emerald-400 (component names)
{ tag: t.number, color: '#fbbf24' }, // amber-400
{ tag: t.string, color: '#fb923c' }, // orange-400 (brace expressions)
{ tag: t.comment, color: '#6b7280', fontStyle: 'italic' }, // gray-500
{ tag: t.variableName, color: '#e2e8f0' }, // slate-200 (node names)
{ tag: t.propertyName, color: '#a78bfa' }, // violet-400 (param names) — small accent OK
{ tag: t.operator, color: '#94a3b8' }, // slate-400
]);
export function spiceSupport(): Extension[] {
return [spiceLanguage, syntaxHighlighting(spiceHighlightStyle)];
}

65
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,65 @@
export type CellType = 'markdown' | 'spice' | 'python' | 'schematic';
export interface CellOutput {
output_type: string;
data: Record<string, unknown>;
timestamp?: string;
}
export interface Cell {
id: string;
type: CellType;
source: string;
outputs: CellOutput[];
}
export interface NotebookMetadata {
title: string;
engine: string;
tags: string[];
created: string;
modified: string;
}
export interface Notebook {
spicebook_version: string;
metadata: NotebookMetadata;
cells: Cell[];
}
export interface NotebookSummary {
id: string;
title: string;
engine: string;
tags: string[];
cell_count: number;
modified: string;
}
export interface WaveformVariable {
name: string;
type: string;
}
export interface WaveformData {
variables: WaveformVariable[];
points: number;
x_data: number[];
y_data: Record<string, number[]>;
x_type: string;
is_complex: boolean;
y_magnitude_db?: Record<string, number[]>;
y_phase_deg?: Record<string, number[]>;
}
export interface SimulationResponse {
success: boolean;
waveform?: WaveformData;
log: string;
error?: string;
elapsed_seconds: number;
}
export interface CreateNotebookResponse extends Notebook {
id: string;
}

View File

@ -0,0 +1,104 @@
/**
* Engineering notation formatter for SPICE waveform data.
* Ported from mcltspice's _format_eng approach.
*/
const SI_PREFIXES: [number, string][] = [
[1e15, 'P'],
[1e12, 'T'],
[1e9, 'G'],
[1e6, 'M'],
[1e3, 'k'],
[1, ''],
[1e-3, 'm'],
[1e-6, '\u00B5'], // micro sign
[1e-9, 'n'],
[1e-12, 'p'],
[1e-15, 'f'],
];
export function formatEng(value: number, unit?: string): string {
if (value === 0) return `0${unit ? ' ' + unit : ''}`;
if (!isFinite(value)) return value > 0 ? '+Inf' : '-Inf';
const absVal = Math.abs(value);
const suffix = unit ? ' ' + unit : '';
for (const [threshold, prefix] of SI_PREFIXES) {
if (absVal >= threshold * 0.9999) {
const scaled = value / threshold;
// Use 3 significant digits for display
const formatted =
Math.abs(scaled) >= 100
? scaled.toFixed(0)
: Math.abs(scaled) >= 10
? scaled.toFixed(1)
: scaled.toFixed(2);
return `${formatted}${prefix}${suffix}`;
}
}
// Below femto range -- use exponential
return `${value.toExponential(2)}${suffix}`;
}
export function formatFreq(hz: number): string {
return formatEng(hz, 'Hz');
}
export function formatTime(seconds: number): string {
return formatEng(seconds, 's');
}
export function formatVoltage(volts: number): string {
return formatEng(volts, 'V');
}
export function formatCurrent(amps: number): string {
return formatEng(amps, 'A');
}
/**
* Format a value for an axis label based on signal type.
*/
export function formatAxisValue(
value: number,
axisType: string,
): string {
switch (axisType) {
case 'frequency':
return formatFreq(value);
case 'time':
return formatTime(value);
case 'voltage':
return formatVoltage(value);
case 'current':
return formatCurrent(value);
case 'dB':
return `${value.toFixed(1)} dB`;
case 'degrees':
case 'phase':
return `${value.toFixed(0)}\u00B0`;
default:
return formatEng(value);
}
}
/** Trace color palette -- no purple. */
export const TRACE_COLORS = [
'#2563eb', // blue
'#dc2626', // red
'#16a34a', // green
'#ca8a04', // amber
'#0891b2', // cyan
'#e11d48', // rose
'#4b5563', // slate
'#ea580c', // orange
];
/**
* Pick a color from the trace palette by index (wraps around).
*/
export function traceColor(index: number): string {
return TRACE_COLORS[index % TRACE_COLORS.length];
}

View File

@ -0,0 +1,28 @@
---
import NotebookLayout from '../layouts/NotebookLayout.astro';
import NotebookList from '../components/NotebookList';
---
<NotebookLayout title="SpiceBook">
<div class="max-w-5xl mx-auto px-6 py-10">
<!-- Header (static) -->
<header class="mb-10">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-slate-100 tracking-tight">SpiceBook</h1>
<p class="text-slate-400 mt-1">Notebook interface for SPICE circuit simulation</p>
</div>
<a
href="/notebook/new"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
New Notebook
</a>
</div>
</header>
<!-- Dynamic notebook list (client-side fetch) -->
<NotebookList client:load />
</div>
</NotebookLayout>

View File

@ -0,0 +1,16 @@
---
/**
* Notebook editor page.
*
* The notebook ID is passed via the ?id= query parameter.
* Example: /notebook/?id=my-circuit
*
* The React island reads the ID from the URL and loads the notebook.
*/
import NotebookLayout from '../../layouts/NotebookLayout.astro';
import NotebookEditorPage from '../../components/notebook/NotebookEditorPage';
---
<NotebookLayout title="SpiceBook">
<NotebookEditorPage client:load />
</NotebookLayout>

View File

@ -0,0 +1,11 @@
---
/**
* Creates a new notebook via the API (client-side) and redirects to its editor.
*/
import NotebookLayout from '../../layouts/NotebookLayout.astro';
import NewNotebookRedirect from '../../components/notebook/NewNotebookRedirect';
---
<NotebookLayout title="New Notebook - SpiceBook">
<NewNotebookRedirect client:load />
</NotebookLayout>

View File

@ -0,0 +1,248 @@
@import "tailwindcss";
@theme {
/* SpiceBook dark theme — engineering tool aesthetics */
--color-sb-bg: #020617; /* slate-950 */
--color-sb-surface: #0f172a; /* slate-900 */
--color-sb-cell: #1e293b; /* slate-800 */
--color-sb-border: #334155; /* slate-700 */
--color-sb-border-active: #2563eb;/* blue-600 */
--color-sb-muted: #64748b; /* slate-500 */
--color-sb-text: #e2e8f0; /* slate-200 */
--color-sb-text-bright: #f1f5f9; /* slate-100 */
--color-sb-accent: #2563eb; /* blue-600 */
--color-sb-accent-hover: #3b82f6; /* blue-500 */
--color-sb-danger: #dc2626; /* red-600 */
--color-sb-warning: #ca8a04; /* amber-600 */
--color-sb-success: #16a34a; /* green-600 */
--color-sb-code-bg: #0f172a; /* slate-900 */
/* Trace colors for waveform plots */
--color-trace-1: #2563eb;
--color-trace-2: #dc2626;
--color-trace-3: #16a34a;
--color-trace-4: #ca8a04;
--color-trace-5: #0891b2;
--color-trace-6: #e11d48;
--color-trace-7: #4b5563;
--color-trace-8: #ea580c;
/* Fonts */
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Base resets */
html {
background-color: var(--color-sb-bg);
color: var(--color-sb-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
margin: 0;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-sb-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-sb-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-sb-muted);
}
/* CodeMirror theme overrides */
.cm-editor {
background-color: var(--color-sb-code-bg) !important;
font-family: var(--font-mono) !important;
font-size: 14px;
}
.cm-editor .cm-gutters {
background-color: var(--color-sb-code-bg) !important;
border-right: 1px solid var(--color-sb-border) !important;
color: var(--color-sb-muted) !important;
}
.cm-editor .cm-content {
caret-color: var(--color-sb-accent) !important;
}
.cm-editor .cm-cursor {
border-left-color: var(--color-sb-accent) !important;
}
.cm-editor .cm-activeLine {
background-color: rgba(30, 41, 59, 0.5) !important;
}
.cm-editor .cm-activeLineGutter {
background-color: rgba(30, 41, 59, 0.5) !important;
}
.cm-editor .cm-selectionBackground,
.cm-editor .cm-content ::selection {
background-color: rgba(37, 99, 235, 0.25) !important;
}
.cm-editor .cm-matchingBracket {
background-color: rgba(37, 99, 235, 0.3) !important;
color: var(--color-sb-text-bright) !important;
}
.cm-editor.cm-focused {
outline: none !important;
}
/* uPlot overrides */
.uplot {
font-family: var(--font-sans) !important;
}
.uplot .u-legend {
font-size: 12px !important;
color: var(--color-sb-muted) !important;
}
.uplot .u-legend .u-marker {
width: 12px !important;
height: 3px !important;
}
.uplot .u-legend .u-value {
color: var(--color-sb-text) !important;
}
/* Markdown rendered output */
.markdown-output {
line-height: 1.7;
}
.markdown-output h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.75rem;
color: var(--color-sb-text-bright);
}
.markdown-output h2 {
font-size: 1.375rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--color-sb-text-bright);
}
.markdown-output h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--color-sb-text-bright);
}
.markdown-output p {
margin-bottom: 0.75rem;
}
.markdown-output strong {
font-weight: 600;
color: var(--color-sb-text-bright);
}
.markdown-output em {
font-style: italic;
}
.markdown-output code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-sb-code-bg);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: #93c5fd; /* blue-300 */
}
.markdown-output pre {
background: var(--color-sb-code-bg);
border: 1px solid var(--color-sb-border);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
overflow-x: auto;
}
.markdown-output pre code {
background: transparent;
padding: 0;
}
.markdown-output a {
color: var(--color-sb-accent-hover);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-output a:hover {
color: #60a5fa; /* blue-400 */
}
.markdown-output ul, .markdown-output ol {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.markdown-output ul {
list-style-type: disc;
}
.markdown-output ol {
list-style-type: decimal;
}
.markdown-output li {
margin-bottom: 0.25rem;
}
.markdown-output blockquote {
border-left: 3px solid var(--color-sb-accent);
padding-left: 1rem;
margin-left: 0;
margin-bottom: 0.75rem;
color: var(--color-sb-muted);
}
.markdown-output hr {
border: none;
border-top: 1px solid var(--color-sb-border);
margin: 1rem 0;
}
.markdown-output table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.75rem;
}
.markdown-output th, .markdown-output td {
border: 1px solid var(--color-sb-border);
padding: 0.375rem 0.75rem;
text-align: left;
}
.markdown-output th {
background: var(--color-sb-surface);
font-weight: 600;
}

14
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/lib/*": ["src/lib/*"],
"@/styles/*": ["src/styles/*"]
}
}
}

View File

@ -0,0 +1,36 @@
{
"spicebook_version": "2026-02-13",
"metadata": {
"title": "Common Emitter Amplifier",
"engine": "ngspice",
"tags": ["amplifier", "bjt", "intermediate"],
"created": "2026-02-13T00:00:00Z",
"modified": "2026-02-13T00:00:00Z"
},
"cells": [
{
"id": "cell-intro",
"type": "markdown",
"source": "# Common Emitter Amplifier\n\nA single-stage BJT amplifier with voltage divider biasing. This is one of the most common amplifier configurations.\n\nKey parameters:\n- **Voltage gain**: approximately -R_C / r_e (inverting)\n- **Input impedance**: R1 || R2 || (beta * r_e)\n- **Bandwidth**: determined by coupling and bypass capacitors",
"outputs": []
},
{
"id": "cell-ac",
"type": "spice",
"source": "Common Emitter Amplifier - Frequency Response\nVCC vcc 0 DC 12\nV1 in 0 AC 1 SIN(0 10m 1k)\n* Bias network\nR1 vcc base 47k\nR2 base 0 10k\n* Transistor (2N2222 model)\nQ1 col base emit QNPN\n.model QNPN NPN(BF=200 IS=1e-14 VAF=100)\n* Collector and emitter resistors\nRC col vcc 4.7k\nRE emit 0 1k\n* Coupling capacitors\nC1 in base 10u\nC2 col out 10u\n* Emitter bypass capacitor\nCE emit 0 100u\n* Load\nRL out 0 10k\n.ac dec 50 10 10meg\n.end",
"outputs": []
},
{
"id": "cell-explain",
"type": "markdown",
"source": "## Frequency Response Analysis\n\nThe Bode plot reveals the amplifier's bandwidth. Look for:\n\n1. **Low-frequency cutoff**: Determined by coupling capacitors C1, C2 and bypass capacitor CE\n2. **Mid-band gain**: The flat region where gain is approximately -R_C / r_e\n3. **High-frequency rolloff**: Due to transistor parasitic capacitances (not modeled here)",
"outputs": []
},
{
"id": "cell-tran",
"type": "spice",
"source": "Common Emitter Amplifier - Transient Response\nVCC vcc 0 DC 12\nV1 in 0 SIN(0 10m 1k)\n* Bias network\nR1 vcc base 47k\nR2 base 0 10k\n* Transistor\nQ1 col base emit QNPN\n.model QNPN NPN(BF=200 IS=1e-14 VAF=100)\n* Collector and emitter resistors\nRC col vcc 4.7k\nRE emit 0 1k\n* Coupling caps\nC1 in base 10u\nC2 col out 10u\nCE emit 0 100u\nRL out 0 10k\n.tran 10u 5m\n.end",
"outputs": []
}
]
}

View File

@ -0,0 +1,42 @@
{
"spicebook_version": "2026-02-13",
"metadata": {
"title": "RC Lowpass Filter Analysis",
"engine": "ngspice",
"tags": ["filter", "passive", "beginner"],
"created": "2026-02-13T00:00:00Z",
"modified": "2026-02-13T00:00:00Z"
},
"cells": [
{
"id": "cell-intro",
"type": "markdown",
"source": "# RC Lowpass Filter\n\nA first-order passive RC lowpass filter with a cutoff frequency of approximately **1.59 kHz**.\n\nThe -3dB frequency is: f_c = 1 / (2 * pi * R * C)\n\nWith R = 1k and C = 100nF: f_c = 1 / (2 * pi * 1000 * 100e-9) = 1591 Hz",
"outputs": []
},
{
"id": "cell-ac",
"type": "spice",
"source": "RC Lowpass Filter - AC Analysis\nV1 in 0 AC 1\nR1 in out 1k\nC1 out 0 100n\n.ac dec 100 1 10meg\n.end",
"outputs": []
},
{
"id": "cell-explain-ac",
"type": "markdown",
"source": "## Interpreting the Bode Plot\n\nThe magnitude plot should show:\n- **Flat response** (0 dB) below the cutoff frequency\n- **-3 dB point** at approximately 1.59 kHz\n- **-20 dB/decade rolloff** above the cutoff\n\nThe phase should transition from 0 degrees to -90 degrees, passing through -45 degrees at the cutoff frequency.",
"outputs": []
},
{
"id": "cell-transient",
"type": "spice",
"source": "RC Lowpass Filter - Step Response\nV1 in 0 PULSE(0 1 0 1n 1n 0.5m 1m)\nR1 in out 1k\nC1 out 0 100n\n.tran 10u 3m\n.end",
"outputs": []
},
{
"id": "cell-explain-transient",
"type": "markdown",
"source": "## Step Response\n\nThe transient simulation shows the capacitor charging and discharging through the resistor.\n\nThe time constant is: tau = R * C = 1000 * 100e-9 = 100 us\n\nThe output reaches ~63% of the input voltage after one time constant, and ~95% after three time constants (300 us).",
"outputs": []
}
]
}

View File

@ -0,0 +1,36 @@
{
"spicebook_version": "2026-02-13",
"metadata": {
"title": "Voltage Divider",
"engine": "ngspice",
"tags": ["passive", "beginner", "dc"],
"created": "2026-02-13T00:00:00Z",
"modified": "2026-02-13T00:00:00Z"
},
"cells": [
{
"id": "cell-intro",
"type": "markdown",
"source": "# Voltage Divider\n\nThe simplest and most fundamental circuit: two resistors dividing an input voltage.\n\nV_out = V_in * R2 / (R1 + R2)\n\nWith R1 = 10k and R2 = 10k: V_out = 5V * 10k / (10k + 10k) = 2.5V",
"outputs": []
},
{
"id": "cell-op",
"type": "spice",
"source": "Voltage Divider - DC Operating Point\nV1 in 0 DC 5\nR1 in out 10k\nR2 out 0 10k\n.op\n.end",
"outputs": []
},
{
"id": "cell-sweep",
"type": "spice",
"source": "Voltage Divider - DC Sweep\nV1 in 0 DC 5\nR1 in out 10k\nR2 out 0 10k\n.dc V1 0 10 0.1\n.end",
"outputs": []
},
{
"id": "cell-explain",
"type": "markdown",
"source": "## DC Sweep\n\nThe DC sweep shows how V(out) varies linearly with V(in). Since R1 = R2, the output is always exactly half of the input. The slope of the line is the voltage division ratio: 0.5.",
"outputs": []
}
]
}