Fix KiCad 9 s-expression netlist import and export_netlist format flags

KiCad 9 defaults to s-expression netlist export, not XML. Add
_parse_kicad_sexp() parser with pinfunction/pintype metadata, update
auto-detection to distinguish (export from <export by content.

Fix export_netlist: use kicad-cli's actual format names (kicadsexpr,
kicadxml) instead of invalid 'kicad', use --format instead of -f, and
treat non-zero exit with valid output as success with warnings.
This commit is contained in:
Ryan Malloy 2026-03-05 15:27:59 -07:00
parent 1f06ef8b7f
commit a129b292e4
3 changed files with 329 additions and 26 deletions

View File

@ -2,7 +2,8 @@
Parses external netlist files into the component-pin-net graph that Parses external netlist files into the component-pin-net graph that
``verify_connectivity`` and ``apply_batch`` consume. Supports KiCad ``verify_connectivity`` and ``apply_batch`` consume. Supports KiCad
XML (.net) and CSV/TSV formats. s-expression (.net, default in KiCad 9+), KiCad XML (legacy), and
CSV/TSV formats.
""" """
import csv import csv
@ -10,6 +11,7 @@ import io
import json import json
import logging import logging
import os import os
import re
from typing import Any from typing import Any
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@ -90,6 +92,122 @@ def _parse_kicad_xml(content: str) -> dict[str, Any]:
} }
def _parse_kicad_sexp(content: str) -> dict[str, Any]:
"""Parse a KiCad s-expression netlist (.net file) into nets/components dicts.
This is the default export format for KiCad 9+, produced by
``kicad-cli sch export netlist`` (``--format kicadsexpr``).
S-expression structure::
(export (version "E")
(components
(comp (ref "R1")
(value "1K")
(libsource (lib "Device") (part "R") (description "..."))
...))
(nets
(net (code "1") (name "GND") (class "Default")
(node (ref "R2") (pin "1") (pinfunction "A") (pintype "passive"))
...)))
"""
_sexp_node = re.compile(
r'\(node\s+'
r'\(ref\s+"([^"]+)"\)\s*'
r'\(pin\s+"([^"]+)"\)'
r'(?:\s*\(pinfunction\s+"([^"]*?)"\))?'
r'(?:\s*\(pintype\s+"([^"]*?)"\))?'
)
# --- Parse components ---
components: dict[str, dict[str, str]] = {}
for comp_match in re.finditer(r'\(comp\s+\(ref\s+"([^"]+)"\)', content):
ref = comp_match.group(1)
# Extract the full (comp ...) block via bracket counting
start = comp_match.start()
depth = 0
end = start
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
end = i + 1
break
comp_block = content[start:end]
# Pull value, footprint, libsource fields
value_m = re.search(r'\(value\s+"([^"]*?)"\)', comp_block)
fp_m = re.search(r'\(footprint\s+"([^"]*?)"\)', comp_block)
lib_m = re.search(r'\(lib\s+"([^"]*?)"\)', comp_block)
part_m = re.search(r'\(part\s+"([^"]*?)"\)', comp_block)
components[ref] = {
"value": value_m.group(1) if value_m else "",
"footprint": fp_m.group(1) if fp_m else "",
"lib": lib_m.group(1) if lib_m else "",
"part": part_m.group(1) if part_m else "",
}
# --- Parse nets ---
nets: dict[str, list[list[str]]] = {}
pin_metadata: dict[str, dict[str, dict[str, str]]] = {} # ref -> pin -> {pinfunction, pintype}
connection_count = 0
# Find each (net ...) block
for net_start in re.finditer(r'\(net\s+\(code\s+"[^"]*"\)\s*\(name\s+"([^"]+)"\)', content):
net_name = net_start.group(1)
# Extract the full (net ...) block
start = net_start.start()
depth = 0
end = start
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
end = i + 1
break
net_block = content[start:end]
pins: list[list[str]] = []
for node_m in _sexp_node.finditer(net_block):
ref = node_m.group(1)
pin = node_m.group(2)
pinfunction = node_m.group(3) or ""
pintype = node_m.group(4) or ""
if ref and pin:
pins.append([ref, pin])
connection_count += 1
# Store pin metadata for richer output
if pinfunction or pintype:
pin_metadata.setdefault(ref, {})[pin] = {
"pinfunction": pinfunction,
"pintype": pintype,
}
if pins:
nets[net_name] = pins
result: dict[str, Any] = {
"nets": nets,
"components": components,
"statistics": {
"net_count": len(nets),
"component_count": len(components),
"connection_count": connection_count,
},
}
# Include pin metadata when available (s-expression format is richer than XML/CSV)
if pin_metadata:
result["pin_metadata"] = pin_metadata
return result
def _parse_csv(content: str) -> dict[str, Any]: def _parse_csv(content: str) -> dict[str, Any]:
"""Parse a CSV/TSV netlist into nets/components dicts. """Parse a CSV/TSV netlist into nets/components dicts.
@ -153,20 +271,27 @@ def _parse_csv(content: str) -> dict[str, Any]:
def _detect_format(content: str, file_path: str) -> str: def _detect_format(content: str, file_path: str) -> str:
"""Auto-detect netlist format from file extension and content.""" """Auto-detect netlist format from file extension and content."""
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()
stripped = content.lstrip()
# Extension-based detection # Content-based detection takes priority (a .net file could be either format)
if ext == ".net": if stripped.startswith("(export"):
return "kicad_sexp"
if stripped.startswith("<?xml") or stripped.startswith("<export"):
return "kicad_xml" return "kicad_xml"
# Extension-based fallback
if ext == ".net":
# KiCad 9+ defaults to s-expression; older versions used XML.
# If content detection didn't match either, try s-expression
# (covers cases with leading whitespace or comments).
if "(export" in stripped[:500]:
return "kicad_sexp"
return "kicad_sexp" # Default for .net — KiCad 9+ is the common case
if ext in (".csv", ".tsv"): if ext in (".csv", ".tsv"):
return "csv" return "csv"
if ext == ".xml": if ext == ".xml":
return "kicad_xml" return "kicad_xml"
# Content-based detection
stripped = content.lstrip()
if stripped.startswith("<?xml") or stripped.startswith("<export"):
return "kicad_xml"
# Check for CSV header pattern # Check for CSV header pattern
first_line = stripped.split("\n", 1)[0].lower() first_line = stripped.split("\n", 1)[0].lower()
if any(kw in first_line for kw in ("reference", "designator", "ref,")): if any(kw in first_line for kw in ("reference", "designator", "ref,")):
@ -193,15 +318,19 @@ def import_netlist(
or for generating ``apply_batch`` wiring instructions. or for generating ``apply_batch`` wiring instructions.
Supported formats: Supported formats:
- ``kicad_xml`` KiCad intermediate netlist (.net XML). Most reliable - ``kicad_sexp`` KiCad s-expression netlist (.net), the default
source, exported via ``kicad-cli sch export netlist``. format for KiCad 9+. Exported via
``kicad-cli sch export netlist --format kicadsexpr``.
Includes ``pinfunction`` and ``pintype`` metadata per pin.
- ``kicad_xml`` KiCad XML netlist (legacy format, ``--format kicadxml``).
- ``csv`` CSV or TSV with Reference, Pin, and Net columns. Common - ``csv`` CSV or TSV with Reference, Pin, and Net columns. Common
export from EDA tools (Altium, Eagle, etc.). export from EDA tools (Altium, Eagle, etc.).
- ``auto`` detect format from file extension and content (default). - ``auto`` detect format from file extension and content (default).
Args: Args:
source_path: Path to the netlist file to import. source_path: Path to the netlist file to import.
format: Netlist format (``auto``, ``kicad_xml``, ``csv``). format: Netlist format (``auto``, ``kicad_sexp``, ``kicad_xml``,
``csv``).
output_path: Optional path to write the parsed result as JSON. output_path: Optional path to write the parsed result as JSON.
Defaults to ``.mckicad/imported_netlist.json`` next to Defaults to ``.mckicad/imported_netlist.json`` next to
the source file. the source file.
@ -211,6 +340,8 @@ def import_netlist(
``components`` (ref metadata dict), and ``statistics``. ``components`` (ref metadata dict), and ``statistics``.
The ``nets`` dict is directly compatible with The ``nets`` dict is directly compatible with
``verify_connectivity``'s ``expected`` parameter. ``verify_connectivity``'s ``expected`` parameter.
When format is ``kicad_sexp``, also includes ``pin_metadata``
with per-pin ``pinfunction`` and ``pintype``.
""" """
source_path = os.path.abspath(os.path.expanduser(source_path)) source_path = os.path.abspath(os.path.expanduser(source_path))
@ -220,7 +351,7 @@ def import_netlist(
"error": f"Source file not found: {source_path}", "error": f"Source file not found: {source_path}",
} }
allowed_formats = ("auto", "kicad_xml", "csv") allowed_formats = ("auto", "kicad_sexp", "kicad_xml", "csv")
fmt = format.lower().strip() fmt = format.lower().strip()
if fmt not in allowed_formats: if fmt not in allowed_formats:
return { return {
@ -245,13 +376,15 @@ def import_netlist(
"success": False, "success": False,
"error": ( "error": (
"Could not auto-detect netlist format. " "Could not auto-detect netlist format. "
"Specify format='kicad_xml' or format='csv' explicitly." "Specify format='kicad_sexp', 'kicad_xml', or 'csv' explicitly."
), ),
} }
# Parse # Parse
try: try:
if fmt == "kicad_xml": if fmt == "kicad_sexp":
parsed = _parse_kicad_sexp(content)
elif fmt == "kicad_xml":
parsed = _parse_kicad_xml(content) parsed = _parse_kicad_xml(content)
elif fmt == "csv": elif fmt == "csv":
parsed = _parse_csv(content) parsed = _parse_csv(content)

View File

@ -958,25 +958,33 @@ def get_component_pins(
def export_netlist( def export_netlist(
schematic_path: str, schematic_path: str,
output_path: str | None = None, output_path: str | None = None,
format: str = "kicad", format: str = "kicadsexpr",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Export a netlist from a KiCad schematic via kicad-cli. """Export a netlist from a KiCad schematic via kicad-cli.
Supported formats: ``kicad`` (default), ``spice``, ``cadstar``, Supported formats: ``kicadsexpr`` (default, s-expression), ``kicadxml``
``allegro``, ``pads``, ``orcadpcb2``. The output file is written to (legacy XML), ``spice``, ``spicemodel``, ``cadstar``, ``allegro``,
the ``.mckicad/`` sidecar directory by default. ``pads``, ``orcadpcb2``. The output file is written to the
``.mckicad/`` sidecar directory by default.
The default ``kicadsexpr`` format can be re-imported via
``import_netlist(format='kicad_sexp')`` for round-trip workflows.
Args: Args:
schematic_path: Path to a .kicad_sch file. schematic_path: Path to a .kicad_sch file.
output_path: Destination file path. Defaults to output_path: Destination file path. Defaults to
``.mckicad/netlist.<ext>`` next to the schematic. ``.mckicad/netlist.<ext>`` next to the schematic.
format: Netlist format name. format: Netlist format name as accepted by ``kicad-cli``.
Returns: Returns:
Dictionary with ``output_path`` and ``format``. Dictionary with ``output_path`` and ``format``.
""" """
# Validate format first (cheap check, no filesystem access) # Validate format first (cheap check, no filesystem access)
allowed_formats = ("kicad", "spice", "cadstar", "allegro", "pads", "orcadpcb2") # Names must match kicad-cli's --format flag exactly
allowed_formats = (
"kicadsexpr", "kicadxml", "spice", "spicemodel",
"cadstar", "allegro", "pads", "orcadpcb2",
)
fmt = format.lower().strip() fmt = format.lower().strip()
if fmt not in allowed_formats: if fmt not in allowed_formats:
return { return {
@ -997,8 +1005,10 @@ def export_netlist(
# Determine file extension based on format # Determine file extension based on format
ext_map = { ext_map = {
"kicad": ".net", "kicadsexpr": ".net",
"kicadxml": ".net",
"spice": ".cir", "spice": ".cir",
"spicemodel": ".cir",
"cadstar": ".frp", "cadstar": ".frp",
"allegro": ".net", "allegro": ".net",
"pads": ".asc", "pads": ".asc",
@ -1015,7 +1025,7 @@ def export_netlist(
try: try:
import subprocess import subprocess
cmd = [cli_path, "sch", "export", "netlist", "-f", fmt, "-o", output_path, schematic_path] cmd = [cli_path, "sch", "export", "netlist", "--format", fmt, "-o", output_path, schematic_path]
logger.info("Exporting netlist: %s", " ".join(cmd)) logger.info("Exporting netlist: %s", " ".join(cmd))
@ -1027,10 +1037,21 @@ def export_netlist(
check=False, check=False,
) )
warnings: str | None = None
if result.returncode != 0: if result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else "Netlist export failed" stderr = result.stderr.strip() if result.stderr else ""
logger.error("Netlist export failed (rc=%d): %s", result.returncode, stderr) # kicad-cli may exit non-zero for warnings (missing fonts, library
return {"success": False, "error": f"kicad-cli netlist export failed: {stderr}"} # warnings) yet still produce a valid output file. Only fail if
# the output wasn't created.
if os.path.isfile(output_path) and os.path.getsize(output_path) > 0:
warnings = stderr or f"kicad-cli exited with code {result.returncode}"
logger.warning("Netlist export warnings (rc=%d): %s", result.returncode, warnings)
else:
logger.error("Netlist export failed (rc=%d): %s", result.returncode, stderr)
return {
"success": False,
"error": f"kicad-cli netlist export failed: {stderr or 'unknown error'}",
}
if not os.path.isfile(output_path): if not os.path.isfile(output_path):
return {"success": False, "error": "Netlist output file was not created"} return {"success": False, "error": "Netlist output file was not created"}
@ -1038,13 +1059,16 @@ def export_netlist(
file_size = os.path.getsize(output_path) file_size = os.path.getsize(output_path)
logger.info("Netlist exported: %s (%d bytes, format=%s)", output_path, file_size, fmt) logger.info("Netlist exported: %s (%d bytes, format=%s)", output_path, file_size, fmt)
return { out: dict[str, Any] = {
"success": True, "success": True,
"output_path": output_path, "output_path": output_path,
"format": fmt, "format": fmt,
"file_size": file_size, "file_size": file_size,
"schematic_path": schematic_path, "schematic_path": schematic_path,
} }
if warnings:
out["warnings"] = warnings
return out
except Exception as exc: except Exception as exc:
logger.error("Netlist export failed: %s", exc, exc_info=True) logger.error("Netlist export failed: %s", exc, exc_info=True)

View File

@ -131,6 +131,152 @@ class TestParseKicadXml:
assert len(pin_pair) == 2 assert len(pin_pair) == 2
@pytest.mark.unit
class TestParseKicadSexp:
"""Tests for KiCad s-expression netlist parsing (KiCad 9+ default)."""
def test_parse_sexp_netlist(self, tmp_output_dir):
"""Parse a KiCad 9 s-expression netlist with components, nets, and pin metadata."""
from mckicad.tools.netlist import import_netlist
sexp_content = textwrap.dedent("""\
(export (version "E")
(design
(source "/path/to/project.kicad_sch")
(tool "Eeschema 9.0.7"))
(components
(comp (ref "R1")
(value "10k")
(footprint "Resistor_SMD:R_0402")
(libsource (lib "Device") (part "R") (description "Resistor")))
(comp (ref "C1")
(value "100nF")
(footprint "Capacitor_SMD:C_0402")
(libsource (lib "Device") (part "C") (description "Capacitor")))
(comp (ref "U1")
(value "ESP32")
(libsource (lib "MCU") (part "ESP32") (description "MCU"))))
(nets
(net (code "1") (name "GND") (class "Default")
(node (ref "R1") (pin "2") (pinfunction "~") (pintype "passive"))
(node (ref "C1") (pin "2") (pinfunction "~") (pintype "passive")))
(net (code "2") (name "VCC") (class "Power")
(node (ref "R1") (pin "1") (pinfunction "~") (pintype "passive"))
(node (ref "U1") (pin "1") (pinfunction "VDD") (pintype "power_in")))
(net (code "3") (name "/SPI/MOSI") (class "Default")
(node (ref "U1") (pin "5") (pinfunction "MOSI") (pintype "bidirectional"))
(node (ref "C1") (pin "1") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write(sexp_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["format_detected"] == "kicad_sexp"
assert result["statistics"]["net_count"] == 3
assert result["statistics"]["component_count"] == 3
assert result["statistics"]["connection_count"] == 6
# Check nets structure
nets = result["nets"]
assert "GND" in nets
assert ["R1", "2"] in nets["GND"]
assert ["C1", "2"] in nets["GND"]
assert "VCC" in nets
assert "/SPI/MOSI" in nets # hierarchical net name preserved
# Check components
assert "R1" in result["components"]
assert result["components"]["R1"]["value"] == "10k"
assert result["components"]["R1"]["lib"] == "Device"
assert result["components"]["U1"]["part"] == "ESP32"
def test_sexp_pin_metadata(self, tmp_output_dir):
"""S-expression format includes pinfunction and pintype metadata."""
from mckicad.tools.netlist import import_netlist
sexp_content = textwrap.dedent("""\
(export (version "E")
(components
(comp (ref "U1")
(value "IC")
(libsource (lib "MCU") (part "IC") (description "IC"))))
(nets
(net (code "1") (name "SDA") (class "Default")
(node (ref "U1") (pin "3") (pinfunction "SDA") (pintype "bidirectional"))
(node (ref "R1") (pin "1") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "meta.net")
with open(path, "w") as f:
f.write(sexp_content)
result = import_netlist(source_path=path)
assert result["success"] is True
assert "pin_metadata" in result
# U1 pin 3 should have pinfunction and pintype
assert "U1" in result["pin_metadata"]
assert "3" in result["pin_metadata"]["U1"]
assert result["pin_metadata"]["U1"]["3"]["pinfunction"] == "SDA"
assert result["pin_metadata"]["U1"]["3"]["pintype"] == "bidirectional"
def test_auto_detects_sexp_format(self, tmp_output_dir):
"""Auto-detection picks kicad_sexp for s-expression .net files."""
from mckicad.tools.netlist import import_netlist
sexp = '(export (version "E")(components)(nets))'
path = os.path.join(tmp_output_dir, "test.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path, format="auto")
assert result["success"] is True
assert result["format_detected"] == "kicad_sexp"
def test_sexp_verify_connectivity_compatible(self, tmp_output_dir):
"""The nets dict from sexp parsing matches verify_connectivity shape."""
from mckicad.tools.netlist import import_netlist
sexp = textwrap.dedent("""\
(export (version "E")
(components
(comp (ref "U1") (value "IC") (libsource (lib "x") (part "y") (description "z"))))
(nets
(net (code "1") (name "NET1") (class "Default")
(node (ref "U1") (pin "1") (pinfunction "A") (pintype "output"))
(node (ref "R1") (pin "2") (pinfunction "~") (pintype "passive")))))
""")
path = os.path.join(tmp_output_dir, "compat.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path)
assert result["success"] is True
nets = result["nets"]
assert "NET1" in nets
assert isinstance(nets["NET1"], list)
for pin_pair in nets["NET1"]:
assert isinstance(pin_pair, list)
assert len(pin_pair) == 2
def test_sexp_empty_sections(self, tmp_output_dir):
"""Handle s-expression netlist with empty components and nets sections."""
from mckicad.tools.netlist import import_netlist
sexp = '(export (version "E")(components)(nets))'
path = os.path.join(tmp_output_dir, "empty_sections.net")
with open(path, "w") as f:
f.write(sexp)
result = import_netlist(source_path=path)
assert result["success"] is True
assert result["statistics"]["net_count"] == 0
assert result["statistics"]["component_count"] == 0
@pytest.mark.unit @pytest.mark.unit
class TestParseCsv: class TestParseCsv:
"""Tests for CSV/TSV netlist parsing.""" """Tests for CSV/TSV netlist parsing."""