kicad-mcp/tests/test_lib_resolver.py
Ryan Malloy 1dca4d6912 Fix symbol library resolution on macOS and without a sym-lib-table
Two bugs left built-in symbols (Device, Connector, ...) unresolvable, which
cascaded into every batch place/label/wire producing zero results:

- get_kicad_symbol_dir() only checked KICAD9/KICAD8_SYMBOL_DIR and a single
  Linux path. It ignored the generic KICAD_SYMBOL_DIR, KiCad 10, and the
  macOS/Windows install locations, so it returned None on macOS even with the
  symbols present. Now checks KICAD_SYMBOL_DIR + KICAD{10,9,8,7}_SYMBOL_DIR and
  per-platform default paths.
- resolve_library_path() never searched the system symbol directory for
  <lib>.kicad_sym; the system dir was only used for variable substitution
  inside a sym-lib-table. With no table configured (tests, fresh installs),
  built-in libraries were unreachable. Added a system-dir fallback and covered
  the KiCad 10/7 global-table config dirs.

Update test_env_override to clear ambient symbol-dir vars so the override under
test is decisive regardless of the host environment.
2026-07-11 17:09:34 -06:00

469 lines
15 KiB
Python

"""Tests for unified symbol library resolution."""
import os
import time
import pytest
from mckicad.utils.lib_resolver import (
_lib_table_cache,
extract_pin_unit_map,
extract_symbol_pins,
extract_symbol_sexp_text,
get_kicad_symbol_dir,
load_sym_lib_table,
resolve_library_path,
resolve_symbol,
)
from mckicad.utils.sexp_tree import find_recursive, parse
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
DEVICE_LIB = "/usr/share/kicad/symbols/Device.kicad_sym"
GLOBAL_SYM_LIB_TABLE_9 = os.path.expanduser("~/.config/kicad/9.0/sym-lib-table")
GLOBAL_SYM_LIB_TABLE_8 = os.path.expanduser("~/.config/kicad/8.0/sym-lib-table")
has_kicad = os.path.isfile(DEVICE_LIB)
has_global_table_9 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_9)
has_global_table_8 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_8)
has_global_table = has_global_table_9 or has_global_table_8
requires_kicad = pytest.mark.skipif(not has_kicad, reason="KiCad not installed")
requires_global_table = pytest.mark.skipif(
not has_global_table, reason="No global sym-lib-table",
)
@pytest.fixture
def project_with_lib(tmp_path):
"""Create a project directory with a custom symbol library."""
# Create a minimal .kicad_sym file
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "MyResistor"
(symbol "MyResistor_1_1"
(pin passive line
(at 0 2.54 270)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -2.54 90)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "MyLib.kicad_sym"
lib_file.write_text(lib_content)
# Create sym-lib-table pointing to it
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
'(uri "${KIPRJMOD}/MyLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_file = tmp_path / "sym-lib-table"
table_file.write_text(table_content)
# Create .kicad_pro so it looks like a project
pro_file = tmp_path / "test.kicad_pro"
pro_file.write_text('{"meta": {}}')
return tmp_path
@pytest.fixture
def multi_unit_lib(tmp_path):
"""Create a project with a multi-unit symbol (like TL072)."""
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "TL072"
(symbol "TL072_1_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "TL072_2_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "7" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "OpAmps.kicad_sym"
lib_file.write_text(lib_content)
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "OpAmps")(type "KiCad")'
'(uri "${KIPRJMOD}/OpAmps.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
(tmp_path / "test.kicad_pro").write_text('{"meta": {}}')
return tmp_path
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear the lib table cache between tests."""
_lib_table_cache.clear()
yield
_lib_table_cache.clear()
# ---------------------------------------------------------------------------
# resolve_library_path
# ---------------------------------------------------------------------------
@requires_global_table
class TestResolveLibraryPathGlobalTable:
def test_finds_device_lib(self):
path = resolve_library_path("Device")
assert path is not None
assert path.endswith("Device.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathProjectTable:
def test_finds_project_lib(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
assert path.endswith("MyLib.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathPrecedence:
@requires_global_table
def test_project_overrides_global(self, tmp_path):
"""If both project and global define a library, project wins."""
# Create a project-level Device.kicad_sym
project_lib = tmp_path / "Device.kicad_sym"
project_lib.write_text("(kicad_symbol_lib (version 20241209))")
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "Device")(type "KiCad")'
'(uri "${KIPRJMOD}/Device.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
path = resolve_library_path("Device", project_dir=str(tmp_path))
assert path is not None
# Should resolve to the project-local one, not /usr/share/kicad/...
assert str(tmp_path) in path
class TestResolveLibraryPathDirectFallback:
def test_finds_lib_in_same_dir(self, tmp_path):
"""Falls back to directory search when no sym-lib-table."""
lib_file = tmp_path / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
assert path.endswith("CustomLib.kicad_sym")
def test_finds_lib_in_libs_subdir(self, tmp_path):
libs_dir = tmp_path / "libs"
libs_dir.mkdir()
lib_file = libs_dir / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
def test_nonexistent_library(self, tmp_path):
path = resolve_library_path("NoSuchLib", project_dir=str(tmp_path))
assert path is None
# ---------------------------------------------------------------------------
# resolve_symbol
# ---------------------------------------------------------------------------
@requires_kicad
@requires_global_table
class TestResolveSymbolDeviceR:
def test_resolves_resistor(self):
sym = resolve_symbol("Device:R")
assert sym is not None
assert sym.tag == "symbol"
assert sym.values[0] == "R"
pins = find_recursive(sym, "pin")
assert len(pins) == 2
class TestResolveSymbolProjectLib:
def test_resolves_from_project(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
assert sym.values[0] == "MyResistor"
class TestResolveSymbolEdgeCases:
def test_nonexistent_library(self):
assert resolve_symbol("NoSuchLib:NoSuchSymbol") is None
def test_nonexistent_symbol_in_library(self, project_with_lib):
assert resolve_symbol(
"MyLib:NoSuchSymbol", project_dir=str(project_with_lib),
) is None
def test_invalid_lib_id_no_colon(self):
assert resolve_symbol("JustAName") is None
def test_invalid_lib_id_empty_parts(self):
assert resolve_symbol(":Symbol") is None
assert resolve_symbol("Lib:") is None
# ---------------------------------------------------------------------------
# extract_symbol_pins
# ---------------------------------------------------------------------------
class TestExtractSymbolPinsFormat:
def test_pin_dict_format(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
pins = extract_symbol_pins(sym)
assert len(pins) == 2
for pin in pins:
assert "number" in pin
assert "name" in pin
assert "type" in pin
assert "shape" in pin
assert "x" in pin
assert "y" in pin
assert "rotation" in pin
assert "length" in pin
# Check specific values
pin_numbers = {p["number"] for p in pins}
assert pin_numbers == {"1", "2"}
pin1 = next(p for p in pins if p["number"] == "1")
assert pin1["type"] == "passive"
assert pin1["shape"] == "line"
assert pin1["x"] == 0.0
assert pin1["y"] == 2.54
assert pin1["rotation"] == 270.0
assert pin1["length"] == 1.27
# ---------------------------------------------------------------------------
# extract_pin_unit_map
# ---------------------------------------------------------------------------
class TestExtractPinUnitMap:
def test_multi_unit_mapping(self, multi_unit_lib):
sym = resolve_symbol("OpAmps:TL072", project_dir=str(multi_unit_lib))
assert sym is not None
pin_map = extract_pin_unit_map(sym)
# Unit 1 pins: 1, 2, 3
assert pin_map["1"] == 1
assert pin_map["2"] == 1
assert pin_map["3"] == 1
# Unit 2 pins: 5, 6, 7
assert pin_map["5"] == 2
assert pin_map["6"] == 2
assert pin_map["7"] == 2
def test_single_unit_empty_map(self, project_with_lib):
"""Single-unit symbols don't have meaningful sub-symbol structure."""
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
# MyResistor only has _1_1 sub-symbol, so all pins map to unit 1
pin_map = extract_pin_unit_map(sym)
# For a single-unit symbol, having a map is fine (unit 1)
if pin_map:
assert all(v == 1 for v in pin_map.values())
# ---------------------------------------------------------------------------
# extract_symbol_sexp_text
# ---------------------------------------------------------------------------
class TestExtractSymbolSexpText:
def test_returns_raw_text(self, project_with_lib):
text = extract_symbol_sexp_text(
"MyLib:MyResistor", project_dir=str(project_with_lib),
)
assert text is not None
assert text.startswith('(symbol "MyResistor"')
assert text.endswith(")")
# Should be parseable
node = parse(text)
assert node.tag == "symbol"
def test_nonexistent_returns_none(self):
assert extract_symbol_sexp_text("NoLib:NoSym") is None
# ---------------------------------------------------------------------------
# get_kicad_symbol_dir
# ---------------------------------------------------------------------------
class TestGetKicadSymbolDir:
@requires_kicad
def test_detected(self):
sym_dir = get_kicad_symbol_dir()
assert sym_dir is not None
assert os.path.isdir(sym_dir)
assert os.path.isfile(os.path.join(sym_dir, "Device.kicad_sym"))
def test_env_override(self, tmp_path, monkeypatch):
# Clear any ambient symbol-dir vars so the override under test is the
# only candidate (a real KICAD_SYMBOL_DIR or install path would
# otherwise take precedence).
for var in (
"KICAD_SYMBOL_DIR",
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD7_SYMBOL_DIR",
):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("KICAD9_SYMBOL_DIR", str(tmp_path))
sym_dir = get_kicad_symbol_dir()
assert sym_dir == str(tmp_path)
# ---------------------------------------------------------------------------
# load_sym_lib_table
# ---------------------------------------------------------------------------
class TestLoadSymLibTable:
def test_parses_project_table(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result = load_sym_lib_table(table_path)
assert "MyLib" in result
assert "${KIPRJMOD}/MyLib.kicad_sym" in result["MyLib"]
@requires_global_table
def test_parses_global_table(self):
table_path = GLOBAL_SYM_LIB_TABLE_9 if has_global_table_9 else GLOBAL_SYM_LIB_TABLE_8
result = load_sym_lib_table(table_path)
assert "Device" in result
def test_nonexistent_file(self):
result = load_sym_lib_table("/no/such/file")
assert result == {}
# ---------------------------------------------------------------------------
# Variable substitution
# ---------------------------------------------------------------------------
class TestVariableSubstitution:
def test_kiprjmod(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
# Should NOT contain ${KIPRJMOD} — it should be resolved
assert "${KIPRJMOD}" not in path
assert str(project_with_lib) in path
@requires_global_table
def test_kicad9_symbol_dir(self):
"""Global table uses ${KICAD9_SYMBOL_DIR} which should be resolved."""
path = resolve_library_path("Device")
assert path is not None
assert "${KICAD9_SYMBOL_DIR}" not in path
assert "${KICAD8_SYMBOL_DIR}" not in path
# ---------------------------------------------------------------------------
# Cache invalidation
# ---------------------------------------------------------------------------
class TestCacheInvalidation:
def test_cache_hit(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result1 = load_sym_lib_table(table_path)
result2 = load_sym_lib_table(table_path)
assert result1 == result2
# Should be the same dict object (cache hit)
assert result1 is result2
def test_cache_invalidation_on_mtime(self, project_with_lib):
table_path = project_with_lib / "sym-lib-table"
str_path = str(table_path)
result1 = load_sym_lib_table(str_path)
assert "MyLib" in result1
# Modify the file (add another library)
time.sleep(0.05) # ensure mtime changes
new_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
'(uri "${KIPRJMOD}/MyLib.kicad_sym")(options "")(descr ""))\n'
' (lib (name "NewLib")(type "KiCad")'
'(uri "${KIPRJMOD}/NewLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_path.write_text(new_content)
result2 = load_sym_lib_table(str_path)
assert "NewLib" in result2
# Should NOT be the same object (cache was invalidated)
assert result1 is not result2