From a129b292e4058cec7263f551a1a6e1abdac969c6 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 5 Mar 2026 15:27:59 -0700 Subject: [PATCH] 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 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(" 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.`` 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" - logger.error("Netlist export failed (rc=%d): %s", result.returncode, stderr) - return {"success": False, "error": f"kicad-cli netlist export failed: {stderr}"} + 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 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) diff --git a/tests/test_netlist.py b/tests/test_netlist.py index 7d21bde..6ab873a 100644 --- a/tests/test_netlist.py +++ b/tests/test_netlist.py @@ -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."""