- scripts/mikilab_lib.py / mikilab_cli.py: typed Python API + JSON CLI
for add/update/remove/query, for other apps to integrate without
shelling out to add_component.py
- lib_common.py/import_component.py/import_batch.py: regenerate
sym-lib-table.global/fp-lib-table.global on every mutating call, not
just the project-local tables
- scripts/find_missing_3d_models.py: lists components with no 3D model
and search links to fill the gap
- docs/GUIDA_USO.md: practical Italian usage guide (companion to README.md)
- checkup fixes: removed orphan duplicate footprint
footprints/other/CP_20_1_ADI.kicad_mod, and a dangling
${easyeda2kicad}/tmp 3D model reference in AXP2101's footprint that
pointed at a file that never existed on disk
309 lines
11 KiB
Python
309 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
mikilab_lib.py
|
|
===============
|
|
|
|
Public Python API for managing the MIKILAB KiCad library: add, update,
|
|
remove and query components. Meant to be imported directly by Python
|
|
apps:
|
|
|
|
import sys
|
|
sys.path.insert(0, "/path/to/mikylab_kikad_library/scripts")
|
|
import mikilab_lib as mikilab
|
|
|
|
result = mikilab.add_component(
|
|
name="TPS7A2018PDBVR",
|
|
symbol="/path/to/TPS7A2018PDBVR.kicad_sym",
|
|
footprint="/path/to/SOT95P280X145-5N.kicad_mod",
|
|
model="/path/to/TPS7A2018PDBVR.step",
|
|
category="power",
|
|
)
|
|
|
|
Non-Python apps (Swift, C++, ...) should drive this through
|
|
mikilab_cli.py instead, which exposes the same operations as a stable
|
|
JSON-over-stdout CLI (see that file's docstring).
|
|
|
|
This module does not reimplement any import/removal logic -- it is a
|
|
thin, typed wrapper over import_component.py's existing functions (same
|
|
validation, collision handling, and lib-table regeneration used by every
|
|
other entry point in this repo: add_component.py, import_batch.py,
|
|
import_snapeda.py, import_ultralibrarian.py). It only adds:
|
|
- structured results/exceptions instead of print()+exit code,
|
|
- query functions (get/list/find) that nothing else in this repo
|
|
exposes today.
|
|
|
|
All mutating calls (add_component, update_component, remove_component)
|
|
regenerate sym-lib-table, fp-lib-table, sym-lib-table.global and
|
|
fp-lib-table.global before returning, and append rows to MANIFEST.csv --
|
|
same guarantees as the CLI scripts. They do NOT touch the real KiCad
|
|
global tables under ~/Library/Preferences/kicad/*/ -- if this library is
|
|
registered globally in KiCad (README.md section 1), that merge step is
|
|
still a separate, deliberate action (see README section 1, step 3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import lib_common as lc
|
|
import import_component as ic
|
|
|
|
ROOT = lc.LIBRARY_ROOT
|
|
|
|
|
|
class MikilabError(Exception):
|
|
"""Raised for any library-management failure: bad component name,
|
|
missing source file, unknown category, component not found (on
|
|
remove/lookup), etc. str(e) is a human-readable message safe to show
|
|
to a user or log as-is."""
|
|
|
|
|
|
@dataclass
|
|
class ComponentResult:
|
|
"""Result of a successful add_component/update_component/remove_component call."""
|
|
|
|
name: str
|
|
category: str
|
|
action: str # "add" | "update" | "remove"
|
|
symbol_path: str | None = None
|
|
footprint_path: str | None = None
|
|
footprint_status: str | None = None # NEW / UPDATED / DUPLICATE / RENAMED_COLLISION
|
|
model_path: str | None = None
|
|
messages: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class ComponentInfo:
|
|
"""One entry as returned by get_component/list_components/find_components.
|
|
|
|
Note: this reports one entry per .kicad_sym file under symbols/, which
|
|
is the same unit add_component/remove_component operate on. A few
|
|
library files (e.g. category-wide symbol libraries like LED.kicad_sym)
|
|
contain multiple symbol definitions internally; this API does not look
|
|
inside those, it reports the file as a single entry."""
|
|
|
|
name: str
|
|
category: str
|
|
symbol_path: str
|
|
sym_nickname: str
|
|
footprint_path: str | None
|
|
fp_nickname: str | None
|
|
footprint_files: list[str]
|
|
model_paths: list[str]
|
|
|
|
|
|
def _component_info(symbol_path: Path) -> ComponentInfo:
|
|
name = symbol_path.stem
|
|
category = symbol_path.parent.name
|
|
|
|
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
|
|
footprint_path = None
|
|
fp_nickname = None
|
|
footprint_files: list[str] = []
|
|
if pretty_dir.is_dir():
|
|
footprint_path = str(pretty_dir.relative_to(ROOT))
|
|
fp_nickname = lc.fp_nickname(pretty_dir)
|
|
footprint_files = sorted(p.stem for p in pretty_dir.glob("*.kicad_mod"))
|
|
|
|
models_dir = ROOT / "3dmodels" / category
|
|
model_paths = []
|
|
if models_dir.is_dir():
|
|
model_paths = sorted(
|
|
str(p.relative_to(ROOT))
|
|
for p in models_dir.glob(f"{name}.*")
|
|
if p.suffix.lower() in lc.MODEL_EXTENSIONS
|
|
)
|
|
|
|
return ComponentInfo(
|
|
name=name,
|
|
category=category,
|
|
symbol_path=str(symbol_path.relative_to(ROOT)),
|
|
sym_nickname=lc.sym_nickname(symbol_path),
|
|
footprint_path=footprint_path,
|
|
fp_nickname=fp_nickname,
|
|
footprint_files=footprint_files,
|
|
model_paths=model_paths,
|
|
)
|
|
|
|
|
|
def _resolve(path: str | Path | None) -> Path | None:
|
|
if path is None:
|
|
return None
|
|
return Path(path).expanduser().resolve()
|
|
|
|
|
|
def _regenerate_tables() -> None:
|
|
lc.write_sym_lib_table(ROOT)
|
|
lc.write_fp_lib_table(ROOT)
|
|
lc.write_global_tables(ROOT)
|
|
|
|
|
|
def _import(
|
|
name: str,
|
|
symbol: str | Path,
|
|
footprint: str | Path | None,
|
|
model: str | Path | None,
|
|
category: str | None,
|
|
update: bool,
|
|
action: str,
|
|
) -> ComponentResult:
|
|
name = name.strip()
|
|
if not name:
|
|
raise MikilabError("name must not be empty")
|
|
|
|
symbol_path = _resolve(symbol)
|
|
footprint_path = _resolve(footprint)
|
|
model_path = _resolve(model)
|
|
|
|
for label, p in (("symbol", symbol_path), ("footprint", footprint_path), ("model", model_path)):
|
|
if p is not None and not p.is_file():
|
|
raise MikilabError(f"{label} does not exist or is not a file: {p}")
|
|
|
|
if model_path is not None and footprint_path is None:
|
|
raise MikilabError("model requires footprint (a 3D model needs a footprint to attach to)")
|
|
|
|
resolved_category = category or lc.classify(name)
|
|
if resolved_category not in lc.CATEGORIES:
|
|
raise MikilabError(
|
|
f"category '{resolved_category}' is not one of the known categories: "
|
|
+ ", ".join(lc.CATEGORIES)
|
|
)
|
|
|
|
manifest_rows: list[list[str]] = []
|
|
report: list[str] = []
|
|
|
|
try:
|
|
sym_dst = ic.import_symbol(name, resolved_category, symbol_path, manifest_rows, report, update=update)
|
|
except ic.ImportError_ as e:
|
|
lc.append_manifest_rows(ROOT, [["symbol", str(symbol_path), "", "ERROR", "", str(e)]])
|
|
raise MikilabError(str(e)) from e
|
|
|
|
fp_dst = None
|
|
fp_status = None
|
|
model_dst = None
|
|
|
|
if footprint_path is not None:
|
|
try:
|
|
fp_dst, fp_nick, fp_name, fp_status = ic.import_footprint(
|
|
name, resolved_category, footprint_path, manifest_rows, report, update=update
|
|
)
|
|
except ic.ImportError_ as e:
|
|
lc.append_manifest_rows(ROOT, manifest_rows + [["footprint", str(footprint_path), "", "ERROR", "", str(e)]])
|
|
raise MikilabError(str(e)) from e
|
|
|
|
if model_path is not None:
|
|
model_target = fp_dst if fp_dst is not None else None
|
|
ic.import_model(name, resolved_category, model_path, model_target, manifest_rows, report)
|
|
model_dst = ROOT / "3dmodels" / resolved_category / f"{name}{model_path.suffix.lower()}"
|
|
|
|
text = sym_dst.read_text(encoding="utf-8")
|
|
new_ref = f"{fp_nick}:{fp_name}"
|
|
new_text, changed = lc.set_symbol_footprint_property(text, new_ref)
|
|
if changed:
|
|
sym_dst.write_text(new_text, encoding="utf-8")
|
|
report.append(f" Linked symbol Footprint property -> {new_ref}")
|
|
|
|
lc.append_manifest_rows(ROOT, manifest_rows)
|
|
_regenerate_tables()
|
|
|
|
return ComponentResult(
|
|
name=name,
|
|
category=resolved_category,
|
|
action=action,
|
|
symbol_path=str(sym_dst.relative_to(ROOT)),
|
|
footprint_path=str(fp_dst.relative_to(ROOT)) if fp_dst is not None else None,
|
|
footprint_status=fp_status,
|
|
model_path=str(model_dst.relative_to(ROOT)) if model_dst is not None else None,
|
|
messages=report,
|
|
)
|
|
|
|
|
|
def add_component(
|
|
name: str,
|
|
symbol: str | Path,
|
|
footprint: str | Path | None = None,
|
|
model: str | Path | None = None,
|
|
category: str | None = None,
|
|
) -> ComponentResult:
|
|
"""Add a new component. Raises MikilabError if a component with this
|
|
name already exists -- use update_component() to replace it in
|
|
place instead."""
|
|
return _import(name, symbol, footprint, model, category, update=False, action="add")
|
|
|
|
|
|
def update_component(
|
|
name: str,
|
|
symbol: str | Path,
|
|
footprint: str | Path | None = None,
|
|
model: str | Path | None = None,
|
|
category: str | None = None,
|
|
) -> ComponentResult:
|
|
"""Replace an existing component's symbol/footprint in place (upsert:
|
|
if no component with this name exists yet, it is created, same as
|
|
add_component()). `category` should normally match the component's
|
|
current category -- this does not move files between category
|
|
directories; use remove_component() + add_component() to
|
|
recategorize."""
|
|
return _import(name, symbol, footprint, model, category, update=True, action="update")
|
|
|
|
|
|
def remove_component(name: str) -> ComponentResult:
|
|
"""Remove a component: its symbol, its footprint library (if any),
|
|
and its 3D model(s) (if any). Raises MikilabError if no component
|
|
with this name exists."""
|
|
name = name.strip()
|
|
if not name:
|
|
raise MikilabError("name must not be empty")
|
|
|
|
manifest_rows: list[list[str]] = []
|
|
report: list[str] = []
|
|
|
|
try:
|
|
ic.remove_component(name, manifest_rows, report)
|
|
except ic.ImportError_ as e:
|
|
raise MikilabError(str(e)) from e
|
|
|
|
category = next(
|
|
(row[5].split("category=", 1)[1] for row in manifest_rows if row[0] == "symbol" and "category=" in row[5]),
|
|
"",
|
|
)
|
|
|
|
lc.append_manifest_rows(ROOT, manifest_rows)
|
|
_regenerate_tables()
|
|
|
|
return ComponentResult(name=name, category=category, action="remove", messages=report)
|
|
|
|
|
|
def get_component(name: str) -> ComponentInfo | None:
|
|
"""Look up a component by exact name. Returns None if not found."""
|
|
path = lc.find_symbol_by_name(ROOT, name.strip())
|
|
if path is None:
|
|
return None
|
|
return _component_info(path)
|
|
|
|
|
|
def list_components(category: str | None = None) -> list[ComponentInfo]:
|
|
"""List every symbol library file under symbols/ (optionally filtered
|
|
to one category). See ComponentInfo's docstring for what "one entry"
|
|
means for multi-symbol category libraries."""
|
|
if category is not None and category not in lc.CATEGORIES:
|
|
raise MikilabError(
|
|
f"category '{category}' is not one of the known categories: " + ", ".join(lc.CATEGORIES)
|
|
)
|
|
|
|
entries = [_component_info(p) for p in lc.discover_symbol_libraries(ROOT)]
|
|
if category is not None:
|
|
entries = [e for e in entries if e.category == category]
|
|
return entries
|
|
|
|
|
|
def find_components(query: str, category: str | None = None) -> list[ComponentInfo]:
|
|
"""Case-insensitive substring search over component names, optionally
|
|
restricted to one category."""
|
|
q = query.strip().lower()
|
|
return [e for e in list_components(category) if q in e.name.lower()]
|