Files
michele 58185c2215 Add ADG936BCPZ, OSD32MP157F-1G-BAA, TPS22965DSGR, AXP2101; add programmatic API and usage guide
- 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
2026-08-30 14:50:08 +02:00

353 lines
14 KiB
Python

#!/usr/bin/env python3
"""
import_component.py
====================
Import a single component (symbol + optional footprint + optional 3D model)
into the MIKILAB KiCad library.
Usage:
python3 scripts/import_component.py \\
--name TPS7A2018PDBVR \\
--symbol /path/to/TPS7A2018PDBVR.kicad_sym \\
--footprint /path/to/SOT95P280X145-5N.kicad_mod \\
--model /path/to/TPS7A2018PDBVR.step \\
--category power
A component may be imported with just a symbol, symbol+footprint, or
symbol+footprint+3D model. --category is optional; if omitted it is
inferred from --name using the same classification rules used elsewhere
in this library.
To remove a component instead:
python3 scripts/import_component.py --name TPS7A2018PDBVR --remove
This deletes its symbol, its footprint library (if any) and its 3D
model(s) (if any), logs the removal to MANIFEST.csv, and regenerates
sym-lib-table/fp-lib-table. Only --name is required; every other option
is ignored in --remove mode.
Never overwrites an existing component. Never silently duplicates a
footprint that already exists byte-for-byte elsewhere in the library --
it is reused instead. A footprint with a colliding filename but different
content is given a distinct, semantically-derived name and the collision
is documented in the report and in MANIFEST.csv.
After a successful import, sym-lib-table and fp-lib-table are fully
regenerated from the contents of the library directory tree, which
guarantees there is never more than one "(version 7)" entry and that
every library on disk is registered exactly once.
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lib_common as lc
ROOT = lc.LIBRARY_ROOT
class ImportError_(Exception):
pass
def validate_args(args) -> None:
if not args.name.strip():
raise ImportError_("--name must not be empty")
for label, value in (("--symbol", args.symbol), ("--footprint", args.footprint), ("--model", args.model)):
if value is not None and not Path(value).expanduser().is_file():
raise ImportError_(f"{label} does not exist or is not a file: {value}")
if args.model and not args.footprint:
raise ImportError_("--model requires --footprint (a 3D model needs a footprint to attach to)")
if args.category and args.category not in lc.CATEGORIES:
raise ImportError_(
f"--category '{args.category}' is not one of the known categories: "
+ ", ".join(lc.CATEGORIES)
)
def import_symbol(name: str, category: str, src: Path, manifest_rows: list, report: list, update: bool = False) -> Path:
bad_name = lc.check_component_name(name)
if bad_name:
raise ImportError_(bad_name)
existing = lc.find_symbol_by_name(ROOT, name)
if existing is not None:
if not update:
raise ImportError_(
f"A component named '{name}' already exists: {existing.relative_to(ROOT)}. "
f"Refusing to overwrite -- choose a different --name, pass --update to "
f"replace it in place, or remove the existing component first."
)
digest = lc.sha256_file(src)
shutil.copy2(src, existing)
manifest_rows.append(["symbol", str(src), str(existing.relative_to(ROOT)), "UPDATED", digest, f"category={category}"])
report.append(f" SYMBOL UPDATED {existing.relative_to(ROOT)}")
return existing
dst = ROOT / "symbols" / category / f"{name}.kicad_sym"
dst.parent.mkdir(parents=True, exist_ok=True)
digest = lc.sha256_file(src)
shutil.copy2(src, dst)
manifest_rows.append(["symbol", str(src), str(dst.relative_to(ROOT)), "NEW", digest, f"category={category}"])
report.append(f" SYMBOL NEW {dst.relative_to(ROOT)}")
return dst
def import_footprint(name: str, category: str, src: Path, manifest_rows: list, report: list, update: bool = False):
"""Returns (footprint_path_or_None_if_reused, fp_nickname, fp_name, status)."""
bad_name = lc.check_component_name(name)
if bad_name:
raise ImportError_(bad_name)
digest = lc.sha256_file(src)
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
basename = src.name
if update and pretty_dir.is_dir():
stale = [p for p in pretty_dir.glob("*.kicad_mod") if p.name != basename]
for p in stale:
p.unlink()
report.append(f" FOOTPRINT REMOVED stale variant {p.relative_to(ROOT)}")
target = pretty_dir / basename
pretty_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
manifest_rows.append(["footprint", str(src), str(target.relative_to(ROOT)), "UPDATED", digest, f"category={category}"])
report.append(f" FOOTPRINT UPDATED {target.relative_to(ROOT)}")
return target, lc.fp_nickname(pretty_dir), target.stem, "UPDATED"
existing = lc.find_footprint_by_hash(ROOT, digest)
if existing is not None:
pretty_dir = existing.parent
nickname = lc.fp_nickname(pretty_dir)
manifest_rows.append([
"footprint", str(src), str(existing.relative_to(ROOT)), "DUPLICATE", digest,
f"reused existing identical footprint instead of duplicating; category={category}",
])
report.append(f" FOOTPRINT DUPLICATE reused {existing.relative_to(ROOT)}")
return None, nickname, existing.stem, "DUPLICATE"
colliding = lc.find_footprints_by_basename(ROOT, basename)
if colliding:
new_basename = f"{name}_{src.stem}{src.suffix}"
note = (
f"filename collision with {', '.join(str(p.relative_to(ROOT)) for p in colliding)} "
f"(different content, verified by SHA256) -> renamed to '{new_basename}'"
)
target = pretty_dir / new_basename
status = "RENAMED_COLLISION"
else:
target = pretty_dir / basename
status = "NEW"
note = f"category={category}"
pretty_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
manifest_rows.append(["footprint", str(src), str(target.relative_to(ROOT)), status, digest, note])
report.append(f" FOOTPRINT {status:11} {target.relative_to(ROOT)}")
nickname = lc.fp_nickname(pretty_dir)
return target, nickname, target.stem, status
def remove_component(name: str, manifest_rows: list, report: list) -> None:
"""Remove a component's symbol, its footprint library, and its 3D
model(s). Category is taken from where the symbol currently lives, so
the caller only needs --name."""
sym_path = lc.find_symbol_by_name(ROOT, name)
if sym_path is None:
raise ImportError_(f"No component named '{name}' found under symbols/ -- nothing to remove.")
category = sym_path.parent.name
digest = lc.sha256_file(sym_path)
sym_path.unlink()
manifest_rows.append(["symbol", str(sym_path.relative_to(ROOT)), "", "REMOVED", digest, f"category={category}"])
report.append(f" SYMBOL REMOVED {sym_path.relative_to(ROOT)}")
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
if pretty_dir.is_dir():
for mod in sorted(pretty_dir.glob("*.kicad_mod")):
digest = lc.sha256_file(mod)
manifest_rows.append(["footprint", str(mod.relative_to(ROOT)), "", "REMOVED", digest, f"category={category}"])
report.append(f" FOOTPRINT REMOVED {mod.relative_to(ROOT)}")
shutil.rmtree(pretty_dir)
report.append(f" Removed directory {pretty_dir.relative_to(ROOT)}")
models_dir = ROOT / "3dmodels" / category
if models_dir.is_dir():
for model in sorted(models_dir.glob(f"{name}.*")):
if model.suffix.lower() not in lc.MODEL_EXTENSIONS:
continue
digest = lc.sha256_file(model)
model.unlink()
manifest_rows.append(["3d-model", str(model.relative_to(ROOT)), "", "REMOVED", digest, f"category={category}"])
report.append(f" 3D MODEL REMOVED {model.relative_to(ROOT)}")
def import_model(name: str, category: str, src: Path, footprint_path: Path | None, manifest_rows: list, report: list):
dst = ROOT / "3dmodels" / category / f"{name}{src.suffix.lower()}"
dst.parent.mkdir(parents=True, exist_ok=True)
digest = lc.sha256_file(src)
final, status = lc.unique_destination(dst, digest)
if status != "DUPLICATE":
shutil.copy2(src, final)
manifest_rows.append(["3d-model", str(src), str(final.relative_to(ROOT)), status, digest, f"category={category}"])
report.append(f" 3D MODEL {status:11} {final.relative_to(ROOT)}")
if footprint_path is None:
report.append(
" NOTE: footprint was reused from an existing shared library; the 3D model "
"was copied but NOT embedded in that footprint (it may already be used by "
"another component with a different 3D body -- assign it manually per-instance "
"in the PCB editor if needed)."
)
return
text = footprint_path.read_text(encoding="utf-8")
model_uri = "${KIPRJMOD}/" + str(final.relative_to(ROOT))
if "(model " in text:
report.append(
f" NOTE: {footprint_path.relative_to(ROOT)} already has a 3D model reference; "
f"leaving it untouched. New model is available at {final.relative_to(ROOT)}."
)
return
block = (
f" (model {model_uri}\n"
f" (offset (xyz 0 0 0))\n"
f" (scale (xyz 1 1 1))\n"
f" (rotate (xyz 0 0 0))\n"
f" )\n"
)
assert text.rstrip().endswith(")")
idx = text.rstrip().rfind(")")
text = text.rstrip()[:idx] + block + ")\n"
footprint_path.write_text(text, encoding="utf-8")
report.append(f" Linked 3D model into {footprint_path.relative_to(ROOT)}")
def main() -> int:
parser = argparse.ArgumentParser(description="Import a single component into the MIKILAB library.")
parser.add_argument("--name", required=True, help="Component name (used as the symbol/footprint base name)")
parser.add_argument("--category", choices=lc.CATEGORIES, help="MIKILAB category (auto-detected from --name if omitted)")
parser.add_argument("--symbol", help="Path to the source .kicad_sym file (required unless --remove)")
parser.add_argument("--footprint", help="Path to the source .kicad_mod file")
parser.add_argument("--model", help="Path to the source 3D model (.step/.stp/.wrl/.wrz)")
parser.add_argument("--update", action="store_true", help="Replace an existing component's symbol/footprint in place instead of refusing")
parser.add_argument("--remove", action="store_true", help="Remove an existing component (symbol + its footprint library + its 3D model(s)) and regenerate the lib-tables; only --name is required, every other option is ignored")
args = parser.parse_args()
name = args.name.strip()
if not name:
print("ERROR: --name must not be empty")
return 1
if args.remove:
manifest_rows = []
report = [f"Removing '{name}'"]
try:
remove_component(name, manifest_rows, report)
except ImportError_ as e:
print(f"ERROR: {e}")
return 1
lc.append_manifest_rows(ROOT, manifest_rows)
sym_entries = lc.write_sym_lib_table(ROOT)
fp_entries = lc.write_fp_lib_table(ROOT)
lc.write_global_tables(ROOT)
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
report.append("Regenerated sym-lib-table.global and fp-lib-table.global")
report.append(
"NOTE: if this library is registered globally in KiCad (README.md section 1), "
"also re-run the merge step to update ~/Library/Preferences/kicad/*/sym-lib-table "
"and fp-lib-table, or the removed component will still resolve there."
)
print("\n".join(report))
print("\nOK.")
return 0
if not args.symbol:
print("ERROR: --symbol is required unless --remove is given")
return 1
try:
validate_args(args)
except ImportError_ as e:
print(f"ERROR: {e}")
return 1
category = args.category or lc.classify(name)
manifest_rows: list[list[str]] = []
report: list[str] = [f"Importing '{name}' into category '{category}'"]
try:
import_symbol(name, category, Path(args.symbol).expanduser().resolve(), manifest_rows, report, update=args.update)
except ImportError_ as e:
print(f"ERROR: {e}")
lc.append_manifest_rows(ROOT, [["symbol", args.symbol, "", "ERROR", "", str(e)]])
return 1
fp_path = None
if args.footprint:
fp_path, fp_nick, fp_name, _ = import_footprint(
name, category, Path(args.footprint).expanduser().resolve(), manifest_rows, report, update=args.update
)
if args.model:
model_target = fp_path if fp_path is not None else None
import_model(name, category, Path(args.model).expanduser().resolve(), model_target, manifest_rows, report)
sym_path = ROOT / "symbols" / category / f"{name}.kicad_sym"
text = sym_path.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_path.write_text(new_text, encoding="utf-8")
report.append(f" Linked symbol Footprint property -> {new_ref}")
else:
report.append(
f" NOTE: could not find a 'Footprint' property in {sym_path.relative_to(ROOT)} "
f"to update automatically -- set it manually to '{new_ref}'"
)
lc.append_manifest_rows(ROOT, manifest_rows)
sym_entries = lc.write_sym_lib_table(ROOT)
fp_entries = lc.write_fp_lib_table(ROOT)
lc.write_global_tables(ROOT)
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
report.append("Regenerated sym-lib-table.global and fp-lib-table.global")
report.append(
"NOTE: if this library is registered globally in KiCad (README.md section 1), "
"also re-run the merge step to update ~/Library/Preferences/kicad/*/sym-lib-table "
"and fp-lib-table, or the new component will not show up there."
)
print("\n".join(report))
print("\nOK.")
return 0
if __name__ == "__main__":
sys.exit(main())