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: