diff --git a/src/mckicad/utils/sexp_parser.py b/src/mckicad/utils/sexp_parser.py index a93b737..e7aea56 100644 --- a/src/mckicad/utils/sexp_parser.py +++ b/src/mckicad/utils/sexp_parser.py @@ -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 diff --git a/tests/test_sexp_parser.py b/tests/test_sexp_parser.py index 38c82e6..f4a6ed4 100644 --- a/tests/test_sexp_parser.py +++ b/tests/test_sexp_parser.py @@ -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")