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:
parent
1f06ef8b7f
commit
a129b292e4
@ -2,7 +2,8 @@
|
||||
|
||||
Parses external netlist files into the component-pin-net graph that
|
||||
``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
|
||||
@ -10,6 +11,7 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
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]:
|
||||
"""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:
|
||||
"""Auto-detect netlist format from file extension and content."""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
stripped = content.lstrip()
|
||||
|
||||
# Extension-based detection
|
||||
if ext == ".net":
|
||||
# Content-based detection takes priority (a .net file could be either format)
|
||||
if stripped.startswith("(export"):
|
||||
return "kicad_sexp"
|
||||
if stripped.startswith("<?xml") or stripped.startswith("<export"):
|
||||
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"):
|
||||
return "csv"
|
||||
if ext == ".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
|
||||
first_line = stripped.split("\n", 1)[0].lower()
|
||||
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.
|
||||
|
||||
Supported formats:
|
||||
- ``kicad_xml`` — KiCad intermediate netlist (.net XML). Most reliable
|
||||
source, exported via ``kicad-cli sch export netlist``.
|
||||
- ``kicad_sexp`` — KiCad s-expression netlist (.net), the default
|
||||
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
|
||||
export from EDA tools (Altium, Eagle, etc.).
|
||||
- ``auto`` — detect format from file extension and content (default).
|
||||
|
||||
Args:
|
||||
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.
|
||||
Defaults to ``.mckicad/imported_netlist.json`` next to
|
||||
the source file.
|
||||
@ -211,6 +340,8 @@ def import_netlist(
|
||||
``components`` (ref → metadata dict), and ``statistics``.
|
||||
The ``nets`` dict is directly compatible with
|
||||
``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))
|
||||
|
||||
@ -220,7 +351,7 @@ def import_netlist(
|
||||
"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()
|
||||
if fmt not in allowed_formats:
|
||||
return {
|
||||
@ -245,13 +376,15 @@ def import_netlist(
|
||||
"success": False,
|
||||
"error": (
|
||||
"Could not auto-detect netlist format. "
|
||||
"Specify format='kicad_xml' or format='csv' explicitly."
|
||||
"Specify format='kicad_sexp', 'kicad_xml', or 'csv' explicitly."
|
||||
),
|
||||
}
|
||||
|
||||
# Parse
|
||||
try:
|
||||
if fmt == "kicad_xml":
|
||||
if fmt == "kicad_sexp":
|
||||
parsed = _parse_kicad_sexp(content)
|
||||
elif fmt == "kicad_xml":
|
||||
parsed = _parse_kicad_xml(content)
|
||||
elif fmt == "csv":
|
||||
parsed = _parse_csv(content)
|
||||
|
||||
@ -958,25 +958,33 @@ def get_component_pins(
|
||||
def export_netlist(
|
||||
schematic_path: str,
|
||||
output_path: str | None = None,
|
||||
format: str = "kicad",
|
||||
format: str = "kicadsexpr",
|
||||
) -> dict[str, Any]:
|
||||
"""Export a netlist from a KiCad schematic via kicad-cli.
|
||||
|
||||
Supported formats: ``kicad`` (default), ``spice``, ``cadstar``,
|
||||
``allegro``, ``pads``, ``orcadpcb2``. The output file is written to
|
||||
the ``.mckicad/`` sidecar directory by default.
|
||||
Supported formats: ``kicadsexpr`` (default, s-expression), ``kicadxml``
|
||||
(legacy XML), ``spice``, ``spicemodel``, ``cadstar``, ``allegro``,
|
||||
``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:
|
||||
schematic_path: Path to a .kicad_sch file.
|
||||
output_path: Destination file path. Defaults to
|
||||
``.mckicad/netlist.<ext>`` next to the schematic.
|
||||
format: Netlist format name.
|
||||
format: Netlist format name as accepted by ``kicad-cli``.
|
||||
|
||||
Returns:
|
||||
Dictionary with ``output_path`` and ``format``.
|
||||
"""
|
||||
# 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()
|
||||
if fmt not in allowed_formats:
|
||||
return {
|
||||
@ -997,8 +1005,10 @@ def export_netlist(
|
||||
|
||||
# Determine file extension based on format
|
||||
ext_map = {
|
||||
"kicad": ".net",
|
||||
"kicadsexpr": ".net",
|
||||
"kicadxml": ".net",
|
||||
"spice": ".cir",
|
||||
"spicemodel": ".cir",
|
||||
"cadstar": ".frp",
|
||||
"allegro": ".net",
|
||||
"pads": ".asc",
|
||||
@ -1015,7 +1025,7 @@ def export_netlist(
|
||||
try:
|
||||
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))
|
||||
|
||||
@ -1027,10 +1037,21 @@ def export_netlist(
|
||||
check=False,
|
||||
)
|
||||
|
||||
warnings: str | None = None
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip() if result.stderr else "Netlist export failed"
|
||||
stderr = result.stderr.strip() if result.stderr else ""
|
||||
# kicad-cli may exit non-zero for warnings (missing fonts, library
|
||||
# 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}"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"kicad-cli netlist export failed: {stderr or 'unknown error'}",
|
||||
}
|
||||
|
||||
if not os.path.isfile(output_path):
|
||||
return {"success": False, "error": "Netlist output file was not created"}
|
||||
@ -1038,13 +1059,16 @@ def export_netlist(
|
||||
file_size = os.path.getsize(output_path)
|
||||
logger.info("Netlist exported: %s (%d bytes, format=%s)", output_path, file_size, fmt)
|
||||
|
||||
return {
|
||||
out: dict[str, Any] = {
|
||||
"success": True,
|
||||
"output_path": output_path,
|
||||
"format": fmt,
|
||||
"file_size": file_size,
|
||||
"schematic_path": schematic_path,
|
||||
}
|
||||
if warnings:
|
||||
out["warnings"] = warnings
|
||||
return out
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Netlist export failed: %s", exc, exc_info=True)
|
||||
|
||||
@ -131,6 +131,152 @@ class TestParseKicadXml:
|
||||
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
|
||||
class TestParseCsv:
|
||||
"""Tests for CSV/TSV netlist parsing."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user