Add S-expression parser for custom library pins and global labels

kicad-sch-api has two parsing gaps: get_symbol_definition() returns
None for non-standard library prefixes (e.g. Espressif:ESP32-P4),
and there is no sch.global_labels attribute for (global_label ...)
nodes. This adds a focused parser that reads directly from the raw
.kicad_sch file as a fallback, integrated into the connectivity
engine, pin extraction, and label counting tools.
This commit is contained in:
Ryan Malloy 2026-03-04 17:18:01 -07:00
parent b7e4fc6859
commit e610bf3871
5 changed files with 609 additions and 7 deletions

View File

@ -16,6 +16,11 @@ from typing import Any
from mckicad.config import INLINE_RESULT_THRESHOLD
from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
logger = logging.getLogger(__name__)
@ -705,8 +710,18 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]:
if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict):
stats["text_elements"] = {}
stats["text_elements"]["global_label"] = len(hier_labels)
# Fallback: parse (global_label ...) nodes from raw file when
# kicad-sch-api reports 0 — it has no sch.global_labels attribute.
if isinstance(stats.get("text_elements"), dict):
if stats["text_elements"].get("global_label", 0) == 0:
raw_global = parse_global_labels(schematic_path)
if raw_global:
stats["text_elements"]["global_label"] = len(raw_global)
stats["text_elements"]["total_text_elements"] = (
stats["text_elements"].get("label", 0) + len(hier_labels)
stats["text_elements"].get("label", 0)
+ stats["text_elements"].get("global_label", 0)
)
# Run validation
@ -871,6 +886,31 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
if not raw_pins:
raw_pins = list(getattr(comp, "pins", []))
# Last resort: parse pins from the raw (lib_symbols ...) section.
# Handles custom library symbols where get_symbol_definition() fails.
if not raw_pins:
lib_id = getattr(comp, "lib_id", None)
if lib_id:
sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id))
if sexp_pins:
comp_pos = getattr(comp, "position", None)
comp_rot = float(getattr(comp, "rotation", 0) or 0)
comp_mirror = getattr(comp, "mirror", None)
mirror_x = comp_mirror in ("x", True) if comp_mirror else False
cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0
cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0
for sp in sexp_pins:
sx, sy = transform_pin_to_schematic(
sp["x"], sp["y"], cx, cy, comp_rot, mirror_x
)
pins.append({
"number": sp["number"],
"name": sp["name"],
"type": sp["type"],
"position": {"x": sx, "y": sy},
})
for p in (raw_pins or []):
if isinstance(p, dict):
pins.append(p)

View File

@ -18,6 +18,11 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS
from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.kicad_cli import find_kicad_cli
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
logger = logging.getLogger(__name__)
@ -104,7 +109,9 @@ def _default_output_path(schematic_path: str, filename: str) -> str:
# ---------------------------------------------------------------------------
def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]:
def _build_connectivity(
sch: Any, schematic_path: str | None = None
) -> tuple[dict[str, list[dict[str, str]]], list[dict[str, str]]]:
"""Build a net connectivity graph by walking wires, pin positions, and labels.
kicad-sch-api does not auto-compute nets on loaded schematics. This
@ -112,10 +119,14 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
1. Collecting all wire start/end coordinates
2. Mapping component pin positions to coordinates
3. Mapping label positions to coordinates
3. Mapping label positions to coordinates (local, hierarchical, global)
4. Using union-find to group touching points into nets
5. Merging groups that share the same label text
When *schematic_path* is provided, ``(global_label ...)`` entries are
also extracted directly from the raw file via :mod:`sexp_parser`, since
kicad-sch-api does not expose them.
Returns:
(net_graph, unconnected_pins) where net_graph maps net names to
lists of {reference, pin} dicts, and unconnected_pins lists
@ -197,7 +208,7 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
except Exception:
pass
# Hierarchical / global labels
# Hierarchical / global labels (kicad-sch-api attribute)
try:
for label in getattr(sch, "hierarchical_labels", []):
text = getattr(label, "text", None)
@ -209,6 +220,15 @@ def _build_connectivity(sch: Any) -> tuple[dict[str, list[dict[str, str]]], list
except Exception:
pass
# Global labels from raw file — kicad-sch-api has no sch.global_labels
# attribute, so (global_label ...) nodes are invisible to the API.
# Parse them directly from the S-expression when the file path is known.
if schematic_path:
for gl in parse_global_labels(schematic_path):
coord = _rc(gl["x"], gl["y"])
_find(coord)
label_at[coord] = gl["text"]
# Power symbols — their value acts as a net name (e.g. GND, VCC)
for comp in getattr(sch, "components", []):
ref = getattr(comp, "reference", None)
@ -487,7 +507,7 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]:
try:
sch = _ksa_load(schematic_path)
net_graph, unconnected = _build_connectivity(sch)
net_graph, unconnected = _build_connectivity(sch, schematic_path)
total_connections = sum(len(pins) for pins in net_graph.values())
detail_path = write_detail_file(schematic_path, "connectivity.json", {
@ -550,7 +570,7 @@ def check_pin_connection(
try:
sch = _ksa_load(schematic_path)
net_graph, _unconnected = _build_connectivity(sch)
net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find which net this pin belongs to
net_name = None
@ -632,7 +652,7 @@ def verify_pins_connected(
try:
sch = _ksa_load(schematic_path)
net_graph, _unconnected = _build_connectivity(sch)
net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find the net for each pin
net_for_pin1 = None
@ -759,6 +779,34 @@ def get_component_pins(
if not raw_pins:
raw_pins = getattr(comp, "pins", [])
# Last resort: parse pins directly from the raw (lib_symbols ...)
# section of the .kicad_sch file. This handles custom library
# symbols (e.g. Espressif:ESP32-P4) where get_symbol_definition()
# returns None because kicad-sch-api can't resolve the library prefix.
if not raw_pins:
lib_id = getattr(comp, "lib_id", None)
if lib_id:
sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id))
if sexp_pins:
# Get component position and rotation for coordinate transform
comp_pos = getattr(comp, "position", None)
comp_rot = float(getattr(comp, "rotation", 0) or 0)
comp_mirror = getattr(comp, "mirror", None)
mirror_x = comp_mirror in ("x", True) if comp_mirror else False
cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0
cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0
for sp in sexp_pins:
sx, sy = transform_pin_to_schematic(
sp["x"], sp["y"], cx, cy, comp_rot, mirror_x
)
pins_data.append({
"number": sp["number"],
"name": sp["name"],
"type": sp["type"],
"position": {"x": sx, "y": sy},
})
for p in raw_pins:
pin_entry: dict[str, Any] = {}

View File

@ -0,0 +1,219 @@
"""Minimal S-expression parser for extracting data kicad-sch-api doesn't parse.
kicad-sch-api v0.5.5 has two known parsing gaps:
1. ``sheets.data['lib_symbols']`` is always ``{}`` even though the raw
``.kicad_sch`` file embeds full symbol definitions (including pin data)
in its ``(lib_symbols ...)`` section.
2. ``(global_label ...)`` nodes are not exposed through any collection
attribute ``sch.labels`` only contains local labels, and there is
no ``sch.global_labels`` attribute.
This module provides focused parsers for these two cases, reading directly
from the raw ``.kicad_sch`` file.
"""
import math
import re
from typing import Any
# ---------------------------------------------------------------------------
# Global labels
# ---------------------------------------------------------------------------
# Match: (global_label "TEXT" ... (at X Y [R]) ...)
_GLOBAL_LABEL_RE = re.compile(
r'\(global_label\s+"([^"]+)"' # capture label text
r'.*?' # skip shape, nested parens etc. (non-greedy, DOTALL)
r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)', # capture x, y
re.DOTALL,
)
def parse_global_labels(filepath: str) -> list[dict[str, Any]]:
"""Extract ``(global_label ...)`` entries from a .kicad_sch file.
Returns a list of ``{'text': str, 'x': float, 'y': float}`` dicts.
"""
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except Exception:
return []
labels: list[dict[str, Any]] = []
for match in _GLOBAL_LABEL_RE.finditer(content):
labels.append({
"text": match.group(1),
"x": float(match.group(2)),
"y": float(match.group(3)),
})
return labels
# ---------------------------------------------------------------------------
# lib_symbols pin extraction
# ---------------------------------------------------------------------------
# Match pins inside a symbol section:
# (pin TYPE SHAPE (at X Y [R]) (length L) (name "NAME" ...) (number "NUM" ...))
_PIN_RE = re.compile(
r'\(pin\s+'
r'(\w+)\s+' # pin type (passive, input, output, ...)
r'(\w+)\s*' # pin shape (line, inverted, ...)
r'\(at\s+([\d.e+-]+)\s+([\d.e+-]+)(?:\s+([\d.e+-]+))?\)' # (at X Y [R])
r'\s*\(length\s+([\d.e+-]+)\)' # (length L)
r'.*?' # skip to name
r'\(name\s+"([^"]*)"' # (name "NAME" ...)
r'.*?' # skip to number
r'\(number\s+"([^"]*)"', # (number "NUM" ...)
re.DOTALL,
)
def parse_lib_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]:
"""Extract pin definitions for a symbol from the ``(lib_symbols ...)`` section.
Pins are returned in **local symbol coordinates** (not transformed to
schematic space). Use :func:`transform_pin_to_schematic` to convert
them if the component's position and rotation are known.
Args:
filepath: Path to a .kicad_sch file.
lib_id: Full library identifier (e.g. ``Espressif:ESP32-P4``).
Returns:
List of pin dicts with ``number``, ``name``, ``type``, ``shape``,
``x``, ``y``, ``rotation``, and ``length`` fields.
"""
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except Exception:
return []
# 1. Find the (lib_symbols ...) section
lib_section = _extract_section(content, "lib_symbols")
if not lib_section:
return []
# 2. Find the top-level (symbol "LIB_ID" ...) within lib_symbols
symbol_section = _extract_named_section(lib_section, "symbol", lib_id)
if not symbol_section:
return []
# 3. Extract all (pin ...) entries
pins: list[dict[str, Any]] = []
for match in _PIN_RE.finditer(symbol_section):
pins.append({
"number": match.group(8),
"name": match.group(7),
"type": match.group(1),
"shape": match.group(2),
"x": float(match.group(3)),
"y": float(match.group(4)),
"rotation": float(match.group(5) or 0),
"length": float(match.group(6)),
})
return pins
# ---------------------------------------------------------------------------
# Coordinate transformation
# ---------------------------------------------------------------------------
def transform_pin_to_schematic(
pin_x: float,
pin_y: float,
comp_x: float,
comp_y: float,
comp_rotation_deg: float = 0,
mirror_x: bool = False,
) -> tuple[float, float]:
"""Transform a pin from local symbol coordinates to schematic coordinates.
Applies the component's rotation and position offset.
Args:
pin_x: Pin X in local symbol coordinates.
pin_y: Pin Y in local symbol coordinates.
comp_x: Component X position in schematic coordinates.
comp_y: Component Y position in schematic coordinates.
comp_rotation_deg: Component rotation in degrees (0, 90, 180, 270).
mirror_x: Whether the component is mirrored along the X axis.
Returns:
(schematic_x, schematic_y) tuple.
"""
px, py = pin_x, pin_y
# Apply mirror first (before rotation)
if mirror_x:
px = -px
# Apply rotation (standard 2D rotation matrix)
rad = math.radians(comp_rotation_deg)
cos_r = math.cos(rad)
sin_r = math.sin(rad)
rx = px * cos_r - py * sin_r
ry = px * sin_r + py * cos_r
# Apply position offset
return (round(comp_x + rx, 3), round(comp_y + ry, 3))
# ---------------------------------------------------------------------------
# Section extraction helpers
# ---------------------------------------------------------------------------
def _extract_section(content: str, keyword: str) -> str | None:
"""Extract the text of a top-level ``(keyword ...)`` section using bracket counting."""
marker = f"({keyword}"
start = content.find(marker)
if start == -1:
return None
depth = 0
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
return content[start : i + 1]
return None
def _extract_named_section(content: str, keyword: str, name: str) -> str | None:
"""Extract ``(keyword "name" ...)`` section, matching the exact name.
Avoids matching sub-units like ``"Device:R_0_1"`` when looking for
``"Device:R"``.
"""
marker = f'({keyword} "{name}"'
pos = 0
while True:
start = content.find(marker, pos)
if start == -1:
return None
# Verify exact name match (next char must be whitespace or close paren)
after = start + len(marker)
if after < len(content) and content[after] not in (" ", "\n", "\r", "\t", ")"):
pos = after
continue
# Extract using bracket counting
depth = 0
for i in range(start, len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
return content[start : i + 1]
return None

View File

@ -74,6 +74,7 @@ class TestAddPowerSymbolToPin:
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100))
pin_pos = sch.get_component_pin_position("C1", "1")
assert pin_pos is not None
result = add_power_symbol_to_pin(
sch=sch,
pin_position=(pin_pos.x, pin_pos.y),
@ -93,6 +94,8 @@ class TestAddPowerSymbolToPin:
pin1 = sch.get_component_pin_position("R1", "1")
pin2 = sch.get_component_pin_position("R1", "2")
assert pin1 is not None
assert pin2 is not None
r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3")
r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND")

292
tests/test_sexp_parser.py Normal file
View File

@ -0,0 +1,292 @@
"""Tests for the S-expression parser utilities.
These tests do NOT require kicad-sch-api they test raw file parsing.
"""
import os
import tempfile
import pytest
from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
# Minimal .kicad_sch content with global labels and lib_symbols
SAMPLE_SCHEMATIC = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "abc123")
(paper "A4")
(lib_symbols
(symbol "Device:R"
(pin_numbers hide)
(pin_names
(offset 0)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "R"
(at 2.032 0 90)
(effects
(font
(size 1.27 1.27)
)
)
)
(symbol "Device:R_0_1"
(polyline
(pts
(xy -1.016 -2.54)
(xy -1.016 2.54)
)
)
)
(symbol "Device:R_1_1"
(pin passive line
(at 0 3.81 270)
(length 2.54)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin passive line
(at 0 -3.81 90)
(length 2.54)
(name "~"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
(symbol "Espressif:ESP32-P4"
(pin_names
(offset 1.016)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(symbol "Espressif:ESP32-P4_0_1"
(pin input line
(at -25.4 22.86 0)
(length 2.54)
(name "GPIO0"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin power_in line
(at 0 30.48 270)
(length 2.54)
(name "VDD"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin output line
(at 25.4 22.86 180)
(length 2.54)
(name "TX"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "3"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
)
(global_label "ESP_3V3"
(shape input)
(at 127 95.25 180)
(uuid "def456")
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
)
(global_label "GND"
(shape input)
(at 200.5 150.75 0)
(uuid "ghi789")
)
(global_label "SPI_CLK"
(shape output)
(at 300 200 90)
(uuid "jkl012")
)
(label "LOCAL_NET"
(at 100 100 0)
(uuid "mno345")
)
)
"""
@pytest.fixture
def sample_schematic_file():
"""Write the sample schematic to a temp file and return its path."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".kicad_sch", delete=False, encoding="utf-8"
) as f:
f.write(SAMPLE_SCHEMATIC)
path = f.name
yield path
os.unlink(path)
class TestParseGlobalLabels:
def test_finds_all_global_labels(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
assert len(labels) == 3
def test_extracts_text_and_position(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
texts = {lbl["text"] for lbl in labels}
assert texts == {"ESP_3V3", "GND", "SPI_CLK"}
esp = next(lbl for lbl in labels if lbl["text"] == "ESP_3V3")
assert esp["x"] == pytest.approx(127.0)
assert esp["y"] == pytest.approx(95.25)
gnd = next(lbl for lbl in labels if lbl["text"] == "GND")
assert gnd["x"] == pytest.approx(200.5)
assert gnd["y"] == pytest.approx(150.75)
def test_does_not_include_local_labels(self, sample_schematic_file):
labels = parse_global_labels(sample_schematic_file)
texts = {lbl["text"] for lbl in labels}
assert "LOCAL_NET" not in texts
def test_nonexistent_file_returns_empty(self):
labels = parse_global_labels("/nonexistent/path.kicad_sch")
assert labels == []
class TestParseLibSymbolPins:
def test_finds_resistor_pins(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R")
assert len(pins) == 2
nums = {p["number"] for p in pins}
assert nums == {"1", "2"}
pin1 = next(p for p in pins if p["number"] == "1")
assert pin1["name"] == "~"
assert pin1["type"] == "passive"
assert pin1["x"] == pytest.approx(0.0)
assert pin1["y"] == pytest.approx(3.81)
def test_finds_custom_ic_pins(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4")
assert len(pins) == 3
names = {p["name"] for p in pins}
assert names == {"GPIO0", "VDD", "TX"}
gpio = next(p for p in pins if p["name"] == "GPIO0")
assert gpio["number"] == "1"
assert gpio["type"] == "input"
assert gpio["x"] == pytest.approx(-25.4)
assert gpio["y"] == pytest.approx(22.86)
assert gpio["rotation"] == pytest.approx(0.0)
vdd = next(p for p in pins if p["name"] == "VDD")
assert vdd["type"] == "power_in"
def test_does_not_match_subunit_prefix(self, sample_schematic_file):
# "Espressif:ESP32-P4_0_1" is a sub-unit, not the top-level symbol
pins = parse_lib_symbol_pins(sample_schematic_file, "Espressif:ESP32-P4_0")
assert len(pins) == 0
def test_nonexistent_lib_id_returns_empty(self, sample_schematic_file):
pins = parse_lib_symbol_pins(sample_schematic_file, "NoSuchLib:Missing")
assert pins == []
def test_nonexistent_file_returns_empty(self):
pins = parse_lib_symbol_pins("/nonexistent/path.kicad_sch", "Device:R")
assert pins == []
class TestTransformPinToSchematic:
def test_zero_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 0)
assert sx == pytest.approx(100.0)
assert sy == pytest.approx(103.81)
def test_90_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 90)
assert sx == pytest.approx(100 - 3.81, abs=0.01)
assert sy == pytest.approx(100.0, abs=0.01)
def test_180_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 180)
assert sx == pytest.approx(100.0, abs=0.01)
assert sy == pytest.approx(100 - 3.81, abs=0.01)
def test_270_degree_rotation(self):
sx, sy = transform_pin_to_schematic(0, 3.81, 100, 100, 270)
assert sx == pytest.approx(100 + 3.81, abs=0.01)
assert sy == pytest.approx(100.0, abs=0.01)
def test_mirror_x(self):
sx, sy = transform_pin_to_schematic(5, 0, 100, 100, 0, mirror_x=True)
assert sx == pytest.approx(95.0)
assert sy == pytest.approx(100.0)