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) <noreply@anthropic.com>
This commit is contained in:
Gavin Colonese
2026-05-18 13:39:22 -04:00
committed by GitHub
parent 8d79f7b801
commit d62ff4a7c8
2 changed files with 90 additions and 1 deletions

View File

@@ -418,7 +418,15 @@ class IPCBoardAPI(BoardAPI):
fp.reference_field.text.value if fp.reference_field else "" fp.reference_field.text.value if fp.reference_field else ""
), ),
"value": fp.value_field.text.value if fp.value_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": { "position": {
"x": to_mm(pos.x) if pos else 0, "x": to_mm(pos.x) if pos else 0,
"y": to_mm(pos.y) if pos else 0, "y": to_mm(pos.y) if pos else 0,

View File

@@ -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"