Add external .kicad_sym library search for custom symbol pin resolution

parse_lib_symbol_pins() now falls back to searching external .kicad_sym
library files when a symbol isn't embedded in the schematic's lib_symbols
section. Splits lib_id ("LibName:SymName") to locate the library file,
then parses pins using the bare symbol name.

Search order: schematic dir, libs/, ../libs/, project root, project
root/libs/, kicad/libs/, and sym-lib-table entries with ${KIPRJMOD}
substitution. Handles nonexistent directories gracefully.

Fixes add_power_symbol for script-generated schematics that reference
project library symbols without embedding them (e.g. D1/SMF5.0CA in
ESP32-P4-WIFI6-DEV-KIT library).
This commit is contained in:
Ryan Malloy 2026-03-04 20:17:32 -07:00
parent 7525f3dcdc
commit e88f75f567
2 changed files with 445 additions and 3 deletions

View File

@ -91,6 +91,11 @@ _PIN_RE = re.compile(
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.
Searches the embedded ``lib_symbols`` section first. If the symbol is not
found there (common for script-generated schematics that reference
external project libraries), falls back to searching external
``.kicad_sym`` library files.
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.
@ -103,23 +108,35 @@ def parse_lib_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]:
List of pin dicts with ``number``, ``name``, ``type``, ``shape``,
``x``, ``y``, ``rotation``, and ``length`` fields.
"""
pins = _parse_embedded_symbol_pins(filepath, lib_id)
if pins:
return pins
# Fallback: search external .kicad_sym library files
return _parse_external_lib_symbol_pins(filepath, lib_id)
def _parse_embedded_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]:
"""Extract pins from the embedded ``(lib_symbols ...)`` section of a schematic."""
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
return _extract_pins_from_section(symbol_section)
def _extract_pins_from_section(symbol_section: str) -> list[dict[str, Any]]:
"""Extract all ``(pin ...)`` entries from an s-expression symbol section."""
pins: list[dict[str, Any]] = []
for match in _PIN_RE.finditer(symbol_section):
pins.append({
@ -132,7 +149,186 @@ def parse_lib_symbol_pins(filepath: str, lib_id: str) -> list[dict[str, Any]]:
"rotation": float(match.group(5) or 0),
"length": float(match.group(6)),
})
return pins
# ---------------------------------------------------------------------------
# External library file search
# ---------------------------------------------------------------------------
def _find_project_root(start_path: str) -> str | None:
"""Walk up from a file path to find the directory containing a ``.kicad_pro`` file.
Returns the directory path, or None if no project root is found within
5 levels.
"""
current = os.path.dirname(os.path.abspath(start_path))
for _ in range(5):
if not current or current == os.path.dirname(current):
break
try:
entries = os.listdir(current)
except OSError:
break
for entry in entries:
if entry.endswith(".kicad_pro"):
return current
current = os.path.dirname(current)
return None
def _find_library_file(schematic_path: str, library_name: str) -> str | None:
"""Search for a ``{library_name}.kicad_sym`` file in predictable locations.
Search order:
1. Same directory as the schematic
2. ``libs/`` relative to schematic directory
3. ``../libs/`` relative to schematic directory
4. Project root directory (directory containing ``.kicad_pro``)
5. ``libs/`` relative to project root
6. ``kicad/libs/`` relative to project root
7. Paths from ``sym-lib-table`` file in project root
Args:
schematic_path: Path to the ``.kicad_sch`` file.
library_name: Library name (the part before ``:`` in a lib_id).
Returns:
Absolute path to the ``.kicad_sym`` file, or None.
"""
filename = f"{library_name}.kicad_sym"
sch_dir = os.path.dirname(os.path.abspath(schematic_path))
# Direct search in common locations
candidates = [
os.path.join(sch_dir, filename),
os.path.join(sch_dir, "libs", filename),
os.path.join(sch_dir, os.pardir, "libs", filename),
]
project_root = _find_project_root(schematic_path)
if project_root:
candidates.extend([
os.path.join(project_root, filename),
os.path.join(project_root, "libs", filename),
os.path.join(project_root, "kicad", "libs", filename),
])
# Parse sym-lib-table for explicit library paths
sym_lib_table = os.path.join(project_root, "sym-lib-table")
if os.path.isfile(sym_lib_table):
resolved = _resolve_from_sym_lib_table(
sym_lib_table, library_name, project_root,
)
if resolved:
candidates.append(resolved)
for candidate in candidates:
resolved_path = os.path.normpath(candidate)
if os.path.isfile(resolved_path):
logger.debug("Found library file: %s", resolved_path)
return resolved_path
return None
# Match: (lib (name "NAME") ... (uri "PATH") ...)
_SYM_LIB_TABLE_RE = re.compile(
r'\(lib\s+\(name\s+"([^"]+)"\)'
r'.*?'
r'\(uri\s+"([^"]+)"\)',
re.DOTALL,
)
def _resolve_from_sym_lib_table(
table_path: str, library_name: str, project_root: str,
) -> str | None:
"""Parse a ``sym-lib-table`` file and resolve the URI for a library name.
Handles ``${KIPRJMOD}`` variable substitution (points to project root).
"""
try:
with open(table_path, encoding="utf-8") as f:
content = f.read()
except Exception:
return None
for match in _SYM_LIB_TABLE_RE.finditer(content):
if match.group(1) == library_name:
uri = match.group(2)
# Substitute ${KIPRJMOD} with project root
uri = uri.replace("${KIPRJMOD}", project_root)
return os.path.normpath(uri)
return None
def parse_lib_file_symbol_pins(lib_file_path: str, symbol_name: str) -> list[dict[str, Any]]:
"""Extract pin definitions from a standalone ``.kicad_sym`` library file.
In ``.kicad_sym`` files, the top-level container is ``(kicad_symbol_lib ...)``
and symbols are named without the library prefix (e.g. ``"SMF5.0CA"``
rather than ``"ESP32-P4-WIFI6-DEV-KIT:SMF5.0CA"``).
Args:
lib_file_path: Path to a ``.kicad_sym`` file.
symbol_name: Symbol name without library prefix (e.g. ``SMF5.0CA``).
Returns:
List of pin dicts (same format as :func:`parse_lib_symbol_pins`).
"""
try:
with open(lib_file_path, encoding="utf-8") as f:
content = f.read()
except Exception:
return []
# The whole file is the library — find the symbol directly
symbol_section = _extract_named_section(content, "symbol", symbol_name)
if not symbol_section:
return []
return _extract_pins_from_section(symbol_section)
def _parse_external_lib_symbol_pins(
schematic_path: str, lib_id: str,
) -> list[dict[str, Any]]:
"""Search external ``.kicad_sym`` files for a symbol's pin definitions.
Splits the ``lib_id`` into library name and symbol name, searches for
the library file, and parses pins from it.
Args:
schematic_path: Path to the ``.kicad_sch`` file (search anchor).
lib_id: Full library identifier (e.g. ``ESP32-P4-WIFI6-DEV-KIT:SMF5.0CA``).
Returns:
List of pin dicts, or empty list if the library file isn't found.
"""
if ":" not in lib_id:
return []
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return []
lib_file = _find_library_file(schematic_path, library_name)
if lib_file is None:
logger.debug(
"External library file not found for %s (searched from %s)",
library_name, schematic_path,
)
return []
pins = parse_lib_file_symbol_pins(lib_file, symbol_name)
if pins:
logger.debug(
"Resolved %d pins for %s from external library %s",
len(pins), lib_id, lib_file,
)
return pins

View File

@ -13,6 +13,7 @@ from mckicad.utils.sexp_parser import (
generate_label_sexp,
insert_sexp_before_close,
parse_global_labels,
parse_lib_file_symbol_pins,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
@ -474,3 +475,248 @@ class TestResolvePinPosition:
# At component position (100, 100) with 0 rotation: (100, 103.81)
assert result[0] == pytest.approx(100.0)
assert result[1] == pytest.approx(103.81)
# ---------------------------------------------------------------------------
# External library file tests
# ---------------------------------------------------------------------------
# Minimal .kicad_sym library file with a custom TVS diode symbol
SAMPLE_LIBRARY_FILE = """\
(kicad_symbol_lib
(version 20231120)
(generator "kicad_symbol_editor")
(symbol "SMF5.0CA"
(pin_names
(offset 1.016)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(symbol "SMF5.0CA_0_1"
(pin passive line
(at -10.16 2.54 0)
(length 2.54)
(name "A"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
(pin passive line
(at 10.16 2.54 180)
(length 2.54)
(name "K"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "2"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
)
)
"""
# Schematic that references the library symbol but doesn't embed it
SAMPLE_SCHEMATIC_NO_EMBED = """\
(kicad_sch
(version 20231120)
(generator "eeschema")
(uuid "abc123")
(paper "A4")
(lib_symbols
)
(symbol
(lib_id "MyProject:SMF5.0CA")
(at 100 100 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(uuid "d1-uuid")
(property "Reference" "D1"
(at 100 90 0)
)
)
)
"""
@pytest.fixture
def external_lib_project(tmp_path):
"""Create a project structure with an external library file.
tmp_path/
test_project.kicad_pro
libs/
MyProject.kicad_sym
sheets/
power.kicad_sch
"""
# Project file
pro_file = tmp_path / "test_project.kicad_pro"
pro_file.write_text('{"meta": {"filename": "test_project.kicad_pro"}}')
# Library directory
libs_dir = tmp_path / "libs"
libs_dir.mkdir()
lib_file = libs_dir / "MyProject.kicad_sym"
lib_file.write_text(SAMPLE_LIBRARY_FILE)
# Schematic in a subdirectory (common for multi-sheet projects)
sheets_dir = tmp_path / "sheets"
sheets_dir.mkdir()
sch_file = sheets_dir / "power.kicad_sch"
sch_file.write_text(SAMPLE_SCHEMATIC_NO_EMBED)
return {
"project_root": str(tmp_path),
"lib_file": str(lib_file),
"schematic": str(sch_file),
}
class TestParseLibFileSymbolPins:
"""Tests for parsing pins from standalone .kicad_sym library files."""
def test_finds_pins_in_library_file(self, external_lib_project):
pins = parse_lib_file_symbol_pins(
external_lib_project["lib_file"], "SMF5.0CA",
)
assert len(pins) == 2
names = {p["name"] for p in pins}
assert names == {"A", "K"}
pin_a = next(p for p in pins if p["name"] == "A")
assert pin_a["number"] == "1"
assert pin_a["x"] == pytest.approx(-10.16)
assert pin_a["y"] == pytest.approx(2.54)
pin_k = next(p for p in pins if p["name"] == "K")
assert pin_k["number"] == "2"
assert pin_k["x"] == pytest.approx(10.16)
def test_nonexistent_symbol_returns_empty(self, external_lib_project):
pins = parse_lib_file_symbol_pins(
external_lib_project["lib_file"], "DOES_NOT_EXIST",
)
assert pins == []
def test_nonexistent_file_returns_empty(self):
pins = parse_lib_file_symbol_pins("/nonexistent/lib.kicad_sym", "X")
assert pins == []
class TestExternalLibraryFallback:
"""Tests for parse_lib_symbol_pins falling back to external .kicad_sym files."""
def test_embedded_symbol_found_first(self, sample_schematic_file):
"""When symbol is embedded, don't search external files."""
pins = parse_lib_symbol_pins(sample_schematic_file, "Device:R")
assert len(pins) == 2 # Found in embedded lib_symbols
def test_external_library_fallback(self, external_lib_project):
"""When symbol not embedded, search external .kicad_sym files."""
pins = parse_lib_symbol_pins(
external_lib_project["schematic"], "MyProject:SMF5.0CA",
)
assert len(pins) == 2
names = {p["name"] for p in pins}
assert names == {"A", "K"}
def test_external_lib_not_found_returns_empty(self, tmp_path):
"""When no external library exists, return empty list."""
sch_file = tmp_path / "orphan.kicad_sch"
sch_file.write_text(
"(kicad_sch\n (version 20231120)\n (lib_symbols\n )\n)\n"
)
pins = parse_lib_symbol_pins(str(sch_file), "NoSuchLib:Missing")
assert pins == []
def test_resolve_pin_via_external_lib(self, external_lib_project):
"""resolve_pin_position should find pins via external library."""
from unittest.mock import MagicMock
from mckicad.utils.sexp_parser import resolve_pin_position
sch = MagicMock()
sch.get_component_pin_position.return_value = None
comp = MagicMock()
comp.lib_id = "MyProject:SMF5.0CA"
comp.position = MagicMock()
comp.position.x = 100.0
comp.position.y = 100.0
comp.rotation = 0
comp.mirror = None
sch.components.get.return_value = comp
result = resolve_pin_position(
sch, external_lib_project["schematic"], "D1", "1",
)
assert result is not None
# Pin 1 (A) at (-10.16, 2.54) local, component at (100, 100), 0 rotation
assert result[0] == pytest.approx(100 - 10.16, abs=0.01)
assert result[1] == pytest.approx(100 + 2.54, abs=0.01)
class TestSymLibTableParsing:
"""Tests for sym-lib-table resolution."""
def test_sym_lib_table_resolution(self, tmp_path):
"""Library paths from sym-lib-table should be found."""
from mckicad.utils.sexp_parser import _find_library_file
# Create project structure
pro_file = tmp_path / "project.kicad_pro"
pro_file.write_text('{}')
custom_libs = tmp_path / "custom" / "symbols"
custom_libs.mkdir(parents=True)
lib_file = custom_libs / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
sym_lib_table = tmp_path / "sym-lib-table"
sym_lib_table.write_text(
'(sym_lib_table\n'
' (version 7)\n'
' (lib (name "CustomLib")(type "KiCad")'
'(uri "${KIPRJMOD}/custom/symbols/CustomLib.kicad_sym")'
'(options "")(descr ""))\n'
')\n'
)
sch_path = str(tmp_path / "test.kicad_sch")
result = _find_library_file(sch_path, "CustomLib")
assert result is not None
assert result == os.path.normpath(str(lib_file))
def test_libs_dir_search(self, external_lib_project):
"""Libraries in ../libs/ relative to schematic should be found."""
from mckicad.utils.sexp_parser import _find_library_file
result = _find_library_file(
external_lib_project["schematic"], "MyProject",
)
assert result is not None
assert result.endswith("MyProject.kicad_sym")