Accept a KiCad project drop so hierarchical sheets and the board upload in one step.

A single .kicad_sch was parsed in isolation, so child sheets looked like an empty netlist. Report 404s now surface the real API error instead of a generic fetch failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 07:26:27 +02:00
co-authored by Cursor
parent b3a47f0bec
commit a56dda2358
18 changed files with 965 additions and 116 deletions
+233
View File
@@ -0,0 +1,233 @@
"""Unpack a netlist upload: one file, several KiCad sheets, or a zip.
The hierarchical ``.kicad_sch`` parser needs sibling files on disk. A single
temp file named ``tmpXXXX.kicad_sch`` cannot see ``Sheetfile`` children.
"""
from __future__ import annotations
import io
import zipfile
from dataclasses import dataclass
from pathlib import Path
from backend.pinscopex.parsers import detect_netlist_format
MAX_BUNDLE_BYTES = 30 * 1024 * 1024
_MAX_ZIP_MEMBERS = 400
_KIND = str # pads | edif | kicad_* | zip | kicad_pcb | unknown
@dataclass
class NetlistUpload:
root: Path
work_dir: Path
pcb: Path | None
extra_sch: list[Path]
def sniff_netlist_kind(content: bytes) -> str:
if content[:2] == b"PK":
return "zip"
head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
low = head[:40].lower()
if low.startswith("(kicad_pcb"):
return "kicad_pcb"
if low.startswith("(kicad_sch"):
return "kicad_sch"
if low.startswith("(edif"):
return "edif"
if low.startswith("(export"):
return "kicad_sexp"
if low.startswith("<?xml") or low.startswith("<export"):
return "kicad_xml"
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
return "pads"
return "unknown"
def _safe_rel(name: str) -> str:
rel = name.replace("\\", "/").strip()
if not rel or rel.startswith("/") or rel.startswith("\\"):
raise ValueError(f"Rejected path: {name}")
parts = Path(rel).parts
if ".." in parts or (parts and parts[0] == ".."):
raise ValueError(f"Rejected path: {name}")
return rel
_KEEP_SUFFIX = {
".kicad_sch",
".kicad_pcb",
".kicad_pro",
".kicad_net",
".xml",
".edn",
".edif",
".edf",
".asc",
".net",
}
def _keep_zip_member(rel: str) -> bool:
parts = Path(rel).parts
if any(
p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"}
for p in parts
):
return False
return Path(rel).suffix.lower() in _KEEP_SUFFIX
def _extract_zip(data: bytes, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
total = 0
kept = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
rel = _safe_rel(info.filename)
if not _keep_zip_member(rel):
continue
kept += 1
if kept > _MAX_ZIP_MEMBERS:
raise ValueError("Zip has too many schematic files")
total += max(info.file_size, 0)
if total > MAX_BUNDLE_BYTES:
raise ValueError("Zip is too large")
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src:
payload = src.read()
if len(payload) > MAX_BUNDLE_BYTES:
raise ValueError("Zip member is too large")
out.write_bytes(payload)
def _write_named(name: str, data: bytes, dest: Path) -> None:
rel = _safe_rel(name)
kind = sniff_netlist_kind(data)
if kind == "zip":
_extract_zip(data, dest)
return
out = dest / Path(rel).name
# Keep a single subdirectory when the client sent webkitRelativePath.
if "/" in rel:
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
else:
dest.mkdir(parents=True, exist_ok=True)
out.write_bytes(data)
def find_kicad_pcb(work: Path) -> Path | None:
hits = sorted(p for p in work.rglob("*.kicad_pcb") if p.is_file())
return hits[0] if hits else None
def _sheetfiles_of(path: Path) -> list[str]:
from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
if _tag(tree) != "kicad_sch":
return []
return _sheetfiles(tree)
def _pick_root(work: Path) -> Path:
schs: list[Path] = []
exported: list[Path] = []
for p in work.rglob("*"):
if not p.is_file():
continue
kind = sniff_netlist_kind(p.read_bytes()[:2048])
if kind == "kicad_sch":
schs.append(p)
elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
exported.append(p)
if exported:
pref = [
p for p in exported
if sniff_netlist_kind(p.read_bytes()[:2048]) in (
"kicad_xml", "kicad_sexp", "edif",
)
]
return (pref or exported)[0]
if not schs:
raise ValueError(
"No schematic found. Drop the KiCad project folder or a netlist."
)
referenced: set[Path] = set()
for p in schs:
for rel in _sheetfiles_of(p):
try:
child = (p.parent / rel.replace("\\", "/")).resolve()
except ValueError:
continue
referenced.add(child)
roots = [p for p in schs if p.resolve() not in referenced]
if not roots:
raise ValueError("Cyclic sheet includes — upload an exported KiCad netlist instead.")
pro = list(work.rglob("*.kicad_pro"))
if len(roots) > 1 and pro:
stems = {p.stem for p in pro}
matched = [r for r in roots if r.stem in stems]
if len(matched) == 1:
return matched[0]
if len(roots) > 1:
names = ", ".join(sorted(r.name for r in roots))
raise ValueError(
f"Multiple root sheets ({names}). Upload a zip of the project, "
"or the top-level .kicad_sch together with every Sheetfile child."
)
return roots[0]
def materialize_netlist_upload(
files: list[tuple[str, bytes]],
dest: Path,
) -> NetlistUpload:
"""Write uploaded bytes into ``dest`` and return the file to parse."""
if not files:
raise ValueError("No netlist file uploaded")
dest.mkdir(parents=True, exist_ok=True)
total = sum(len(b) for _n, b in files)
if total > MAX_BUNDLE_BYTES:
raise ValueError("Upload is too large")
if len(files) == 1:
name, data = files[0]
kind = sniff_netlist_kind(data)
if kind == "kicad_pcb":
raise ValueError(
"This is a board file. Drop the KiCad project folder, or put "
"the .kicad_pcb on the optional board step."
)
if kind == "unknown" and not name.lower().endswith(".zip"):
raise ValueError(
"Not a netlist. Drop the KiCad project folder, a zip, or a "
"PADS / EDIF / KiCad netlist."
)
for name, data in files:
_write_named(name, data, dest)
root = _pick_root(dest)
pcb = find_kicad_pcb(dest)
extras = [
p for p in work_sch_files(dest)
if p.resolve() != root.resolve()
]
return NetlistUpload(root=root, work_dir=dest, pcb=pcb, extra_sch=extras)
def work_sch_files(dest: Path) -> list[Path]:
return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file())
+4 -1
View File
@@ -556,7 +556,10 @@ def parse_kicad_sch_project(
if path in seen:
raise ValueError(f"Cyclic sheet include: {path.name}")
if not path.is_file():
raise ValueError(f"Missing sheet file: {path.name}")
raise ValueError(
f"Missing sheet file: {path.name}. Drop the whole KiCad "
"project folder, not a single sheet."
)
seen.add(path)
text = path.read_text(encoding="utf-8", errors="replace")
tree = _parse_sexp(text)
+67 -35
View File
@@ -1,7 +1,10 @@
"""Project CRUD and file upload endpoints."""
import json
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
@@ -462,51 +465,80 @@ async def upload_bom(
@router.post("/projects/{project_id}/upload/netlist")
async def upload_netlist(project_id: str, file: UploadFile, request: Request):
async def upload_netlist(
project_id: str,
request: Request,
file: UploadFile | None = File(default=None),
files: list[UploadFile] | None = File(default=None),
paths: str | None = Form(default=None),
):
storage = get_storage(request)
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
if not result:
raise HTTPException(404, "Project not found")
user_id = result[0] # owner_user_id for storage paths
data = await file.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
# Auto-detect PADS vs EDIF from the file's first bytes — users don't pick
# a format, the wizard accepts either.
from backend.pinscopex.parsers import (
detect_netlist_format, parse_netlist_any, validate_netlist,
)
from backend.pinscopex.netlist_bundle import materialize_netlist_upload
from backend.pinscopex.parsers import parse_netlist_any, validate_netlist
from backend.pinscopex.parsers_edif import list_edif_subdesigns
import tempfile, os
import tempfile
blobs: list[tuple[str, bytes]] = []
seen: set[tuple[str, int]] = set()
uploads = list(files or []) if files else ([file] if file is not None else [])
rels: list[str] | None = None
if paths:
try:
parsed_paths = json.loads(paths)
except json.JSONDecodeError:
parsed_paths = None
if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths):
rels = parsed_paths
for i, uf in enumerate(uploads):
data = await uf.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(
413,
f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
)
name = (
rels[i]
if rels is not None and i < len(rels)
else (uf.filename or "netlist")
)
mark = (name, len(data))
if mark in seen:
continue
seen.add(mark)
blobs.append((name, data))
if not blobs:
raise HTTPException(400, "No netlist file uploaded")
fmt = detect_netlist_format(data)
suffix = {
"edif": ".edn",
"kicad_xml": ".xml",
"kicad_sexp": ".kicad_net",
"kicad_sch": ".kicad_sch",
}.get(fmt, ".asc")
sub_designs: list[dict] = []
try:
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(data)
tmp.close()
parts, nets, _ = parse_netlist_any(tmp.name)
# For EDIF, also surface the sub-design layout so the wizard can
# decide whether to prompt the user. Cheap second parse — same file.
if fmt == "edif":
sub_designs = list_edif_subdesigns(tmp.name)
os.unlink(tmp.name)
with tempfile.TemporaryDirectory() as tmp:
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
parts, nets, fmt = parse_netlist_any(parsed.root)
if fmt == "edif":
sub_designs = list_edif_subdesigns(parsed.root)
issues = validate_netlist(parts, nets)
if issues:
raise ValueError("; ".join(issues))
root_bytes = parsed.root.read_bytes()
key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt)
if fmt == "kicad_sch":
proj_svc.save_companion_sheets(
storage, user_id, project_id, parsed.root, parsed.extra_sch,
)
else:
proj_svc.clear_companion_sheets(storage, user_id, project_id)
if parsed.pcb is not None:
proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"Invalid netlist: {e}")
issues = validate_netlist(parts, nets)
if issues:
raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}")
key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt)
# EDIF: emit a designator→pins preview matching the PADS browser-side
# shape, so the wizard's power-sources step can render its dropdowns
# without re-parsing the (s-expression-heavy) file in the browser.
raise HTTPException(400, f"Netlist failed sanity check: {e}") from e
designator_pins: list[dict] = []
if fmt != "pads":
designator_pins = _build_designator_pins(parts, nets)
+29
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
@@ -663,6 +664,34 @@ def save_netlist(
return key
def clear_companion_sheets(
storage: StorageBackend, user_id: str, project_id: str,
) -> None:
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
for key in storage.list_recursive(prefix):
rel = key[len(prefix):]
if rel.endswith(".kicad_sch") and rel != "netlist.kicad_sch":
storage.delete_key(key)
def save_companion_sheets(
storage: StorageBackend,
user_id: str,
project_id: str,
root: Path,
extras: list[Path],
) -> None:
"""Keep Sheetfile children next to ``uploads/netlist.kicad_sch``."""
clear_companion_sheets(storage, user_id, project_id)
parent = root.parent
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
for extra in extras:
rel = extra.relative_to(parent).as_posix()
if rel == "netlist.kicad_sch":
continue
storage.write_bytes(prefix + rel, extra.read_bytes())
def save_pcb(
storage: StorageBackend, user_id: str, project_id: str, data: bytes
) -> str:
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope.
## 2.26.1 — 2026-09-11 — Ask for the KiCad board
The create-project wizard has a dedicated PCB step (skip still allowed). Report load errors show the API detail instead of a generic fetch failure.
- [New] Wizard step for `.kicad_pcb`. Upload later from Settings or Impedance.
- [Improved] Report page retries briefly and links back to pipeline progress when `report.json` is missing.
- [New] Drop the KiCad project folder (or a zip). Extra library junk is ignored; the board is picked up if present.
## 2.26.0 — 2026-09-11 — ImpedenceFinder Z0 on PCB nets
A pipeline run with `.kicad_pcb` + stackup samples routed **signal** nets (power/ground skipped). Extra net names can be analyzed from the Impedance tab. Z0 is ImpedenceFinder net_walk/zsolver; no invented εr.
+6 -1
View File
@@ -1,9 +1,10 @@
# File Upload Guide
Pinscope needs two files from your EDA tool to review a design:
Pinscope needs two files from your EDA tool to review a schematic, and an optional KiCad board for layout:
- A **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the circuit's connectivity. Pinscope accepts `.asc`, `.net`, `.NET`, `.txt` (PADS-PCB) and `.edn`, `.edif`, `.edf` (EDIF); the format is auto-detected from the file's first bytes.
- A **Bill of Materials** (CSV or XLSX) — mapping each reference designator to a manufacturer part number.
- Optional: a **KiCad PCB** (`.kicad_pcb`) — placement, keepout, pair length, and net Z0. Schematic review still runs without it.
## Example files
@@ -179,4 +180,8 @@ For both EasyEDA Standard and EasyEDA Pro.
- **"No ground net found"** — your netlist has no net named `GND`, `VSS`, `AGND`, `DGND`, or similar. If you exported a sub-sheet, re-export the top sheet instead.
- **Unresolved parts after the pipeline runs** — a BOM row has no MPN, or the MPN wasn't found on DigiKey. Add the MPN, or rely on Pinscope's value fallback (fills in from the `Value` / `Comment` column).
## The KiCad PCB (optional)
Drop the KiCad **project folder** (or a zip of it) on Schematic — Pinscope takes the sheets and the `.kicad_pcb` if it is there. You can also add the board later.
Still stuck? [Contact us](/contact) with your netlist and BOM attached and we'll take a look.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "2.26.0",
"version": "2.26.1",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
+24 -1
View File
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel";
import { PcbUploadButton } from "@/components/project/pcb-upload";
export default function ProjectDetailPage({
params,
@@ -190,6 +191,11 @@ export default function ProjectDetailPage({
<h1 className="text-lg font-semibold">{project.name}</h1>
<p className="text-sm text-muted-foreground">
{new Date(project.created).toLocaleDateString()}
{project.hasPcb ? (
<span className="ml-2">· PCB uploaded</span>
) : (
<span className="ml-2">· no PCB</span>
)}
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
<span className="ml-2 font-mono tabular-nums text-foreground">
${project.totalCostUsd.toFixed(4)}
@@ -348,7 +354,11 @@ export default function ProjectDetailPage({
)}
{tab === "impedance" && (
<ImpedancePanel projectId={id} hasPcb={Boolean(project.hasPcb)} />
<ImpedancePanel
projectId={id}
hasPcb={Boolean(project.hasPcb)}
onPcbUploaded={reload}
/>
)}
{tab === "logs" && (
@@ -357,6 +367,19 @@ export default function ProjectDetailPage({
{tab === "settings" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm">KiCad PCB</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{project.hasPcb
? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
: "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
</p>
<PcbUploadButton projectId={id} onUploaded={reload} />
</CardContent>
</Card>
<CollaboratorsSection projectId={id} />
<SkippedComponentsSection skipped={project.skippedComponents} />
<ReportVersionSection pinscopeVersion={project.pinscopeVersion} />
@@ -20,7 +20,7 @@ import {
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
import { cancelPipeline, fetchProject, fetchReport, resumePipeline, reprocessPipeline } from "@/lib/api";
import type { PauseCheckpoint } from "@/lib/types";
import {
AlertTriangle,
@@ -107,14 +107,24 @@ export default function ProgressPage({
}
};
// Auto-navigate to report when pipeline completes successfully
// Auto-navigate to report when pipeline completes and the file exists
useEffect(() => {
if (done && !error && !cancelled && !projectPaused) {
const timer = setTimeout(() => {
router.push(`/project/${id}/report`);
}, 1500);
return () => clearTimeout(timer);
}
if (!done || error || cancelled || projectPaused) return;
let stopped = false;
(async () => {
for (let i = 0; i < 8; i++) {
try {
await fetchReport(id);
if (!stopped) router.push(`/project/${id}/report`);
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
})();
return () => {
stopped = true;
};
}, [done, error, cancelled, projectPaused, id, router]);
// Auto-navigate to dashboard when pipeline is cancelled
@@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) {
if (error || !report || !graph) {
return (
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 text-sm text-muted-foreground">
{error ?? "Report not found."}
<div className="p-6 max-w-5xl mx-auto w-full text-center py-12 space-y-3">
<p className="text-sm text-muted-foreground">
{error ?? "Report not found."}
</p>
<p className="text-sm">
<a
href={`/project/${projectId}/progress`}
className="text-blue-600 dark:text-blue-500 hover:underline"
>
Open pipeline progress
</a>
</p>
</div>
);
}
@@ -352,6 +352,7 @@ function summarizeLcscModel(model: Record<string, unknown>): string {
type WizardStep =
| "details"
| "pcb"
| "columns"
| "subdesigns"
| "lcsc-passives"
@@ -361,6 +362,7 @@ type WizardStep =
const ALL_STEPS: { key: WizardStep; label: string }[] = [
{ key: "details", label: "Project Details" },
{ key: "pcb", label: "Board (optional)" },
{ key: "columns", label: "BOM Columns" },
{ key: "subdesigns", label: "Sub-designs" },
{ key: "lcsc-passives", label: "Resolving Passive Specs" },
@@ -374,6 +376,45 @@ const ALL_STEPS: { key: WizardStep; label: string }[] = [
const NULL_SUBDESIGN_KEY = "";
const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY;
const _NET_EXT = new Set([
".kicad_sch",
".kicad_net",
".kicad_pro",
".asc",
".net",
".edn",
".edif",
".edf",
".xml",
".zip",
]);
function _fileExt(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i).toLowerCase() : "";
}
function splitProjectFiles(incoming: File[]): {
netlist: File[];
pcb: File[];
bom: File[];
} {
const netlist: File[] = [];
const pcb: File[] = [];
const bom: File[] = [];
for (const f of incoming) {
const rel = (
(f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name
).toLowerCase();
if (rel.includes("-backups/") || rel.includes(".pretty/")) continue;
const ext = _fileExt(f.name);
if (ext === ".kicad_pcb") pcb.push(f);
else if (ext === ".csv" || ext === ".xlsx") bom.push(f);
else if (_NET_EXT.has(ext)) netlist.push(f);
}
return { netlist, pcb, bom };
}
// Concurrency cap for the per-row LCSC passive resolve. Backend charges one
// credit-billing API call per request; small batch keeps latency reasonable
// without overloading the resolve endpoint.
@@ -533,7 +574,7 @@ export function CreateProjectDialog({
// Rerun mode: operate on an existing project rather than creating new.
const [existingProjectId, setExistingProjectId] = useState<string | null>(null);
const [initialBomFile, setInitialBomFile] = useState<File | null>(null);
const [initialNetlistFile, setInitialNetlistFile] = useState<File | null>(null);
const [initialNetlistFiles, setInitialNetlistFiles] = useState<File[]>([]);
const [existingDatasheetStems, setExistingDatasheetStems] = useState<Set<string>>(
new Set(),
);
@@ -548,8 +589,12 @@ export function CreateProjectDialog({
// Step 1
const [name, setName] = useState("");
const [bomFile, setBomFile] = useState<File | null>(null);
const [netlistFile, setNetlistFile] = useState<File | null>(null);
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
const netlistFile = netlistFiles[0] ?? null;
const [pcbFile, setPcbFile] = useState<File | null>(null);
const skipPcbStep =
Boolean(pcbFile) ||
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".zip"));
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
const [netlistError, setNetlistError] = useState<string | null>(null);
@@ -840,6 +885,7 @@ export function CreateProjectDialog({
// lcsc-passives drains to zero before the auto-advance effect
// moves us off the step on the next tick).
if (s.key === step) return true;
if (s.key === "pcb") return !skipPcbStep;
if (s.key === "subdesigns") return hasSubdesignChoice;
if (s.key === "lcsc-passives") return hasLcscPassives;
if (s.key === "datasheets") return hasIcs;
@@ -847,14 +893,15 @@ export function CreateProjectDialog({
if (s.key === "passives") return hasPassives;
return true;
});
}, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
}, [step, skipPcbStep, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]);
const stepIndex = activeSteps.findIndex((s) => s.key === step);
// ---- Handlers ----
const handleBomChange = useCallback((files: File[]) => {
const file = files[0] || null;
const file =
files.find((f) => /\.(csv|xlsx)$/i.test(f.name)) || files[0] || null;
setBomFile(file);
if (!file) {
setCsvData(null);
@@ -880,9 +927,13 @@ export function CreateProjectDialog({
}
}, []);
const handleNetlistChange = useCallback((files: File[]) => {
const handleNetlistChange = useCallback((incoming: File[]) => {
const { netlist, pcb, bom } = splitProjectFiles(incoming);
if (pcb[0]) setPcbFile(pcb[0]);
if (bom[0]) handleBomChange([bom[0]]);
const files = netlist;
const file = files[0] || null;
setNetlistFile(file);
setNetlistFiles(files);
setNetlistNetCount(null);
setNetlistError(null);
// Invalidate any cached netlist preview, since it was relative to the
@@ -895,6 +946,11 @@ export function CreateProjectDialog({
setNetlistUploadedEarly(false);
setNetlistIsEdif(false);
if (!file) return;
const lower = file.name.toLowerCase();
if (lower.endsWith(".zip") || files.length > 1) {
setNetlistPreview([]);
return;
}
file.text().then((text) => {
// EDIF: skip browser-side preview — the net-count badge will populate
// after the server upload parses the file.
@@ -920,14 +976,15 @@ export function CreateProjectDialog({
);
}
});
}, []);
}, [handleBomChange]);
const resetAndClose = useCallback(() => {
setOpen(false);
setStep("details");
setName("");
setBomFile(null);
setNetlistFile(null);
setNetlistFiles([]);
setPcbFile(null);
setNetlistNetCount(null);
setNetlistError(null);
setCsvData(null);
@@ -954,7 +1011,7 @@ export function CreateProjectDialog({
setError(null);
setExistingProjectId(null);
setInitialBomFile(null);
setInitialNetlistFile(null);
setInitialNetlistFiles([]);
setExistingDatasheetStems(new Set());
setPrefillLoading(false);
setPrefillError(null);
@@ -1049,8 +1106,8 @@ export function CreateProjectDialog({
if (netlist) {
const netlistText = await netlist.text();
if (cancelled) return;
setNetlistFile(netlist);
setInitialNetlistFile(netlist);
setNetlistFiles([netlist]);
setInitialNetlistFiles([netlist]);
if (isEdifNetlist(netlistText)) {
setNetlistIsEdif(true);
// EDIF: PADS-shape browser preview doesn't apply.
@@ -1135,7 +1192,7 @@ export function CreateProjectDialog({
if (netlist) {
const netlistText = await netlist.text();
if (cancelled) return;
setNetlistFile(netlist);
setNetlistFiles([netlist]);
if (isEdifNetlist(netlistText)) {
setNetlistIsEdif(true);
setNetlistNetCount(null);
@@ -1488,7 +1545,7 @@ export function CreateProjectDialog({
setBomUploadedEarly(true);
setInitialBomFile(bomFile);
// Treat the netlist as fresh so it still uploads in handleCreate.
setInitialNetlistFile(null);
setInitialNetlistFiles([]);
const map = uploadRes.lcsc_to_mpn ?? {};
if (Object.keys(map).length > 0) setLcscToMpn(map);
if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) {
@@ -1547,13 +1604,13 @@ export function CreateProjectDialog({
createdProjectIdHere = proj.id;
setExistingProjectId(proj.id);
}
const result = await uploadNetlist(projectId, netlistFile);
const result = await uploadNetlist(projectId, netlistFiles);
setEdifSubDesigns(result.sub_designs);
if (result.designator_pins.length > 0) {
setNetlistPreview(result.designator_pins);
}
setNetlistUploadedEarly(true);
setInitialNetlistFile(netlistFile);
setInitialNetlistFiles(netlistFiles);
return { ok: true, subDesigns: result.sub_designs };
} catch (e) {
// Only delete the project if we created it just now — leave LCSC's
@@ -1573,7 +1630,7 @@ export function CreateProjectDialog({
} finally {
setEarlyUploading(false);
}
}, [netlistFile, existingProjectId, name]);
}, [netlistFile, netlistFiles, existingProjectId, name]);
// ---- LCSC per-row passive resolve ----
//
@@ -1689,6 +1746,7 @@ export function CreateProjectDialog({
const canAdvance = (): boolean => {
if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError);
if (step === "pcb") return true;
if (step === "columns") return !!(refCol && mpnCol);
if (step === "subdesigns")
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
@@ -1729,6 +1787,9 @@ export function CreateProjectDialog({
const res = await runEarlyNetlistUpload();
if (!res.ok) return;
}
setStep(skipPcbStep ? "columns" : "pcb");
}
else if (step === "pcb") {
setStep("columns");
}
else if (step === "columns") {
@@ -1806,7 +1867,8 @@ export function CreateProjectDialog({
const goBack = () => {
setError(null);
if (step === "columns") setStep("details");
if (step === "pcb") setStep("details");
else if (step === "columns") setStep(skipPcbStep ? "details" : "pcb");
else if (step === "subdesigns") setStep("columns");
else if (step === "lcsc-passives") {
if (hasSubdesignChoice) setStep("subdesigns");
@@ -1877,9 +1939,9 @@ export function CreateProjectDialog({
);
}
if (!projectAlreadyExists || netlistFile !== initialNetlistFile) {
if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) {
setProgress("Uploading netlist...");
await uploadNetlist(project.id, netlistFile!);
await uploadNetlist(project.id, netlistFiles);
}
if (pcbFile) {
setProgress("Uploading PCB...");
@@ -2065,14 +2127,13 @@ export function CreateProjectDialog({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<FileUploadZone
label="BOM (.csv, .xlsx)"
label="BOM"
accept=".csv,.xlsx"
files={bomFile ? [bomFile] : []}
onFilesChange={handleBomChange}
/>
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Requires columns: Designator, Manufacturer Part Number, and
Comment (for passive values / mismatch detection).{" "}
CSV or Excel.{" "}
<a
href="/file-guide#the-bom"
target="_blank"
@@ -2085,9 +2146,10 @@ export function CreateProjectDialog({
</div>
<div className="space-y-1.5">
<FileUploadZone
label="Netlist (.asc / .net / .edn / .kicad_sch)"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net"
files={netlistFile ? [netlistFile] : []}
label="Schematic"
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
multiple
files={netlistFiles}
onFilesChange={handleNetlistChange}
/>
{netlistError ? (
@@ -2099,13 +2161,17 @@ export function CreateProjectDialog({
<p className="text-[11px] text-muted-foreground leading-tight px-1">
{netlistNetCount} nets detected
</p>
) : netlistFile ? (
) : netlistFiles.length > 0 ? (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Net count will appear after upload.
{pcbFile
? "Board included."
: netlistFiles.length > 1
? `${netlistFiles.length} schematic files.`
: "Ready."}
</p>
) : (
<p className="text-[11px] text-muted-foreground leading-tight px-1">
PADS-PCB, EDIF, or KiCad netlist / .kicad_sch.{" "}
Drop the KiCad folder, or one netlist.{" "}
<a
href="/file-guide#the-netlist"
target="_blank"
@@ -2118,19 +2184,20 @@ export function CreateProjectDialog({
)}
</div>
</div>
<div className="space-y-1.5">
<FileUploadZone
label="PCB (.kicad_pcb, optional)"
accept=".kicad_pcb"
files={pcbFile ? [pcbFile] : []}
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
/>
<p className="text-[11px] text-muted-foreground leading-tight px-1">
Optional. Layout checks (trace width, 3W, placement mm) stay
off until a board is present. Schema review still runs
without it.
</p>
</div>
</div>
)}
{step === "pcb" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Optional. Skip if you only want schematic review.
</p>
<FileUploadZone
label="KiCad board (.kicad_pcb)"
accept=".kicad_pcb"
files={pcbFile ? [pcbFile] : []}
onFilesChange={(files) => setPcbFile(files[0] ?? null)}
/>
</div>
)}
@@ -10,6 +10,7 @@ import {
computeImpedance,
fetchImpedanceNets,
} from "@/lib/api";
import { PcbUploadButton } from "@/components/project/pcb-upload";
import type {
ImpedanceKind,
ImpedanceNetsReport,
@@ -29,9 +30,11 @@ function fmt(n: number | null | undefined, digits = 2): string {
export function ImpedancePanel({
projectId,
hasPcb,
onPcbUploaded,
}: {
projectId: string;
hasPcb: boolean;
onPcbUploaded?: () => void;
}) {
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
const [h, setH] = useState("0.20");
@@ -225,10 +228,13 @@ export function ImpedancePanel({
</CardHeader>
<CardContent className="space-y-3">
{!hasPcb && (
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb` and run analysis. Power/ground nets are
skipped; signal traces with stackup εr/h are sampled.
</p>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground
nets are skipped; signal traces with stackup εr/h are sampled.
</p>
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
</div>
)}
{hasPcb && boardNets?.skipped && (
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { uploadPcb } from "@/lib/api";
export function PcbUploadButton({
projectId,
onUploaded,
}: {
projectId: string;
onUploaded?: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="space-y-1">
<label className="inline-flex">
<Button
size="sm"
variant="outline"
disabled={busy}
nativeButton={false}
render={<span />}
>
{busy ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-1" />
)}
{busy ? "Uploading…" : "Upload .kicad_pcb"}
</Button>
<input
type="file"
accept=".kicad_pcb"
className="hidden"
disabled={busy}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setBusy(true);
setError(null);
try {
await uploadPcb(projectId, file);
onUploaded?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setBusy(false);
}
}}
/>
</label>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
@@ -13,6 +13,80 @@ interface FileUploadZoneProps {
preloaded?: string[];
}
function withRelativePath(file: File, rel: string): File {
if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) {
return file;
}
try {
Object.defineProperty(file, "webkitRelativePath", {
value: rel,
configurable: true,
});
} catch {
/* ignore — some File objects are sealed */
}
return file;
}
function readDirEntries(
reader: FileSystemDirectoryReader,
): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
const acc: FileSystemEntry[] = [];
const pump = () => {
reader.readEntries((chunk) => {
if (chunk.length === 0) resolve(acc);
else {
acc.push(...chunk);
pump();
}
}, reject);
};
pump();
});
}
async function filesFromEntry(
entry: FileSystemEntry,
prefix: string,
): Promise<File[]> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
return [withRelativePath(file, prefix + file.name)];
}
if (entry.isDirectory) {
const skip = /(-backups|\.pretty|3dmodels|__macosx)$/i.test(entry.name);
if (skip) return [];
const reader = (entry as FileSystemDirectoryEntry).createReader();
const children = await readDirEntries(reader);
const nested: File[] = [];
for (const child of children) {
nested.push(...(await filesFromEntry(child, prefix + entry.name + "/")));
}
return nested;
}
return [];
}
async function filesFromDrop(e: React.DragEvent): Promise<File[]> {
const items = e.dataTransfer?.items;
if (items && items.length > 0) {
const out: File[] = [];
for (const item of Array.from(items)) {
const entry = item.webkitGetAsEntry?.();
if (entry) out.push(...(await filesFromEntry(entry, "")));
else {
const f = item.getAsFile();
if (f) out.push(f);
}
}
if (out.length > 0) return out;
}
return Array.from(e.dataTransfer?.files ?? []);
}
export function FileUploadZone({
label,
accept,
@@ -27,10 +101,11 @@ export function FileUploadZone({
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const dropped = Array.from(e.dataTransfer.files);
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
void filesFromDrop(e).then((dropped) => {
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
});
},
[files, multiple, onFilesChange]
[files, multiple, onFilesChange],
);
const handleChange = useCallback(
@@ -38,7 +113,7 @@ export function FileUploadZone({
const selected = Array.from(e.target.files ?? []);
onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1));
},
[files, multiple, onFilesChange]
[files, multiple, onFilesChange],
);
const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0);
@@ -48,9 +123,12 @@ export function FileUploadZone({
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
hasFiles && "border-emerald-500/40 bg-emerald-500/5"
hasFiles && "border-emerald-500/40 bg-emerald-500/5",
)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
@@ -69,12 +147,27 @@ export function FileUploadZone({
)}
{files.length > 0 && (
<div className="text-xs text-muted-foreground">
{files.map((f) => (
<span key={f.name} className="font-mono block">{f.name}</span>
))}
{files.length <= 2 ? (
files.map((f) => {
const rel =
(f as File & { webkitRelativePath?: string }).webkitRelativePath ||
f.name;
return (
<span key={rel} className="font-mono block">{rel.split("/").pop()}</span>
);
})
) : (
<span>{files.length} files</span>
)}
</div>
)}
<input type="file" accept={accept} multiple={multiple} className="hidden" onChange={handleChange} />
<input
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={handleChange}
/>
</label>
);
}
+39 -7
View File
@@ -4,6 +4,13 @@ import { useState, useEffect } from "react";
import type { ValidationReport, DesignGraph } from "@/lib/types";
import { fetchReport, fetchGraph } from "@/lib/api";
function describeLoadError(e: unknown): string {
if (e instanceof TypeError) {
return "Could not reach the Pinscope API. Check that the backend is running and that /api is proxied to it.";
}
return e instanceof Error ? e.message : "Failed to load report";
}
export function useReport(projectId: string) {
const [report, setReport] = useState<ValidationReport | null>(null);
const [graph, setGraph] = useState<DesignGraph | null>(null);
@@ -11,14 +18,39 @@ export function useReport(projectId: string) {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([fetchReport(projectId), fetchGraph(projectId)])
.then(([r, g]) => {
setReport(r);
setGraph(g);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
setError(null);
(async () => {
let lastErr: unknown;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const [r, g] = await Promise.all([
fetchReport(projectId),
fetchGraph(projectId),
]);
if (!cancelled) {
setReport(r);
setGraph(g);
}
return;
} catch (e) {
lastErr = e;
const msg = e instanceof Error ? e.message : "";
const retryable = msg.includes("not found") || e instanceof TypeError;
if (!retryable || attempt === 3) break;
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
}
}
if (!cancelled) setError(describeLoadError(lastErr));
})().finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId]);
return { report, graph, loading, error };
+40 -4
View File
@@ -56,6 +56,19 @@ async function authFetch(url: string, init?: RequestInit): Promise<Response> {
return fetch(url, { ...init, headers });
}
async function throwHttpError(res: Response, fallback: string): Promise<never> {
let detail: unknown;
try {
const body = await res.json();
detail = (body as { detail?: unknown; error?: unknown }).detail
?? (body as { error?: unknown }).error;
} catch {
detail = undefined;
}
const msg = typeof detail === "string" && detail.trim() ? detail : fallback;
throw new Error(msg);
}
// --- Projects ---
function mapProject(p: Record<string, unknown>): Project {
@@ -301,10 +314,19 @@ export async function uploadPcb(
}
export async function uploadNetlist(
projectId: string, file: File,
projectId: string, files: File | File[],
): Promise<UploadNetlistResult> {
const list = Array.isArray(files) ? files : [files];
const form = new FormData();
form.append("file", file);
const rels = list.map((f) => {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
return rel && rel.length > 0 ? rel : f.name;
});
if (list[0]) form.append("file", list[0], list[0].name);
if (list.length > 1) {
for (const f of list) form.append("files", f, f.name);
form.append("paths", JSON.stringify(rels));
}
const res = await authFetch(
`${BASE}/api/projects/${projectId}/upload/netlist`,
{ method: "POST", body: form },
@@ -651,7 +673,14 @@ export async function fetchReport(
projectId: string,
): Promise<ValidationReport> {
const res = await authFetch(`${BASE}/api/report/${projectId}`);
if (!res.ok) throw new Error("Failed to fetch report");
if (!res.ok) {
await throwHttpError(
res,
res.status === 404
? "Report not found — the pipeline has not finished, or it failed before writing a report."
: "Failed to fetch report",
);
}
return res.json();
}
@@ -723,7 +752,14 @@ export async function deleteComment(
export async function fetchGraph(projectId: string): Promise<DesignGraph> {
const res = await authFetch(`${BASE}/api/graph/${projectId}`);
if (!res.ok) throw new Error("Failed to fetch graph");
if (!res.ok) {
await throwHttpError(
res,
res.status === 404
? "Design graph not found — the pipeline has not finished, or it failed during graph build."
: "Failed to fetch graph",
);
}
return res.json();
}
+1 -1
View File
@@ -1,4 +1,4 @@
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
export const APP_VERSION = "2.26.0";
export const APP_VERSION = "2.26.1";
export const APP_VERSION_DATE = "2026-09-11";
+202
View File
@@ -0,0 +1,202 @@
"""Multi-file / zip KiCad schematic ingest."""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
import pytest
from backend.pinscopex.netlist_bundle import (
find_kicad_pcb,
materialize_netlist_upload,
sniff_netlist_kind,
)
from backend.pinscopex.parsers import parse_netlist_any, validate_netlist
_LIB_R = """
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
"""
def _resistor(ref: str, value: str) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:R")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1"))
(pin "2" (uuid "p2"))
)
"""
def _sch(*body: str) -> str:
return (
"(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")"
+ _LIB_R
+ "".join(body)
+ "\n)\n"
)
def _root_with_child() -> tuple[str, str]:
child = _sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
)
return root, child
def test_sniff_rejects_pcb_as_netlist():
assert sniff_netlist_kind(b"(kicad_pcb (version 1)\n") == "kicad_pcb"
assert sniff_netlist_kind(b"PK\x03\x04rest") == "zip"
assert sniff_netlist_kind(b"(kicad_sch (version 1)") == "kicad_sch"
assert sniff_netlist_kind(b"*PADS-PCB*\n*PART*\n") == "pads"
def test_root_alone_missing_child_explains_multi_file(tmp_path: Path):
from backend.pinscopex.parsers_kicad import parse_kicad
root, _child = _root_with_child()
parsed = materialize_netlist_upload(
[("root.kicad_sch", root.encode())],
tmp_path / "work",
)
with pytest.raises(ValueError, match="child.kicad_sch"):
parse_kicad(parsed.root)
def test_multiple_sch_files_parse(tmp_path: Path):
root, child = _root_with_child()
parsed = materialize_netlist_upload(
[
("root.kicad_sch", root.encode()),
("child.kicad_sch", child.encode()),
],
tmp_path / "work",
)
parts, nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "R1" in parts and "C1" in parts
assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"]
assert validate_netlist(parts, nets) == []
assert parsed.pcb is None
def test_zip_with_nested_folder_and_pcb(tmp_path: Path):
root, child = _root_with_child()
pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("board/root.kicad_sch", root)
zf.writestr("board/child.kicad_sch", child)
zf.writestr("board/board.kicad_pcb", pcb)
parsed = materialize_netlist_upload(
[("board.zip", buf.getvalue())],
tmp_path / "work",
)
parts, nets, _fmt = parse_netlist_any(parsed.root)
assert "R1" in parts and "C1" in parts
assert parsed.pcb is not None
assert find_kicad_pcb(parsed.work_dir) is not None
def test_zip_slip_is_rejected(tmp_path: Path):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../escape.kicad_sch", _sch(_resistor("R1", "1k")))
with pytest.raises(ValueError, match="Rejected"):
materialize_netlist_upload(
[("bad.zip", buf.getvalue())],
tmp_path / "work",
)
def test_api_accepts_zip_and_companion_sheets(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "kicad"}).json()["id"]
root, child = _root_with_child()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("root.kicad_sch", root)
zf.writestr("child.kicad_sch", child)
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files={"file": ("board.zip", buf.getvalue(), "application/zip")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["parts"] == 2
assert body["format"] == "kicad_sch"
storage = client.app.state.storage
prefix = f"users/local/projects/{pid}/uploads/"
assert storage.exists(prefix + "netlist.kicad_sch")
assert storage.exists(prefix + "child.kicad_sch")
def test_api_accepts_multiple_sch_files(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "multi"}).json()["id"]
root, child = _root_with_child()
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files=[
("files", ("root.kicad_sch", root.encode(), "text/plain")),
("files", ("child.kicad_sch", child.encode(), "text/plain")),
],
data={"paths": '["root.kicad_sch", "child.kicad_sch"]'},
)
assert resp.status_code == 200, resp.text
assert resp.json()["parts"] == 2
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
with pytest.raises(ValueError, match="board"):
materialize_netlist_upload(
[("board.kicad_pcb", b"(kicad_pcb (version 1)\n")],
tmp_path / "work",
)