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
|
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)
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
# 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)
|
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):
|
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)
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user