Files
KiC-AI/mcp_servers/kicad_text.py
T
2026-03-08 03:17:21 +01:00

872 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Shared KiCad text parsing helpers for lightweight MCP servers.
These helpers intentionally work without pcbnew or KiCad system libraries.
They read `.kicad_sch` and `.kicad_pcb` files directly and only report facts
that can be derived from the available local files.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import math
import json
import os
import re
import shutil
import subprocess
DEFAULT_KICAD_V10_IMAGE = "kill_life_cad-kicad-mcp:latest"
_CONTAINER_CACHE_STATE: Optional[Path] = None
_CONTAINER_CACHE_FAILED = False
_CONTAINER_FOOTPRINT_INDEX_CACHE: Optional[Tuple[str, int, Dict[str, Path]]] = None
_FOOTPRINT_NAME_CACHE: Dict[str, Tuple[str, ...]] = {}
INDEX_VERSION = 1
SYMBOL_INDEX_FILENAME = ".symbol_index.sqlite3"
FOOTPRINT_INDEX_FILENAME = ".footprint_index.json"
PROPERTY_PATTERN = re.compile(r'\(property\s+"([^"]+)"\s+"([^"]*)"')
LIB_ID_PATTERN = re.compile(r'\(lib_id\s+"([^"]+)"')
QUOTED_SYMBOL_PATTERN = re.compile(r'^\(symbol\s+"([^"]+)"')
LABEL_PATTERN = re.compile(r'\((?:global_label|label|hierarchical_label)\s+"([^"]+)"')
TITLE_PATTERN = re.compile(r'\(title\s+"([^"]*)"')
LAYER_PATTERN = re.compile(r'\(\s*\d+\s+"([^"]+)"\s+([^)]+)\)')
EDGE_LINE_PATTERN = re.compile(
r'\(gr_line\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_RECT_PATTERN = re.compile(
r'\(gr_rect\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_CIRCLE_PATTERN = re.compile(
r'\(gr_circle\b.*?\(center\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
EDGE_ARC_PATTERN = re.compile(
r'\(gr_arc\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(mid\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
r'\(layer\s+"Edge\.Cuts"\)',
re.DOTALL,
)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def env_is_enabled(name: str, default: bool = True) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() not in {"0", "false", "no", "off", ""}
def container_library_cache_root() -> Path:
override = os.getenv("KICAD_AUX_CONTAINER_CACHE_DIR")
if override:
return Path(override).expanduser()
return Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp" / "kicad-v10-libs"
def container_symbols_dir(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / "symbols"
def container_footprints_dir(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / "footprints"
def symbol_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / SYMBOL_INDEX_FILENAME
def footprint_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / FOOTPRINT_INDEX_FILENAME
def ensure_container_library_cache(refresh: bool = False) -> Optional[Path]:
global _CONTAINER_CACHE_STATE, _CONTAINER_CACHE_FAILED
if _CONTAINER_CACHE_STATE is not None and not refresh:
return _CONTAINER_CACHE_STATE
if _CONTAINER_CACHE_FAILED and not refresh:
return None
if not env_is_enabled("KICAD_AUX_USE_CONTAINER_LIBS", True):
_CONTAINER_CACHE_FAILED = True
return None
if shutil.which("docker") is None:
_CONTAINER_CACHE_FAILED = True
return None
image = os.getenv("KICAD_MCP_IMAGE", DEFAULT_KICAD_V10_IMAGE)
cache_root = container_library_cache_root()
symbols_dir = cache_root / "symbols"
footprints_dir = cache_root / "footprints"
if not refresh and symbols_dir.is_dir() and footprints_dir.is_dir():
_CONTAINER_CACHE_STATE = cache_root
return cache_root
if subprocess.run(
["docker", "image", "inspect", image],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
_CONTAINER_CACHE_FAILED = True
return None
container_id = ""
tmp_root = cache_root.with_name(f".{cache_root.name}.tmp")
try:
if tmp_root.exists():
shutil.rmtree(tmp_root)
tmp_root.parent.mkdir(parents=True, exist_ok=True)
tmp_root.mkdir(parents=True, exist_ok=True)
container_id = subprocess.check_output(["docker", "create", image], text=True).strip()
subprocess.run(
["docker", "cp", f"{container_id}:/usr/share/kicad/symbols", str(tmp_root)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["docker", "cp", f"{container_id}:/usr/share/kicad/footprints", str(tmp_root)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if cache_root.exists():
shutil.rmtree(cache_root)
tmp_root.rename(cache_root)
_CONTAINER_CACHE_STATE = cache_root
_CONTAINER_CACHE_FAILED = False
return cache_root
except (subprocess.CalledProcessError, OSError):
_CONTAINER_CACHE_FAILED = True
return None
finally:
if container_id:
subprocess.run(
["docker", "rm", "-f", container_id],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if tmp_root.exists():
shutil.rmtree(tmp_root, ignore_errors=True)
def _safe_relpath(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))
except ValueError:
return str(path)
def _footprint_index_metadata(cache_root: Path) -> Dict[str, object]:
footprints_dir = container_footprints_dir(cache_root)
dirs = sorted(footprints_dir.glob("*.pretty"))
return {
"version": INDEX_VERSION,
"footprints_dir": str(footprints_dir),
"footprints_dir_mtime_ns": footprints_dir.stat().st_mtime_ns if footprints_dir.exists() else None,
"library_count": len(dirs),
"library_names": [path.name for path in dirs],
}
def _metadata_matches(current: Dict[str, object], cached: Dict[str, object]) -> bool:
for key, value in current.items():
if cached.get(key) != value:
return False
return True
def load_container_symbol_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Tuple[List["KiCadComponent"], List[Path]]:
try:
from .kicad_symbol_sqlite import load_all_symbol_components
except ImportError:
from kicad_symbol_sqlite import load_all_symbol_components # type: ignore
return load_all_symbol_components(cache_root, refresh=refresh)
def load_container_footprint_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Dict[str, Path]:
global _CONTAINER_FOOTPRINT_INDEX_CACHE
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return {}
resolved_root = resolved_root.expanduser()
current_meta = _footprint_index_metadata(resolved_root)
cache_signature = int(current_meta.get("footprints_dir_mtime_ns") or 0)
cache_key = str(resolved_root)
if (
_CONTAINER_FOOTPRINT_INDEX_CACHE is not None
and _CONTAINER_FOOTPRINT_INDEX_CACHE[0] == cache_key
and _CONTAINER_FOOTPRINT_INDEX_CACHE[1] == cache_signature
and not refresh
):
return _CONTAINER_FOOTPRINT_INDEX_CACHE[2]
index_file = footprint_index_path(resolved_root)
if index_file.exists() and not refresh:
try:
payload = json.loads(index_file.read_text(encoding="utf-8"))
cached_meta = payload.get("meta", {})
if isinstance(cached_meta, dict) and _metadata_matches(current_meta, cached_meta):
libraries: Dict[str, Path] = {}
_FOOTPRINT_NAME_CACHE.clear()
for library_name, entry in payload.get("libraries", {}).items():
relative_path = entry.get("relative_path")
if not relative_path:
continue
full_path = resolved_root / relative_path
libraries[library_name] = full_path
_FOOTPRINT_NAME_CACHE[str(full_path)] = tuple(entry.get("footprints", []))
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
return libraries
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
libraries: Dict[str, Path] = {}
serialized: Dict[str, Dict[str, object]] = {}
_FOOTPRINT_NAME_CACHE.clear()
footprints_dir = container_footprints_dir(resolved_root)
for library_path in sorted(footprints_dir.glob("*.pretty")):
if not library_path.is_dir():
continue
library_name = library_path.stem
if library_name in libraries:
continue
footprint_names = tuple(sorted(path.stem for path in library_path.glob("*.kicad_mod")))
libraries[library_name] = library_path
_FOOTPRINT_NAME_CACHE[str(library_path)] = footprint_names
serialized[library_name] = {
"relative_path": _safe_relpath(library_path, resolved_root),
"footprints": list(footprint_names),
}
payload = {"meta": current_meta, "libraries": serialized}
index_file.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
return libraries
def prewarm_container_indexes(refresh: bool = False) -> Dict[str, object]:
cache_root = ensure_container_library_cache(refresh=refresh)
if cache_root is None:
return {
"cache_root": None,
"symbol_libraries": 0,
"symbol_components": 0,
"footprint_libraries": 0,
}
try:
from .kicad_symbol_sqlite import build_symbol_sqlite_index
except ImportError:
from kicad_symbol_sqlite import build_symbol_sqlite_index # type: ignore
symbol_summary = build_symbol_sqlite_index(cache_root, refresh=refresh)
footprint_libraries = load_container_footprint_index(cache_root, refresh=refresh)
return {
"cache_root": str(cache_root),
"symbol_libraries": symbol_summary.get("symbol_libraries", 0),
"symbol_components": symbol_summary.get("symbol_components", 0),
"footprint_libraries": len(footprint_libraries),
}
def clear_container_indexes(cache_root: Optional[Path] = None) -> None:
global _CONTAINER_FOOTPRINT_INDEX_CACHE
resolved_root = cache_root or container_library_cache_root()
legacy_symbol_index = resolved_root / ".symbol_index.json"
for index_file in (symbol_index_path(resolved_root), footprint_index_path(resolved_root)):
try:
index_file.unlink()
except FileNotFoundError:
pass
try:
legacy_symbol_index.unlink()
except FileNotFoundError:
pass
_CONTAINER_FOOTPRINT_INDEX_CACHE = None
def extract_blocks(text: str, keyword: str) -> List[str]:
"""Return every S-expression block whose head is `keyword`."""
target = f"({keyword}"
blocks: List[str] = []
start = 0
while True:
idx = text.find(target, start)
if idx == -1:
return blocks
depth = 0
in_string = False
escaped = False
for pos in range(idx, len(text)):
char = text[pos]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
continue
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
blocks.append(text[idx : pos + 1])
start = pos + 1
break
else:
return blocks
def parse_properties(block: str) -> Dict[str, str]:
return {match.group(1): match.group(2) for match in PROPERTY_PATTERN.finditer(block)}
def parse_float(value: Optional[str]) -> Optional[float]:
if value in (None, ""):
return None
cleaned = str(value).strip().replace(",", ".")
match = re.search(r"-?\d+(?:\.\d+)?", cleaned)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def parse_int(value: Optional[str]) -> Optional[int]:
if value in (None, ""):
return None
cleaned = str(value).strip().replace(",", "")
match = re.search(r"-?\d+", cleaned)
if not match:
return None
try:
return int(match.group(0))
except ValueError:
return None
def derive_package(footprint: str) -> str:
if not footprint:
return ""
footprint_name = footprint.split(":", 1)[-1]
match = re.search(
r"(0201|0402|0603|0805|1206|1210|SOT-23(?:-[0-9]+)?|SOT-223|"
r"SOIC-[0-9]+|TSSOP-[0-9]+|QFN-[0-9]+|TQFP-[0-9]+|LQFP-[0-9]+|"
r"DIP-[0-9]+|PinHeader_[^:_]+|JST_[^:_]+)",
footprint_name,
re.IGNORECASE,
)
return match.group(1) if match else footprint_name
def infer_component_type(
reference: str = "",
lib_id: str = "",
value: str = "",
description: str = "",
category: str = "",
) -> str:
reference = (reference or "").upper()
lib_id_lower = (lib_id or "").lower()
haystack = " ".join([lib_id_lower, value.lower(), description.lower(), category.lower()])
if "regulator" in haystack or "ldo" in haystack:
return "regulator"
if "microcontroller" in haystack or "mcu" in haystack:
return "microcontroller"
if "opamp" in haystack or "amplifier" in haystack:
return "opamp"
if "connector" in haystack or lib_id_lower.startswith("connector"):
return "connector"
if "switch" in haystack:
return "switch"
if "mosfet" in haystack:
return "mosfet"
if "transistor" in haystack or lib_id_lower.startswith("device:q_"):
return "transistor"
if "crystal" in haystack or "oscillator" in haystack:
return "crystal"
if "inductor" in haystack or lib_id_lower.startswith("device:l"):
return "inductor"
if "capacitor" in haystack or lib_id_lower.startswith("device:c"):
return "capacitor"
if "resistor" in haystack or lib_id_lower.startswith("device:r"):
return "resistor"
if "led" in haystack:
return "led"
if "diode" in haystack or lib_id_lower.startswith("device:d"):
return "diode"
if "power" in haystack or lib_id_lower.startswith("power:"):
return "power"
if reference.startswith("R"):
return "resistor"
if reference.startswith("C"):
return "capacitor"
if reference.startswith("L"):
return "inductor"
if reference.startswith("D"):
return "diode"
if reference.startswith("Q"):
return "transistor"
if reference.startswith("U"):
return "ic"
if reference.startswith("J"):
return "connector"
if reference.startswith("Y"):
return "crystal"
if reference.startswith("SW"):
return "switch"
return "other"
def derive_manufacturer(lib_id: str, manufacturer: str) -> str:
if manufacturer:
return manufacturer
library_name = lib_id.split(":", 1)[0]
parts = [part for part in library_name.split("_") if part]
if len(parts) >= 2 and parts[0] in {"MCU", "CPU", "DSP", "FPGA", "CPLD"}:
aliases = {
"ST": "STMicroelectronics",
"NXP": "NXP",
"TI": "Texas Instruments",
}
return aliases.get(parts[1], parts[1])
return manufacturer
def derive_category(lib_id: str, category: str) -> str:
if category:
return category
return lib_id.split(":", 1)[0].replace("_", " ")
@dataclass
class KiCadComponent:
reference: str
value: str
footprint: str
datasheet: str
description: str
manufacturer: str
part_number: str
lcsc_id: str
lib_id: str
source_path: str
category: str
component_type: str
unit_price: Optional[float] = None
stock: Optional[int] = None
@property
def package(self) -> str:
return derive_package(self.footprint)
def to_dict(
self,
include_values: bool = True,
include_footprints: bool = True,
) -> Dict[str, object]:
result = asdict(self)
result["type"] = result.pop("component_type")
result["package"] = self.package if include_footprints else ""
result["value"] = self.value if include_values else None
result["footprint"] = self.footprint if include_footprints else None
result["price"] = self.unit_price
return result
def _build_component(
*,
reference: str,
value: str,
footprint: str,
datasheet: str,
description: str,
manufacturer: str,
part_number: str,
lcsc_id: str,
lib_id: str,
source_path: Path,
category: str,
price_raw: str,
stock_raw: str,
) -> KiCadComponent:
component_type = infer_component_type(
reference=reference,
lib_id=lib_id,
value=value,
description=description,
category=category,
)
return KiCadComponent(
reference=reference,
value=value,
footprint=footprint,
datasheet=datasheet,
description=description,
manufacturer=derive_manufacturer(lib_id, manufacturer),
part_number=part_number,
lcsc_id=lcsc_id,
lib_id=lib_id,
source_path=str(source_path),
category=derive_category(lib_id, category),
component_type=component_type,
unit_price=parse_float(price_raw),
stock=parse_int(stock_raw),
)
def parse_inline_library_symbols(path: Path) -> List[KiCadComponent]:
text = read_text(path)
components: List[KiCadComponent] = []
for block in extract_blocks(text, "symbol"):
match = QUOTED_SYMBOL_PATTERN.match(block)
if not match:
continue
symbol_name = match.group(1)
if re.search(r"_\d+_\d+$", symbol_name):
continue
if path.suffix == ".kicad_sym" and ":" not in symbol_name:
symbol_ref = f"{path.stem}:{symbol_name}"
else:
symbol_ref = symbol_name
properties = parse_properties(block)
components.append(
_build_component(
reference=properties.get("Reference", symbol_ref.split(":")[-1]),
value=properties.get("Value", symbol_ref.split(":")[-1]),
footprint=properties.get("Footprint", ""),
datasheet=properties.get("Datasheet", ""),
description=properties.get("Description", ""),
manufacturer=properties.get("Manufacturer", ""),
part_number=properties.get(
"Part",
properties.get("MPN", symbol_ref.split(":")[-1]),
),
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
lib_id=symbol_ref,
source_path=path,
category=properties.get("Category", ""),
price_raw=properties.get("Price", ""),
stock_raw=properties.get("Stock", ""),
)
)
return components
def parse_schematic_components(
path: Path,
*,
include_templates: bool = False,
) -> List[KiCadComponent]:
text = read_text(path)
components: List[KiCadComponent] = []
for block in extract_blocks(text, "symbol"):
lib_id_match = LIB_ID_PATTERN.search(block)
if not lib_id_match:
continue
properties = parse_properties(block)
reference = properties.get("Reference", "")
if not reference:
continue
if not include_templates and reference.startswith("_TEMPLATE"):
continue
components.append(
_build_component(
reference=reference,
value=properties.get("Value", lib_id_match.group(1).split(":")[-1]),
footprint=properties.get("Footprint", ""),
datasheet=properties.get("Datasheet", ""),
description=properties.get("Description", ""),
manufacturer=properties.get("Manufacturer", ""),
part_number=properties.get(
"Part",
properties.get("MPN", lib_id_match.group(1).split(":")[-1]),
),
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
lib_id=lib_id_match.group(1),
source_path=path,
category=properties.get("Category", ""),
price_raw=properties.get("Price", ""),
stock_raw=properties.get("Stock", ""),
)
)
return sorted(components, key=lambda item: item.reference)
def parse_schematic_metrics(path: Path) -> Dict[str, object]:
text = read_text(path)
labels = sorted(set(LABEL_PATTERN.findall(text)))
return {
"wire_count": text.count("(wire "),
"junction_count": text.count("(junction "),
"label_count": len(labels),
"labels": labels,
"sheet_count": text.count("(sheet "),
}
def _extract_layer_block(text: str) -> str:
blocks = extract_blocks(text, "layers")
return blocks[0] if blocks else ""
def _edge_cut_points(text: str) -> List[Tuple[float, float]]:
points: List[Tuple[float, float]] = []
for match in EDGE_LINE_PATTERN.finditer(text):
x1, y1, x2, y2 = map(float, match.groups())
points.extend([(x1, y1), (x2, y2)])
for match in EDGE_RECT_PATTERN.finditer(text):
x1, y1, x2, y2 = map(float, match.groups())
points.extend([(x1, y1), (x2, y2)])
for match in EDGE_CIRCLE_PATTERN.finditer(text):
cx, cy, ex, ey = map(float, match.groups())
radius = math.dist((cx, cy), (ex, ey))
points.extend(
[
(cx - radius, cy - radius),
(cx + radius, cy + radius),
]
)
for match in EDGE_ARC_PATTERN.finditer(text):
coords = list(map(float, match.groups()))
points.extend([(coords[0], coords[1]), (coords[2], coords[3]), (coords[4], coords[5])])
return points
def parse_pcb_summary(path: Path) -> Dict[str, object]:
text = read_text(path)
layer_block = _extract_layer_block(text)
copper_layers: List[str] = []
for name, layer_type in LAYER_PATTERN.findall(layer_block):
if name.endswith(".Cu") or "signal" in layer_type:
copper_layers.append(name)
points = _edge_cut_points(text)
width_mm = None
height_mm = None
if points:
xs = [point[0] for point in points]
ys = [point[1] for point in points]
width_mm = round(max(xs) - min(xs), 3)
height_mm = round(max(ys) - min(ys), 3)
title_match = TITLE_PATTERN.search(text)
return {
"title": title_match.group(1) if title_match else path.stem,
"copper_layers": copper_layers,
"footprint_count": len(re.findall(r"\(footprint\b", text)),
"segment_count": len(re.findall(r"\(segment\b", text)),
"via_count": len(re.findall(r"\(via\b", text)),
"zone_count": len(re.findall(r"\(zone\b", text)),
"net_count": len(set(re.findall(r'\(net\s+\d+\s+"([^"]*)"', text))),
"edge_cuts_count": text.count('Edge.Cuts'),
"width_mm": width_mm,
"height_mm": height_mm,
}
def discover_search_roots(base_dir: Path, env_var: str = "KICAD_AUX_MCP_ROOTS") -> List[Path]:
extra_roots: List[Path] = []
env_value = os.getenv(env_var, "")
if env_value:
extra_roots.extend(Path(entry).expanduser() for entry in env_value.split(os.pathsep) if entry)
sibling_templates = base_dir.parents[1] / "kicad_mcp_server" / "python" / "templates"
container_cache_root = ensure_container_library_cache(
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
)
default_roots = [
Path.cwd(),
base_dir.parents[1],
sibling_templates,
Path.home() / "Documents" / "KiCad",
Path.home() / ".local" / "share" / "kicad",
Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp",
container_cache_root,
]
roots: List[Path] = []
seen = set()
for root in [*extra_roots, *default_roots]:
if root is None:
continue
root = root.expanduser()
key = str(root.resolve()) if root.exists() else str(root)
if key in seen or not root.exists():
continue
seen.add(key)
roots.append(root)
return roots
def discover_schematic_files(roots: Sequence[Path], limit: int = 64) -> List[Path]:
files: List[Path] = []
seen = set()
for root in roots:
for path in root.rglob("*.kicad_sch"):
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
files.append(path)
if len(files) >= limit:
return files
return files
def discover_symbol_library_files(roots: Sequence[Path], limit: int = 512) -> List[Path]:
files: List[Path] = []
seen = set()
for root in roots:
for path in root.rglob("*.kicad_sym"):
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
files.append(path)
if len(files) >= limit:
return files
return files
def discover_footprint_libraries(roots: Sequence[Path], limit: int = 256) -> Dict[str, Path]:
libraries: Dict[str, Path] = {}
container_root = ensure_container_library_cache(
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
)
container_libraries_loaded = False
for root in roots:
if container_root is not None:
try:
root_resolved = root.resolve()
container_resolved = container_root.resolve()
if root_resolved == container_resolved or container_resolved in root_resolved.parents:
if not container_libraries_loaded:
for name, path in load_container_footprint_index(container_root).items():
libraries.setdefault(name, path)
if len(libraries) >= limit:
return libraries
container_libraries_loaded = True
continue
except FileNotFoundError:
pass
for path in root.rglob("*.pretty"):
if not path.is_dir():
continue
libraries.setdefault(path.stem, path)
if len(libraries) >= limit:
return libraries
return libraries
def footprint_exists(footprint_spec: str, libraries: Dict[str, Path]) -> bool:
if not footprint_spec:
return False
if ":" in footprint_spec:
library_name, footprint_name = footprint_spec.split(":", 1)
library_path = libraries.get(library_name)
if not library_path:
return False
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
if cached_names:
return footprint_name in cached_names
return (library_path / f"{footprint_name}.kicad_mod").exists()
footprint_name = footprint_spec
for path in libraries.values():
cached_names = _FOOTPRINT_NAME_CACHE.get(str(path))
if cached_names:
if footprint_name in cached_names:
return True
continue
if (path / f"{footprint_name}.kicad_mod").exists():
return True
return False
def search_footprints(
libraries: Dict[str, Path],
patterns: Iterable[str],
limit: int = 5,
) -> List[str]:
results: List[str] = []
seen = set()
lowered_patterns = [pattern.lower() for pattern in patterns if pattern]
for library_name, library_path in libraries.items():
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
if cached_names:
footprint_names = list(cached_names)
else:
footprint_names = [candidate.stem for candidate in library_path.glob("*.kicad_mod")]
for footprint_name in footprint_names:
search_text = f"{library_name}:{footprint_name}".lower()
if lowered_patterns and not any(pattern in search_text for pattern in lowered_patterns):
continue
full_name = f"{library_name}:{footprint_name}"
if full_name in seen:
continue
seen.add(full_name)
results.append(full_name)
if len(results) >= limit:
return results
return results