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:
commit
8abd7719bf
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
48
Makefile
Normal 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
54
backend/Dockerfile
Normal 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
44
backend/pyproject.toml
Normal 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"]
|
||||
3
backend/src/spicebook/__init__.py
Normal file
3
backend/src/spicebook/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""SpiceBook - Notebook interface for SPICE circuit simulation."""
|
||||
|
||||
__version__ = "2026.02.13"
|
||||
29
backend/src/spicebook/config.py
Normal file
29
backend/src/spicebook/config.py
Normal 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()
|
||||
0
backend/src/spicebook/engine/__init__.py
Normal file
0
backend/src/spicebook/engine/__init__.py
Normal file
22
backend/src/spicebook/engine/base.py
Normal file
22
backend/src/spicebook/engine/base.py
Normal 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.
|
||||
"""
|
||||
381
backend/src/spicebook/engine/ngspice.py
Normal file
381
backend/src/spicebook/engine/ngspice.py
Normal 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,
|
||||
)
|
||||
97
backend/src/spicebook/main.py
Normal file
97
backend/src/spicebook/main.py
Normal 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,
|
||||
)
|
||||
0
backend/src/spicebook/models/__init__.py
Normal file
0
backend/src/spicebook/models/__init__.py
Normal file
69
backend/src/spicebook/models/notebook.py
Normal file
69
backend/src/spicebook/models/notebook.py
Normal 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
|
||||
45
backend/src/spicebook/models/simulation.py
Normal file
45
backend/src/spicebook/models/simulation.py
Normal 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
|
||||
0
backend/src/spicebook/routers/__init__.py
Normal file
0
backend/src/spicebook/routers/__init__.py
Normal file
152
backend/src/spicebook/routers/notebooks.py
Normal file
152
backend/src/spicebook/routers/notebooks.py
Normal 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")
|
||||
86
backend/src/spicebook/routers/simulation.py
Normal file
86
backend/src/spicebook/routers/simulation.py
Normal 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
|
||||
337
backend/src/spicebook/routers/waveforms.py
Normal file
337
backend/src/spicebook/routers/waveforms.py
Normal 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)
|
||||
0
backend/src/spicebook/storage/__init__.py
Normal file
0
backend/src/spicebook/storage/__init__.py
Normal file
147
backend/src/spicebook/storage/filesystem.py
Normal file
147
backend/src/spicebook/storage/filesystem.py
Normal 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)
|
||||
150
backend/tests/test_ngspice.py
Normal file
150
backend/tests/test_ngspice.py
Normal 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
686
backend/uv.lock
generated
Normal 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
26
docker-compose.dev.yml
Normal 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
29
docker-compose.prod.yml
Normal 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
20
docker-compose.yml
Normal 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
48
frontend/Dockerfile
Normal 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
26
frontend/astro.config.mjs
Normal 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
8457
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/public/favicon.svg
Normal file
7
frontend/public/favicon.svg
Normal 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 |
167
frontend/src/components/NotebookList.tsx
Normal file
167
frontend/src/components/NotebookList.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/notebook/CellList.tsx
Normal file
100
frontend/src/components/notebook/CellList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/notebook/NewNotebookRedirect.tsx
Normal file
79
frontend/src/components/notebook/NewNotebookRedirect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/notebook/NotebookEditor.tsx
Normal file
103
frontend/src/components/notebook/NotebookEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/notebook/NotebookEditorPage.tsx
Normal file
39
frontend/src/components/notebook/NotebookEditorPage.tsx
Normal 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} />;
|
||||
}
|
||||
88
frontend/src/components/notebook/cells/MarkdownCell.tsx
Normal file
88
frontend/src/components/notebook/cells/MarkdownCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/notebook/cells/PythonCell.tsx
Normal file
79
frontend/src/components/notebook/cells/PythonCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/notebook/cells/SchematicCell.tsx
Normal file
67
frontend/src/components/notebook/cells/SchematicCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/notebook/cells/SpiceCell.tsx
Normal file
123
frontend/src/components/notebook/cells/SpiceCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/notebook/editor/MarkdownEditor.tsx
Normal file
138
frontend/src/components/notebook/editor/MarkdownEditor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/notebook/editor/PythonEditor.tsx
Normal file
119
frontend/src/components/notebook/editor/PythonEditor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/notebook/editor/SpiceEditor.tsx
Normal file
143
frontend/src/components/notebook/editor/SpiceEditor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/notebook/output/SimulationLog.tsx
Normal file
75
frontend/src/components/notebook/output/SimulationLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/notebook/output/TextOutput.tsx
Normal file
16
frontend/src/components/notebook/output/TextOutput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
frontend/src/components/notebook/output/WaveformViewer.tsx
Normal file
236
frontend/src/components/notebook/output/WaveformViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/notebook/toolbar/CellToolbar.tsx
Normal file
133
frontend/src/components/notebook/toolbar/CellToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
frontend/src/components/notebook/toolbar/NotebookToolbar.tsx
Normal file
182
frontend/src/components/notebook/toolbar/NotebookToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/ui/Badge.tsx
Normal file
33
frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ui/Button.tsx
Normal file
55
frontend/src/components/ui/Button.tsx
Normal 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';
|
||||
86
frontend/src/components/ui/Dropdown.tsx
Normal file
86
frontend/src/components/ui/Dropdown.tsx
Normal 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
9
frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
25
frontend/src/layouts/NotebookLayout.astro
Normal file
25
frontend/src/layouts/NotebookLayout.astro
Normal 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
171
frontend/src/lib/api.ts
Normal 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
6
frontend/src/lib/cn.ts
Normal 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));
|
||||
}
|
||||
154
frontend/src/lib/markdown.ts
Normal file
154
frontend/src/lib/markdown.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
314
frontend/src/lib/notebook-store.ts
Normal file
314
frontend/src/lib/notebook-store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
190
frontend/src/lib/spice-language.ts
Normal file
190
frontend/src/lib/spice-language.ts
Normal 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
65
frontend/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
104
frontend/src/lib/waveform-utils.ts
Normal file
104
frontend/src/lib/waveform-utils.ts
Normal 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];
|
||||
}
|
||||
28
frontend/src/pages/index.astro
Normal file
28
frontend/src/pages/index.astro
Normal 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>
|
||||
16
frontend/src/pages/notebook/index.astro
Normal file
16
frontend/src/pages/notebook/index.astro
Normal 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>
|
||||
11
frontend/src/pages/notebook/new.astro
Normal file
11
frontend/src/pages/notebook/new.astro
Normal 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>
|
||||
248
frontend/src/styles/globals.css
Normal file
248
frontend/src/styles/globals.css
Normal 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
14
frontend/tsconfig.json
Normal 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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
notebooks/examples/common-emitter-amplifier.spicebook
Normal file
36
notebooks/examples/common-emitter-amplifier.spicebook
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
42
notebooks/examples/rc-lowpass-filter.spicebook
Normal file
42
notebooks/examples/rc-lowpass-filter.spicebook
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
36
notebooks/examples/voltage-divider.spicebook
Normal file
36
notebooks/examples/voltage-divider.spicebook
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user