"""
kicad_symbol_cache.py
Provides a caching mechanism for KiCad symbol libraries.
"""
import hashlib
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
# Import the parser
from .kicad_symbol_parser import parse_kicad_sym_file
# Python implementation for symbol cache
# Add performance timing
try:
from ..core.performance_profiler import quick_time
except ImportError:
# Fallback if profiler not available
def quick_time(name):
def decorator(func):
return func
return decorator
logger = logging.getLogger(__name__)
[docs]
class SymbolLibCache:
"""
Python fallback implementation of SymbolLibCache.
Restored from the original working implementation.
"""
_instance = None
_initialized = False
# Class-level data structures for singleton pattern
_library_data: Dict[str, Dict[str, Any]] = {}
_symbol_index: Dict[str, Dict[str, Any]] = {}
_library_index: Dict[str, Path] = {}
_index_built: bool = False
_library_categories: Dict[str, str] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
[docs]
def __init__(self):
if not self._initialized:
logger.debug("Initializing Python fallback SymbolLibCache")
self._cache_dir = self._get_cache_dir()
self._cache_dir.mkdir(parents=True, exist_ok=True)
self.__class__._initialized = True
@classmethod
def _get_cache_dir(cls) -> Path:
"""Get the cache directory for symbol data."""
cache_dir = os.environ.get("CIRCUIT_SYNTH_CACHE_DIR")
if cache_dir:
cache_path = Path(cache_dir)
else:
cache_path = Path.home() / ".cache" / "circuit_synth" / "symbols"
# Ensure the directory exists
cache_path.mkdir(parents=True, exist_ok=True)
return cache_path
[docs]
@classmethod
@quick_time("Get Symbol Data")
def get_symbol_data(cls, symbol_id: str) -> Dict[str, Any]:
"""Get symbol data by symbol ID (LibraryName:SymbolName)."""
instance = cls()
# Validate format
if ":" not in symbol_id:
raise ValueError(
f"Invalid symbol_id format; expected 'LibName:SymbolName', got '{symbol_id}'"
)
try:
lib_name, sym_name = symbol_id.split(":", 1)
except ValueError:
raise ValueError(
f"Invalid symbol_id format; expected 'LibName:SymbolName', got '{symbol_id}'"
)
# Try lazy symbol search first (much faster)
try:
return instance._lazy_symbol_search(symbol_id)
except Exception as e:
logger.debug(f"Lazy search failed for {symbol_id}: {e}")
# Build index if needed (fallback)
instance._build_complete_index()
# Find the library file
lib_path = instance._find_library_file(lib_name)
if not lib_path:
raise FileNotFoundError(f"Library '{lib_name}' not found")
# Load the library
library_data = instance._load_library(lib_path)
if not library_data or "symbols" not in library_data:
raise FileNotFoundError(f"Failed to load library '{lib_name}'")
# Find the symbol in the library
if sym_name not in library_data["symbols"]:
raise FileNotFoundError(
f"Symbol '{sym_name}' not found in library '{lib_name}'"
)
return library_data["symbols"][sym_name]
[docs]
@classmethod
def get_symbol_data_by_name(cls, symbol_name: str) -> Dict[str, Any]:
"""Get symbol data by name only (searches all libraries)."""
instance = cls()
# Build index if needed
instance._build_complete_index()
# Check if symbol exists in index
if symbol_name not in cls._symbol_index:
raise FileNotFoundError(f"Symbol '{symbol_name}' not found in any library")
# Get the library containing this symbol
lib_name = cls._symbol_index[symbol_name]["lib_name"]
symbol_id = f"{lib_name}:{symbol_name}"
# Use the regular get_symbol_data method
return cls.get_symbol_data(symbol_id)
[docs]
@classmethod
def find_symbol_library(cls, symbol_name: str) -> Optional[str]:
"""Find which library contains the given symbol name."""
instance = cls()
instance._build_complete_index()
if symbol_name in cls._symbol_index:
return cls._symbol_index[symbol_name]["lib_name"]
return None
[docs]
@classmethod
def get_all_libraries(cls) -> Dict[str, str]:
"""Get a dictionary of all available libraries."""
instance = cls()
instance._build_complete_index()
return {
lib_name: str(lib_path) for lib_name, lib_path in cls._library_index.items()
}
[docs]
@classmethod
def get_all_symbols(cls) -> Dict[str, str]:
"""Get a dictionary of all available symbols."""
instance = cls()
instance._build_complete_index()
return {
sym_name: info["lib_name"] for sym_name, info in cls._symbol_index.items()
}
def _build_complete_index(self) -> None:
"""
Build a complete index of ALL symbols from ALL .kicad_sym files in KICAD_SYMBOL_DIR.
This enables automatic discovery of any symbol without knowing the library name.
"""
if self.__class__._index_built:
return
logger.debug("Building complete symbol library index...")
# Parse KICAD_SYMBOL_DIR - can contain multiple paths separated by colons
kicad_dirs = self.__class__._parse_kicad_symbol_dirs()
if not kicad_dirs:
logger.error("No valid KiCad symbol directories found")
self.__class__._index_built = (
True # Mark as built to avoid repeated attempts
)
return
logger.debug(f"Scanning symbol libraries in {len(kicad_dirs)} directories:")
for dir_path in kicad_dirs:
logger.debug(f" - {dir_path}")
# Build library index
self.__class__._library_index.clear()
self.__class__._symbol_index.clear()
total_files = 0
for symbol_dir in kicad_dirs:
# Find all .kicad_sym files recursively in this directory
try:
symbol_files = list(symbol_dir.rglob("*.kicad_sym"))
total_files += len(symbol_files)
logger.debug(f"Found {len(symbol_files)} symbol files in {symbol_dir}")
for sym_file in symbol_files:
lib_name = sym_file.stem
# Handle duplicate library names from different directories
original_lib_name = lib_name
counter = 1
while lib_name in self._library_index:
lib_name = f"{original_lib_name}_{counter}"
counter += 1
self.__class__._library_index[lib_name] = sym_file
# Parse the file to get symbol names (lightweight parsing)
try:
symbol_names = self._extract_symbol_names_fast(sym_file)
for symbol_name in symbol_names:
# Store in symbol index for fast lookup
# If symbol exists in multiple libraries, keep the first one found
if symbol_name not in self.__class__._symbol_index:
self.__class__._symbol_index[symbol_name] = {
"lib_name": lib_name,
"lib_path": sym_file,
}
logger.debug(
f"Indexed {len(symbol_names)} symbols from {lib_name}"
)
except Exception as e:
logger.warning(f"Failed to index symbols from {sym_file}: {e}")
except Exception as e:
logger.warning(f"Failed to scan directory {symbol_dir}: {e}")
self.__class__._index_built = True
logger.debug(
f"Symbol index built: {len(self.__class__._library_index)} libraries, {len(self.__class__._symbol_index)} symbols"
)
@classmethod
def _find_kicad_symbol_dirs(cls) -> List[Path]:
"""
Find KiCad symbol directories. Alias for _parse_kicad_symbol_dirs.
"""
return cls._parse_kicad_symbol_dirs()
@classmethod
def _parse_kicad_symbol_dirs(cls) -> List[Path]:
"""
Parse KICAD_SYMBOL_DIR environment variable which can contain multiple paths
separated by colons (like PATH variable). Returns list of valid directory paths.
If no environment variable is set, try common KiCad installation paths.
"""
kicad_dir_env = os.environ.get("KICAD_SYMBOL_DIR", "")
valid_dirs = []
if kicad_dir_env:
# Split by colon (Unix-style) or semicolon (Windows-style)
separator = ";" if os.name == "nt" else ":"
dir_paths = kicad_dir_env.split(separator)
for dir_path in dir_paths:
dir_path = dir_path.strip()
if not dir_path:
continue
path_obj = Path(
dir_path
).resolve() # Resolve relative paths to absolute
if path_obj.exists() and path_obj.is_dir():
valid_dirs.append(path_obj)
logger.debug(f"Added valid symbol directory: {path_obj}")
else:
logger.warning(f"Skipping invalid symbol directory: {path_obj}")
else:
# Try common KiCad installation paths as fallback
common_paths = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols", # macOS
"/usr/share/kicad/symbols", # Linux (KiCad 6+)
"/usr/share/kicad/library", # Linux (KiCad 5)
"/usr/local/share/kicad/symbols", # Linux alternative
"/usr/local/share/kicad/library", # Linux alternative (KiCad 5)
"C:\\Program Files\\KiCad\\share\\kicad\\symbols", # Windows
"C:\\Program Files (x86)\\KiCad\\share\\kicad\\symbols", # Windows 32-bit
]
for path_str in common_paths:
path_obj = Path(path_str)
if path_obj.exists() and path_obj.is_dir():
valid_dirs.append(path_obj)
logger.info(f"Auto-detected KiCad symbol directory: {path_obj}")
break # Use the first valid path found
# If no valid directories from environment, try defaults
if not valid_dirs:
logger.warning("KICAD_SYMBOL_DIR not set or invalid, trying default paths")
default_dirs = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/", # macOS
"/usr/share/kicad/symbols/", # Linux (KiCad 6+)
"/usr/share/kicad/library/", # Linux (KiCad 5)
"/usr/local/share/kicad/symbols/", # Linux alternative
"/usr/local/share/kicad/library/", # Linux alternative (KiCad 5)
"C:\\Program Files\\KiCad\\share\\kicad\\symbols\\", # Windows
]
for dir_path in default_dirs:
path_obj = Path(dir_path).resolve()
if path_obj.exists() and path_obj.is_dir():
valid_dirs.append(path_obj)
logger.info(f"Using default symbol directory: {path_obj}")
return valid_dirs
def _extract_symbol_names_fast(self, sym_file_path: Path) -> List[str]:
"""
Quickly extract symbol names from a .kicad_sym file without full parsing.
"""
symbol_names = []
try:
with open(sym_file_path, "r", encoding="utf-8") as f:
content = f.read()
# Quick regex to find symbol names
# Look for (symbol "SymbolName" patterns
pattern = r'\(symbol\s+"([^"]+)"'
matches = re.findall(pattern, content)
# Filter out sub-symbols (those with underscores and numbers at the end)
for match in matches:
if not re.match(r".*_\d+_\d+$", match):
symbol_names.append(match)
except Exception as e:
logger.warning(f"Failed to extract symbol names from {sym_file_path}: {e}")
return symbol_names
def _find_library_file(self, lib_name: str) -> Optional[Path]:
"""
Find the actual file path for a given library name.
"""
# First, try the complete index
self._build_complete_index()
if lib_name in self.__class__._library_index:
return self.__class__._library_index[lib_name]
# If not found in library index, check if we have it in symbol index
# This handles cases where tests manually set up the symbol index
for symbol_info in self.__class__._symbol_index.values():
if symbol_info["lib_name"] == lib_name:
lib_path = symbol_info["lib_path"]
# Add to library index for future lookups
self.__class__._library_index[lib_name] = lib_path
return lib_path
# Check current directory
candidate = Path.cwd() / f"{lib_name}.kicad_sym"
if candidate.exists():
return candidate
return None
@quick_time("Load Symbol Library")
def _load_library(self, lib_path: Path) -> Dict[str, Any]:
"""
Load and cache a library file.
"""
str_path = str(lib_path.resolve())
# Check in-memory cache first
if str_path in self.__class__._library_data:
# Verify file hasn't changed
existing_hash = self.__class__._library_data[str_path]["file_hash"]
current_hash = self._compute_file_hash(str_path)
if existing_hash == current_hash:
return self.__class__._library_data[str_path]
else:
# File changed, re-parse
logger.debug(f"File changed, re-parsing {str_path}")
del self.__class__._library_data[str_path]
# Check disk cache
cache_file = self._cache_dir / self._cache_filename(lib_path)
current_hash = self._compute_file_hash(str_path)
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("file_hash") == current_hash:
logger.debug(f"Loaded library from disk cache: {cache_file}")
self._library_data[str_path] = data
return data
except Exception as e:
logger.warning(f"Failed to load library cache file {cache_file}: {e}")
# Parse the actual .kicad_sym file
logger.debug(f"Parsing .kicad_sym file: {lib_path}")
try:
parsed_data = parse_kicad_sym_file(str_path)
library_data = {
"file_hash": current_hash,
"symbols": parsed_data.get("symbols", {}),
}
# Store in memory
self._library_data[str_path] = library_data
# Store to disk
try:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(library_data, f, indent=2)
logger.debug(f"Wrote library cache to {cache_file}")
except Exception as e:
logger.warning(f"Failed writing cache file {cache_file}: {e}")
return library_data
except Exception as e:
logger.error(f"Failed to parse library file {lib_path}: {e}")
return {}
def _compute_file_hash(self, file_path: str) -> str:
"""Compute the SHA-256 of the file."""
hasher = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
return hasher.hexdigest()
def _cache_filename(self, lib_path: Path) -> str:
"""Return a safe cache file name."""
path_hash = hashlib.sha1(str(lib_path.resolve()).encode("utf-8")).hexdigest()[
:8
]
stem = lib_path.stem.replace(".", "_")
return f"{stem}_{path_hash}.json"
@classmethod
def _is_cache_expired(cls, cache_time: float, ttl_hours: int) -> bool:
"""Check if cache is expired based on TTL."""
current_time = time.time()
return (current_time - cache_time) > (ttl_hours * 3600)
def _lazy_symbol_search(self, symbol_id: str) -> Dict[str, Any]:
"""
Fast lazy search for symbols without building complete index.
Uses multiple strategies in order of speed.
"""
try:
lib_name, sym_name = symbol_id.split(":", 1)
except ValueError:
raise ValueError(f"Invalid symbol_id format: {symbol_id}")
# Strategy 1: File-based discovery (fastest - < 0.01s)
symbol_file = self._find_symbol_file_by_name(lib_name)
if symbol_file and symbol_file.exists():
logger.debug(f"Found symbol file by name: {symbol_file}")
return self._load_symbol_from_file_direct(symbol_file, symbol_id)
# Strategy 2: Ripgrep search (fast - < 0.1s)
symbol_file = self._ripgrep_symbol_search(lib_name, sym_name)
if symbol_file:
logger.debug(f"Found symbol via ripgrep: {symbol_file}")
return self._load_symbol_from_file_direct(symbol_file, symbol_id)
# Strategy 3: Python grep fallback (medium - < 1s)
symbol_file = self._python_grep_search(lib_name, sym_name)
if symbol_file:
logger.debug(f"Found symbol via Python grep: {symbol_file}")
return self._load_symbol_from_file_direct(symbol_file, symbol_id)
raise FileNotFoundError(f"Symbol {symbol_id} not found via lazy search")
def _find_symbol_file_by_name(self, lib_name: str) -> Optional[Path]:
"""Find symbol file using intelligent file name guessing."""
kicad_dirs = self._parse_kicad_symbol_dirs()
for kicad_dir in kicad_dirs:
# Try exact library name first
candidates = [
kicad_dir / f"{lib_name}.kicad_sym",
kicad_dir / f"{lib_name.lower()}.kicad_sym",
kicad_dir / f"{lib_name.upper()}.kicad_sym",
kicad_dir / f"{lib_name.replace('_', '-')}.kicad_sym",
kicad_dir / f"{lib_name.replace('-', '_')}.kicad_sym",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _ripgrep_symbol_search(self, lib_name: str, sym_name: str) -> Optional[Path]:
"""Use ripgrep to quickly find symbol in .kicad_sym files."""
import subprocess
kicad_dirs = self._parse_kicad_symbol_dirs()
for kicad_dir in kicad_dirs:
try:
# Search for the specific symbol pattern
result = subprocess.run(
[
"rg",
"-l", # list files only
f'\\(symbol\\s+"{sym_name}"', # regex pattern for symbol definition
str(kicad_dir),
"--type-add",
"kicad:*.kicad_sym",
"--type",
"kicad",
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
# Return first match
first_file = result.stdout.strip().split("\n")[0]
return Path(first_file)
except (FileNotFoundError, subprocess.TimeoutExpired):
# ripgrep not available or too slow, skip
continue
return None
def _python_grep_search(self, lib_name: str, sym_name: str) -> Optional[Path]:
"""Fallback Python-based grep search for symbols."""
import re
kicad_dirs = self._parse_kicad_symbol_dirs()
pattern = re.compile(rf'\(symbol\s+"{re.escape(sym_name)}"')
for kicad_dir in kicad_dirs:
# Search .kicad_sym files
for sym_file in kicad_dir.rglob("*.kicad_sym"):
try:
# Read file in chunks to avoid memory issues
with open(sym_file, "r", encoding="utf-8") as f:
chunk = f.read(8192) # Read first 8KB
if pattern.search(chunk):
return sym_file
except (IOError, UnicodeDecodeError):
continue
return None
def _load_symbol_from_file_direct(
self, symbol_file: Path, symbol_id: str
) -> Dict[str, Any]:
"""Load specific symbol from a known file and return data directly."""
try:
lib_name, sym_name = symbol_id.split(":", 1)
# Load the library
library_data = self._load_library(symbol_file)
if not library_data or "symbols" not in library_data:
raise ValueError(f"No symbols found in {symbol_file}")
# Find the specific symbol
symbol_data = library_data["symbols"].get(sym_name)
if not symbol_data:
raise KeyError(f"Symbol '{sym_name}' not found in library '{lib_name}'")
logger.debug(f"Successfully loaded {symbol_id} from {symbol_file}")
return symbol_data
except Exception as e:
logger.warning(f"Failed to load symbol {symbol_id} from {symbol_file}: {e}")
raise
# Module-level flag for checking availability (already defined above)