fix(loader): read global sym-lib-table; quoted URIs with spaces (#164)
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/<ver>/sym-lib-table on Windows, ~/.config/kicad/<ver>/sym-lib-table on Linux, ~/Library/Preferences/kicad/<ver>/sym-lib-table on macOS) 3. Bundled / well-known symbol directories Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<ver>/sym-lib-table on
|
||||
Windows, ~/.config/kicad/<ver>/sym-lib-table on Linux,
|
||||
~/Library/Preferences/kicad/<ver>/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)
|
||||
|
||||
139
tests/test_loader_global_sym_lib_table.py
Normal file
139
tests/test_loader_global_sym_lib_table.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user