From 1f095cff59e227139188a1d82d2fa579c4cbc6bc Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Mon, 18 May 2026 13:39:33 -0400 Subject: [PATCH] fix(loader): read global sym-lib-table; quoted URIs with spaces (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dynamic_symbol_loader only consulted the project-local sym-lib-table and a hardcoded list of bundled symbol directories, so libraries registered via the user-global sym-lib-table (Preferences > Manage Symbol Libraries > Global) were invisible to add_schematic_component. Common case: company libraries that live under OneDrive / a network share / any other custom path the user added through the GUI. Also widened the sym-lib-table parser regex to accept quoted URIs (and quoted names) that contain spaces — required for paths like "C:/Users/.../OneDrive - Company/Documents/KiCad/...". The old bare- word capture stopped at the first space. Search order is now: 1. Project sym-lib-table 2. User-global sym-lib-table (~/AppData/Roaming/kicad//sym-lib-table on Windows, ~/.config/kicad//sym-lib-table on Linux, ~/Library/Preferences/kicad//sym-lib-table on macOS) 3. Bundled / well-known symbol directories Co-authored-by: Claude Opus 4.7 (1M context) --- python/commands/dynamic_symbol_loader.py | 48 +++++++- tests/test_loader_global_sym_lib_table.py | 139 ++++++++++++++++++++++ 2 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/test_loader_global_sym_lib_table.py diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index eeea4c5..583d749 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -62,7 +62,12 @@ class DynamicSymbolLoader: Search order: 1. Project-specific sym-lib-table (if project_path is set) - 2. Global KiCad symbol library directories + 2. Global KiCad sym-lib-table (~/AppData/Roaming/kicad//sym-lib-table on + Windows, ~/.config/kicad//sym-lib-table on Linux, + ~/Library/Preferences/kicad//sym-lib-table on macOS) — covers user- + registered libraries that live outside the bundled symbol directories + (e.g. company libraries in OneDrive, network shares, custom paths). + 3. Bundled / well-known KiCad symbol library directories. """ # 1. Check project-specific sym-lib-table if self.project_path: @@ -73,7 +78,17 @@ class DynamicSymbolLoader: logger.info(f"Found '{library_name}' in project sym-lib-table: {resolved}") return resolved - # 2. Fall back to global KiCad symbol directories + # 2. Check global user sym-lib-table + for global_table in self._global_sym_lib_table_paths(): + if global_table.exists(): + resolved = self._resolve_library_from_table(global_table, library_name) + if resolved: + logger.info( + f"Found '{library_name}' in global sym-lib-table {global_table}: {resolved}" + ) + return resolved + + # 3. Fall back to bundled / well-known KiCad symbol directories for lib_dir in self.find_kicad_symbol_libraries(): lib_file = lib_dir / f"{library_name}.kicad_sym" if lib_file.exists(): @@ -82,20 +97,43 @@ class DynamicSymbolLoader: logger.warning(f"Library file not found: {library_name}.kicad_sym") return None + def _global_sym_lib_table_paths(self) -> list: + """Candidate paths for the user-global sym-lib-table, newest version first.""" + home = Path.home() + versions = ["10.0", "9.0", "8.0"] + bases = [] + if os.name == "nt": + bases.append(home / "AppData" / "Roaming" / "kicad") + else: + bases.append(home / ".config" / "kicad") + bases.append(home / "Library" / "Preferences" / "kicad") # macOS + candidates = [] + for base in bases: + for v in versions: + candidates.append(base / v / "sym-lib-table") + return candidates + def _resolve_library_from_table(self, table_path: Path, library_name: str) -> Optional[Path]: """Parse a sym-lib-table file and return the resolved path for the given library nickname.""" try: with open(table_path, "r", encoding="utf-8") as f: content = f.read() + # Name and URI may be quoted (with embedded spaces, e.g. OneDrive paths) + # or bare. Match a quoted "..." form first, otherwise a bareword that + # excludes whitespace and parens. lib_pattern = ( - r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?' + r"\(lib\s+" + r'\(name\s+(?:"([^"]+)"|([^"\)\s]+))\)\s*' + r"\(type\s+[^)]+\)\s*" + r'\(uri\s+(?:"([^"]+)"|([^"\)\s]+))' ) for match in re.finditer(lib_pattern, content, re.IGNORECASE): - nickname = match.group(1) + # Groups: 1=quoted name, 2=bare name, 3=quoted uri, 4=bare uri + nickname = match.group(1) or match.group(2) if nickname != library_name: continue - uri = match.group(2) + uri = match.group(3) or match.group(4) resolved = self._resolve_sym_uri(uri) if resolved and Path(resolved).exists(): return Path(resolved) diff --git a/tests/test_loader_global_sym_lib_table.py b/tests/test_loader_global_sym_lib_table.py new file mode 100644 index 0000000..b629512 --- /dev/null +++ b/tests/test_loader_global_sym_lib_table.py @@ -0,0 +1,139 @@ +"""Regression tests for DynamicSymbolLoader.find_library_file. + +Covers the global-sym-lib-table fallback and the quoted-URI parsing path. +The bug these guard against: libraries registered via KiCad's GUI +(Preferences > Manage Symbol Libraries > Global) live in the user-global +sym-lib-table only; the loader previously consulted only the project-local +table and a hardcoded list of bundled symbol directories, so any company +library mounted from OneDrive / a network share / a custom path was invisible +to add_schematic_component. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.dynamic_symbol_loader import DynamicSymbolLoader + + +def _write_lib(tmp_path: Path, name: str, symbols: list) -> Path: + """Write a minimal .kicad_sym file with the given symbol names.""" + parts = [f"(kicad_symbol_lib (version 20231120) (generator test)"] + for sym in symbols: + parts.append(f' (symbol "{sym}" (pin_numbers (hide yes)) (pin_names (hide yes))') + parts.append(f' (property "Reference" "R" (at 0 0 0))') + parts.append(f' (property "Value" "{sym}" (at 0 0 0))') + parts.append(f" )") + parts.append(")") + path = tmp_path / f"{name}.kicad_sym" + path.write_text("\n".join(parts), encoding="utf-8") + return path + + +def _write_table(table_path: Path, libs: list) -> None: + """Write a sym-lib-table file. libs = list of (name, uri, quote_uri).""" + lines = ["(sym_lib_table"] + for name, uri, quote in libs: + uri_str = f'"{uri}"' if quote else uri + lines.append(f' (lib (name "{name}")(type "KiCad")(uri {uri_str})(options "")(descr ""))') + lines.append(")") + table_path.parent.mkdir(parents=True, exist_ok=True) + table_path.write_text("\n".join(lines), encoding="utf-8") + + +def test_global_sym_lib_table_resolves_library(monkeypatch, tmp_path): + """Library registered only in the user-global table must resolve.""" + # Lay out a fake user home with the library in a non-standard location + fake_home = tmp_path / "home" + fake_home.mkdir() + lib_dir = tmp_path / "external_libs" + lib_dir.mkdir() + lib_file = _write_lib(lib_dir, "MyCompanyLib", ["R_220"]) + + # Place the user-global sym-lib-table where Windows KiCad keeps it + if os.name == "nt": + global_table = fake_home / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table" + else: + global_table = fake_home / ".config" / "kicad" / "9.0" / "sym-lib-table" + _write_table(global_table, [("MyCompanyLib", str(lib_file), True)]) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + loader = DynamicSymbolLoader(project_path=None) + + resolved = loader.find_library_file("MyCompanyLib") + assert resolved is not None + assert Path(resolved).resolve() == lib_file.resolve() + + +def test_quoted_uri_with_spaces(monkeypatch, tmp_path): + """URIs containing spaces (e.g. OneDrive paths) must be parsed correctly.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + # Path with embedded space, like 'OneDrive - Company' + lib_dir = tmp_path / "OneDrive - Company" / "Documents" / "KiCad" / "9.0" / "symbols" + lib_dir.mkdir(parents=True) + lib_file = _write_lib(lib_dir, "SpacedLib", ["R_390"]) + + if os.name == "nt": + global_table = fake_home / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table" + else: + global_table = fake_home / ".config" / "kicad" / "9.0" / "sym-lib-table" + _write_table(global_table, [("SpacedLib", str(lib_file), True)]) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + loader = DynamicSymbolLoader(project_path=None) + + resolved = loader.find_library_file("SpacedLib") + assert resolved is not None + assert Path(resolved).resolve() == lib_file.resolve() + # And the symbol can be extracted end-to-end through the resolved path + block = loader.extract_symbol_from_library("SpacedLib", "R_390") + assert block is not None + assert "R_390" in block + + +def test_project_table_still_takes_precedence(monkeypatch, tmp_path): + """Project-local sym-lib-table must override the global one.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + + # Two libraries with the same nickname but different content + project_lib_dir = tmp_path / "proj_libs" + project_lib_dir.mkdir() + project_lib = _write_lib(project_lib_dir, "DualLib", ["FROM_PROJECT"]) + + global_lib_dir = tmp_path / "global_libs" + global_lib_dir.mkdir() + global_lib = _write_lib(global_lib_dir, "DualLib", ["FROM_GLOBAL"]) + + project_path = tmp_path / "project" + project_path.mkdir() + _write_table(project_path / "sym-lib-table", [("DualLib", str(project_lib), True)]) + + if os.name == "nt": + global_table = fake_home / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table" + else: + global_table = fake_home / ".config" / "kicad" / "9.0" / "sym-lib-table" + _write_table(global_table, [("DualLib", str(global_lib), True)]) + + monkeypatch.setattr(Path, "home", lambda: fake_home) + loader = DynamicSymbolLoader(project_path=str(project_path)) + + resolved = loader.find_library_file("DualLib") + assert Path(resolved).resolve() == project_lib.resolve() + # Confirm content came from project, not global + block = loader.extract_symbol_from_library("DualLib", "FROM_PROJECT") + assert block is not None + + +def test_unknown_library_returns_none(monkeypatch, tmp_path): + """Looking up a library that isn't in any table returns None, not a path.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + loader = DynamicSymbolLoader(project_path=None) + assert loader.find_library_file("NoSuchLib") is None