From d62ff4a7c81b0e4247dee0d58536de8f4e2a1178 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Mon, 18 May 2026 13:39:22 -0400 Subject: [PATCH] Fix: handle missing library_link attribute on Footprint objects in KiCad 9 (#157) KiCad 9's IPC API returns Footprint objects where fp.definition lacks the library_link attribute, causing all component queries to fail with "'Footprint' object has no attribute 'library_link'" warnings and returning 0 components. This adds a hasattr check with fallback to fp.definition.id. Co-authored-by: Claude Opus 4.6 (1M context) --- python/kicad_api/ipc_backend.py | 10 ++- .../test_ipc_footprint_library_link_guard.py | 81 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/test_ipc_footprint_library_link_guard.py diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 27821d0..e2bc956 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -418,7 +418,15 @@ class IPCBoardAPI(BoardAPI): fp.reference_field.text.value if fp.reference_field else "" ), "value": fp.value_field.text.value if fp.value_field else "", - "footprint": str(fp.definition.library_link) if fp.definition else "", + "footprint": ( + str(fp.definition.library_link) + if fp.definition and hasattr(fp.definition, "library_link") + else ( + str(fp.definition.id) + if fp.definition and hasattr(fp.definition, "id") + else "" + ) + ), "position": { "x": to_mm(pos.x) if pos else 0, "y": to_mm(pos.y) if pos else 0, diff --git a/tests/test_ipc_footprint_library_link_guard.py b/tests/test_ipc_footprint_library_link_guard.py new file mode 100644 index 0000000..19f1fb8 --- /dev/null +++ b/tests/test_ipc_footprint_library_link_guard.py @@ -0,0 +1,81 @@ +"""Regression test for IPCBoardAPI.list_components — footprint field guard. + +KiCad 9 IPC sometimes returns Footprint objects whose ``definition`` lacks the +``library_link`` attribute, which used to raise ``AttributeError`` and drop +every component from ``list_components``. The fix guards the access and falls +back to ``definition.id`` when present, or an empty string. +""" + +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + + +# Stub kipy.util.units.to_mm so the module-level import inside list_components +# succeeds without a real kipy installation. +_kipy = sys.modules.setdefault("kipy", MagicMock(name="kipy")) +_kipy_util = sys.modules.setdefault("kipy.util", MagicMock(name="kipy.util")) +_kipy_units = sys.modules.setdefault("kipy.util.units", MagicMock(name="kipy.util.units")) +_kipy_units.to_mm = lambda v: v / 1_000_000 + + +def _make_fp(*, library_link=None, fp_id=None): + """Build a minimal mock Footprint with the requested definition shape.""" + if library_link is not None and fp_id is not None: + definition = SimpleNamespace(library_link=library_link, id=fp_id) + elif library_link is not None: + definition = SimpleNamespace(library_link=library_link) + elif fp_id is not None: + definition = SimpleNamespace(id=fp_id) + else: + definition = SimpleNamespace() # neither attribute + + fp = SimpleNamespace( + position=SimpleNamespace(x=1_000_000, y=2_000_000), + reference_field=SimpleNamespace(text=SimpleNamespace(value="R1")), + value_field=SimpleNamespace(text=SimpleNamespace(value="220")), + definition=definition, + orientation=SimpleNamespace(degrees=0), + layer="F.Cu", + id="fp-uuid-1", + ) + return fp + + +def _list_components_with(fps): + """Run IPCBoardAPI.list_components against a list of mock footprints.""" + from kicad_api.ipc_backend import IPCBoardAPI + + api = IPCBoardAPI.__new__(IPCBoardAPI) + fake_board = MagicMock() + fake_board.get_footprints.return_value = fps + with patch.object(api, "_get_board", return_value=fake_board, create=True): + return api.list_components() + + +def test_definition_with_library_link_returned_as_footprint(): + fp = _make_fp(library_link="Library:Footprint") + [comp] = _list_components_with([fp]) + assert comp["footprint"] == "Library:Footprint" + + +def test_definition_without_library_link_falls_back_to_id(): + """Regression: the bug we are guarding against.""" + fp = _make_fp(fp_id="some-fp-id") + [comp] = _list_components_with([fp]) + # Old code raised AttributeError and dropped the component entirely. + assert comp["footprint"] == "some-fp-id" + assert comp["reference"] == "R1" + + +def test_definition_without_either_attribute_returns_empty_string(): + fp = _make_fp() + [comp] = _list_components_with([fp]) + assert comp["footprint"] == "" + # And the component is still emitted (not silently dropped) + assert comp["reference"] == "R1"