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

524 lines
18 KiB
Python

#!/usr/bin/env python3
"""
SQLite-backed KiCad v10 symbol catalog.
This module indexes the exported KiCad v10 symbol libraries once and exposes
search/query helpers for lightweight MCP servers.
"""
from __future__ import annotations
import json
from pathlib import Path
import sqlite3
import tempfile
from typing import Any, Dict, List, Optional, Sequence, Tuple
try:
from .kicad_text import (
KiCadComponent,
container_library_cache_root,
container_symbols_dir,
ensure_container_library_cache,
parse_inline_library_symbols,
)
except ImportError:
from kicad_text import ( # type: ignore
KiCadComponent,
container_library_cache_root,
container_symbols_dir,
ensure_container_library_cache,
parse_inline_library_symbols,
)
INDEX_VERSION = 1
SYMBOL_SQLITE_FILENAME = ".symbol_index.sqlite3"
def symbol_sqlite_index_path(cache_root: Optional[Path] = None) -> Path:
root = cache_root or container_library_cache_root()
return root / SYMBOL_SQLITE_FILENAME
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _fts_term(value: str) -> str:
escaped = value.replace('"', '""').strip()
return f'"{escaped}"'
def _connect(index_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(index_path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=NORMAL")
connection.execute("PRAGMA temp_store=MEMORY")
return connection
def _index_meta(cache_root: Path) -> Dict[str, Any]:
symbols_dir = container_symbols_dir(cache_root)
library_files = sorted(symbols_dir.glob("*.kicad_sym"))
return {
"version": INDEX_VERSION,
"symbols_dir": str(symbols_dir),
"symbols_dir_mtime_ns": symbols_dir.stat().st_mtime_ns if symbols_dir.exists() else None,
"library_count": len(library_files),
"library_names": [path.name for path in library_files],
}
def _metadata_matches(connection: sqlite3.Connection, current_meta: Dict[str, Any]) -> bool:
row = connection.execute("SELECT value FROM metadata WHERE key = 'meta_json'").fetchone()
if row is None:
return False
try:
cached_meta = json.loads(row["value"])
except json.JSONDecodeError:
return False
return cached_meta == current_meta
def _create_schema(connection: sqlite3.Connection) -> None:
connection.executescript(
"""
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE sources (
path TEXT PRIMARY KEY
);
CREATE TABLE symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lib_id TEXT NOT NULL,
reference TEXT NOT NULL,
value TEXT NOT NULL,
footprint TEXT NOT NULL,
datasheet TEXT NOT NULL,
description TEXT NOT NULL,
manufacturer TEXT NOT NULL,
part_number TEXT NOT NULL,
lcsc_id TEXT NOT NULL,
source_path TEXT NOT NULL,
category TEXT NOT NULL,
component_type TEXT NOT NULL,
package TEXT NOT NULL,
unit_price REAL,
stock INTEGER,
lib_id_lc TEXT NOT NULL,
reference_lc TEXT NOT NULL,
value_lc TEXT NOT NULL,
footprint_lc TEXT NOT NULL,
datasheet_lc TEXT NOT NULL,
description_lc TEXT NOT NULL,
manufacturer_lc TEXT NOT NULL,
part_number_lc TEXT NOT NULL,
lcsc_id_lc TEXT NOT NULL,
source_path_lc TEXT NOT NULL,
category_lc TEXT NOT NULL,
component_type_lc TEXT NOT NULL,
package_lc TEXT NOT NULL
);
CREATE INDEX idx_symbols_component_type ON symbols(component_type_lc);
CREATE INDEX idx_symbols_manufacturer ON symbols(manufacturer_lc);
CREATE INDEX idx_symbols_part_number ON symbols(part_number_lc);
CREATE INDEX idx_symbols_lib_id ON symbols(lib_id_lc);
CREATE INDEX idx_symbols_value ON symbols(value_lc);
CREATE INDEX idx_symbols_footprint ON symbols(footprint_lc);
CREATE INDEX idx_symbols_category ON symbols(category_lc);
CREATE INDEX idx_symbols_reference ON symbols(reference_lc);
CREATE INDEX idx_symbols_lcsc ON symbols(lcsc_id_lc);
CREATE VIRTUAL TABLE symbols_fts USING fts5(
lib_id,
reference,
value,
footprint,
description,
manufacturer,
part_number,
lcsc_id,
category,
component_type,
package
);
"""
)
def _insert_component(connection: sqlite3.Connection, component: KiCadComponent) -> None:
cursor = connection.execute(
"""
INSERT INTO symbols (
lib_id, reference, value, footprint, datasheet, description,
manufacturer, part_number, lcsc_id, source_path, category,
component_type, package, unit_price, stock,
lib_id_lc, reference_lc, value_lc, footprint_lc, datasheet_lc,
description_lc, manufacturer_lc, part_number_lc, lcsc_id_lc,
source_path_lc, category_lc, component_type_lc, package_lc
) VALUES (
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?
)
""",
(
component.lib_id,
component.reference,
component.value,
component.footprint,
component.datasheet,
component.description,
component.manufacturer,
component.part_number,
component.lcsc_id,
component.source_path,
component.category,
component.component_type,
component.package,
component.unit_price,
component.stock,
_normalize(component.lib_id),
_normalize(component.reference),
_normalize(component.value),
_normalize(component.footprint),
_normalize(component.datasheet),
_normalize(component.description),
_normalize(component.manufacturer),
_normalize(component.part_number),
_normalize(component.lcsc_id),
_normalize(component.source_path),
_normalize(component.category),
_normalize(component.component_type),
_normalize(component.package),
),
)
rowid = cursor.lastrowid
connection.execute(
"""
INSERT INTO symbols_fts (
rowid, lib_id, reference, value, footprint, description,
manufacturer, part_number, lcsc_id, category, component_type, package
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
rowid,
component.lib_id,
component.reference,
component.value,
component.footprint,
component.description,
component.manufacturer,
component.part_number,
component.lcsc_id,
component.category,
component.component_type,
component.package,
),
)
def _row_to_component(row: sqlite3.Row) -> KiCadComponent:
return KiCadComponent(
reference=row["reference"],
value=row["value"],
footprint=row["footprint"],
datasheet=row["datasheet"],
description=row["description"],
manufacturer=row["manufacturer"],
part_number=row["part_number"],
lcsc_id=row["lcsc_id"],
lib_id=row["lib_id"],
source_path=row["source_path"],
category=row["category"],
component_type=row["component_type"],
unit_price=row["unit_price"],
stock=row["stock"],
)
def build_symbol_sqlite_index(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Dict[str, Any]:
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return {
"index_path": None,
"symbol_libraries": 0,
"symbol_components": 0,
"source_count": 0,
}
resolved_root = resolved_root.expanduser()
index_path = symbol_sqlite_index_path(resolved_root)
current_meta = _index_meta(resolved_root)
if index_path.exists() and not refresh:
with _connect(index_path) as connection:
if _metadata_matches(connection, current_meta):
return symbol_sqlite_summary(resolved_root)
fd, tmp_name = tempfile.mkstemp(
prefix=".symbol-index-",
suffix=".sqlite3",
dir=str(resolved_root),
)
Path(tmp_name).unlink(missing_ok=True)
tmp_path = Path(tmp_name)
try:
with _connect(tmp_path) as connection:
_create_schema(connection)
connection.execute(
"INSERT INTO metadata(key, value) VALUES('meta_json', ?)",
(json.dumps(current_meta, ensure_ascii=True),),
)
sources = sorted(container_symbols_dir(resolved_root).glob("*.kicad_sym"))
for source in sources:
connection.execute("INSERT INTO sources(path) VALUES (?)", (str(source),))
for component in parse_inline_library_symbols(source):
_insert_component(connection, component)
connection.commit()
tmp_path.replace(index_path)
return symbol_sqlite_summary(resolved_root)
finally:
tmp_path.unlink(missing_ok=True)
def symbol_sqlite_summary(cache_root: Optional[Path] = None) -> Dict[str, Any]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return {
"index_path": None,
"symbol_libraries": 0,
"symbol_components": 0,
"source_count": 0,
}
index_path = symbol_sqlite_index_path(resolved_root)
if not index_path.exists():
return build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(index_path) as connection:
symbol_components = connection.execute("SELECT COUNT(*) AS count FROM symbols").fetchone()["count"]
symbol_libraries = connection.execute("SELECT COUNT(*) AS count FROM sources").fetchone()["count"]
return {
"index_path": str(index_path),
"symbol_libraries": symbol_libraries,
"symbol_components": symbol_components,
"source_count": symbol_libraries,
}
def load_all_symbol_components(
cache_root: Optional[Path] = None,
*,
refresh: bool = False,
) -> Tuple[List[KiCadComponent], List[Path]]:
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
if resolved_root is None:
return [], []
build_symbol_sqlite_index(resolved_root, refresh=refresh)
index_path = symbol_sqlite_index_path(resolved_root)
with _connect(index_path) as connection:
rows = connection.execute("SELECT * FROM symbols ORDER BY lib_id").fetchall()
sources = [Path(row["path"]) for row in connection.execute("SELECT path FROM sources ORDER BY path")]
return [_row_to_component(row) for row in rows], sources
def source_sample(cache_root: Optional[Path] = None, limit: int = 10) -> List[str]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return []
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
rows = connection.execute(
"SELECT path FROM sources ORDER BY path LIMIT ?",
(limit,),
).fetchall()
return [row["path"] for row in rows]
def source_count(cache_root: Optional[Path] = None) -> int:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return 0
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
return connection.execute("SELECT COUNT(*) AS count FROM sources").fetchone()["count"]
def catalog_size(cache_root: Optional[Path] = None) -> int:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return 0
build_symbol_sqlite_index(resolved_root, refresh=False)
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
return connection.execute("SELECT COUNT(*) AS count FROM symbols").fetchone()["count"]
def resolve_symbol_component(identifier: str, cache_root: Optional[Path] = None) -> Optional[KiCadComponent]:
needle = _normalize(identifier)
if not needle:
return None
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return None
build_symbol_sqlite_index(resolved_root, refresh=False)
query = """
SELECT *
FROM symbols
WHERE lib_id_lc = ?
OR reference_lc = ?
OR value_lc = ?
OR part_number_lc = ?
OR lcsc_id_lc = ?
OR (lib_id_lc || ':' || reference_lc) = ?
LIMIT 1
"""
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
row = connection.execute(query, (needle, needle, needle, needle, needle, needle)).fetchone()
return _row_to_component(row) if row is not None else None
def _apply_spec_filter(
where_clauses: List[str],
params: List[Any],
score_terms: List[str],
score_params: List[Any],
key: str,
value: str,
) -> None:
normalized = _normalize(value)
if not normalized:
return
if key in {"value", "resistance", "capacitance"}:
where_clauses.append("instr(value_lc, ?) > 0")
params.append(normalized)
score_terms.append("CASE WHEN value_lc = ? THEN 60 WHEN instr(value_lc, ?) > 0 THEN 30 ELSE 0 END")
score_params.extend([normalized, normalized])
return
if key in {"package"}:
where_clauses.append("instr(package_lc, ?) > 0")
params.append(normalized)
score_terms.append("CASE WHEN package_lc = ? THEN 35 WHEN instr(package_lc, ?) > 0 THEN 20 ELSE 0 END")
score_params.extend([normalized, normalized])
return
if key in {"footprint"}:
where_clauses.append("(instr(package_lc, ?) > 0 OR instr(footprint_lc, ?) > 0)")
params.extend([normalized, normalized])
score_terms.append(
"CASE WHEN footprint_lc = ? THEN 35 WHEN instr(footprint_lc, ?) > 0 THEN 25 "
"WHEN package_lc = ? THEN 25 WHEN instr(package_lc, ?) > 0 THEN 15 ELSE 0 END"
)
score_params.extend([normalized, normalized, normalized, normalized])
return
if key in {"manufacturer", "brand"}:
where_clauses.append("instr(manufacturer_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN manufacturer_lc = ? THEN 55 WHEN instr(manufacturer_lc, ?) > 0 THEN 25 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"part", "part_number", "mpn"}:
where_clauses.append("instr(part_number_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN part_number_lc = ? THEN 90 WHEN instr(part_number_lc, ?) > 0 THEN 45 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"lcsc", "lcsc_id"}:
where_clauses.append("instr(lcsc_id_lc, ?) > 0")
params.append(normalized)
score_terms.append(
"CASE WHEN lcsc_id_lc = ? THEN 90 WHEN instr(lcsc_id_lc, ?) > 0 THEN 45 ELSE 0 END"
)
score_params.extend([normalized, normalized])
return
if key in {"type", "category"}:
where_clauses.append("(instr(component_type_lc, ?) > 0 OR instr(category_lc, ?) > 0)")
params.extend([normalized, normalized])
score_terms.append(
"CASE WHEN component_type_lc = ? THEN 70 WHEN instr(component_type_lc, ?) > 0 THEN 35 "
"WHEN instr(category_lc, ?) > 0 THEN 20 ELSE 0 END"
)
score_params.extend([normalized, normalized, normalized])
return
where_clauses.append(
"id IN (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?)"
)
params.append(_fts_term(normalized))
score_terms.append("20")
def search_symbol_components(
component_type: str,
specs: Dict[str, Any],
*,
limit: int = 20,
cache_root: Optional[Path] = None,
) -> List[KiCadComponent]:
resolved_root = cache_root or ensure_container_library_cache()
if resolved_root is None:
return []
build_symbol_sqlite_index(resolved_root, refresh=False)
where_clauses = ["1 = 1"]
params: List[Any] = []
score_terms: List[str] = []
score_params: List[Any] = []
wanted_type = _normalize(component_type)
if wanted_type:
where_clauses.append("(component_type_lc = ? OR instr(lib_id_lc, ?) > 0)")
params.extend([wanted_type, wanted_type])
score_terms.append(
"CASE WHEN component_type_lc = ? THEN 80 WHEN instr(lib_id_lc, ?) > 0 THEN 40 ELSE 0 END"
)
score_params.extend([wanted_type, wanted_type])
for key, value in specs.items():
_apply_spec_filter(where_clauses, params, score_terms, score_params, key.lower(), str(value))
score_sql = " + ".join(score_terms) if score_terms else "0"
sql = f"""
SELECT *,
({score_sql}) AS score
FROM symbols
WHERE {' AND '.join(where_clauses)}
ORDER BY score DESC, lib_id ASC
LIMIT ?
"""
with _connect(symbol_sqlite_index_path(resolved_root)) as connection:
rows = connection.execute(sql, tuple(score_params + params + [max(limit * 10, limit)])).fetchall()
return [_row_to_component(row) for row in rows[:limit]]