Files
mikilab-kicad-library/scripts/import_component.py
T
micheleandClaude Sonnet 5 c544db45f4 Add ESD9B5.0ST5G, PESD5V0L4UW, TPD2E007DCKR, TPS22994RUKT; fix FSC-BT1058 duplicate library registration
FSC-BT1058 had ended up with two footprint/symbol libraries on disk
(FSC-BT1058 and MIKILAB_FSC-BT1058) after a re-import baked the
"MIKILAB_" nickname prefix into the component name itself. Since that
prefix is normally added automatically when sym-lib-table/fp-lib-table
are generated, the double-prefixed copy (MIKILAB_MIKILAB_FSC_BT1058)
was invisible to collision checks and left the original as a stale,
outdated duplicate.

Consolidated onto the single correct library (symbols/other and
footprints/other without the redundant prefix, matching the rest of
the library's naming convention), fixed the symbol's Footprint
property and the footprint's 3D model reference, and regenerated both
lib-tables from disk.

To stop this class of bug from recurring:
- lib_common.check_component_name() rejects any --name that already
  starts with "MIKILAB", used by import_component.py's import_symbol/
  import_footprint (and therefore also by add_component.py and the
  SnapEDA/UltraLibrarian importers, which call into the same core).
- check_library.py now flags any on-disk symbol/footprint whose name
  already carries that prefix as an ERROR, independent of how it got
  there.
- import_component.py gains --remove to cleanly delete a component
  (symbol, footprint library, 3D model) and regenerate the lib-tables
  in one step, for exactly this kind of cleanup.

Errors: 0 on check_library.py after the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:06:54 +02:00

339 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)
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
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)
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
print("\n".join(report))
print("\nOK.")
return 0
if __name__ == "__main__":
sys.exit(main())