Initial commit: MIKILAB KiCad personal library
Self-contained KiCad library (symbols, footprints, 3D models, docs,
import/check tooling) with no dependency on kicad-personal-library.
- Fixed sym-lib-table/fp-lib-table: single (version 7) header, one entry
per library, MIKILAB_<name> nicknames, ${KIPRJMOD}-relative URIs
- Resolved the SOT95P280X145-5N footprint collision (TPS7A2012PDBVR vs
TPS7A2018PDBVR): confirmed byte-identical modulo KiCad's internal
tedit timestamp, unified into one shared footprint
- Resolved a case-insensitive filename collision between the official
Diode.kicad_sym and a custom diode.kicad_sym
- Wrapped standalone footprints (ESP32-S31-WROOM-3, IC_TPS63020DSJT,
SOT95P280X145-5N) into their own .pretty libraries so they're
actually registered in fp-lib-table
- Fixed broken symbol->footprint references (including one pointing at
a nonexistent easyeda2kicad library)
- Added scripts/check_library.py, import_component.py, add_component.py,
import_batch.py, and lib_common.py
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
add_component.py
|
||||
=================
|
||||
|
||||
The simple, one-command way to add a component to the MIKILAB library.
|
||||
|
||||
python3 scripts/add_component.py \\
|
||||
--name TPS7A2018PDBVR \\
|
||||
--symbol /path/to/TPS7A2018PDBVR.kicad_sym \\
|
||||
--footprint /path/to/SOT95P280X145-5N.kicad_mod \\
|
||||
--model /path/to/TPS7A2018PDBVR.step \\
|
||||
--category power
|
||||
|
||||
This does not reimplement anything: it is a thin, friendlier front-end
|
||||
over import_component.py's core logic (same validation, same collision
|
||||
handling, same lib-table regeneration). Use import_component.py directly
|
||||
if you want the more explicit/verbose interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import import_component
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return import_component.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
check_library.py
|
||||
=================
|
||||
|
||||
Full consistency check of the MIKILAB KiCad library.
|
||||
|
||||
Checks performed:
|
||||
A. Directory structure (unexpected files, empty directories)
|
||||
B. Symbols (syntax, duplicate names/files, footprint references)
|
||||
C. Footprints (syntax, duplicate filenames, filename collisions, 3D refs)
|
||||
D. 3D models (files exist, references valid, relative/portable paths)
|
||||
E. Lib tables (syntax, single "(version 7)", all libs exist, no dup nicknames)
|
||||
F. Cross check: symbol -> footprint -> 3D model
|
||||
|
||||
Exit code is 0 if there are no ERROR-level findings, 1 otherwise.
|
||||
WARN-level findings (e.g. known/documented gaps) never fail the run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import lib_common as lc
|
||||
|
||||
ROOT = lc.LIBRARY_ROOT
|
||||
|
||||
EXPECTED_TOP_LEVEL = {
|
||||
"symbols", "footprints", "3dmodels", "docs", "legacy", "scripts",
|
||||
"sym-lib-table", "fp-lib-table", "MANIFEST.csv", "MANIFEST.md",
|
||||
"README.md", ".git", ".claude", ".gitignore", ".DS_Store",
|
||||
}
|
||||
|
||||
KNOWN_UNRESOLVED_MODEL_VARS = ("${KISBLIB}",)
|
||||
|
||||
_STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
|
||||
|
||||
def parens_balanced(text: str) -> bool:
|
||||
"""Check s-expression paren balance, ignoring parens inside string
|
||||
literals (KiCad symbol/footprint descriptions routinely contain
|
||||
literal '(' / ')' characters, e.g. "Vin (typ)")."""
|
||||
stripped = _STRING_LITERAL_RE.sub('""', text)
|
||||
return stripped.count("(") == stripped.count(")")
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
self.info: list[str] = []
|
||||
|
||||
def error(self, msg: str):
|
||||
self.errors.append(msg)
|
||||
|
||||
def warn(self, msg: str):
|
||||
self.warnings.append(msg)
|
||||
|
||||
def note(self, msg: str):
|
||||
self.info.append(msg)
|
||||
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def check_directory_structure(report: Report):
|
||||
for entry in ROOT.iterdir():
|
||||
if entry.name not in EXPECTED_TOP_LEVEL:
|
||||
report.warn(f"[DIR] Unexpected top-level entry: {entry.name}")
|
||||
|
||||
for sub in ("symbols", "footprints", "3dmodels"):
|
||||
base = ROOT / sub
|
||||
if not base.exists():
|
||||
report.error(f"[DIR] Missing expected directory: {sub}/")
|
||||
continue
|
||||
|
||||
for d in sorted(base.rglob("*")):
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
report.warn(f"[DIR] Empty directory: {d.relative_to(ROOT)}")
|
||||
|
||||
for cat_dir in (ROOT / "symbols").iterdir() if (ROOT / "symbols").exists() else []:
|
||||
if cat_dir.is_dir() and cat_dir.name not in lc.CATEGORIES:
|
||||
report.warn(f"[DIR] Symbol category not in known list: {cat_dir.name}")
|
||||
|
||||
for cat_dir in (ROOT / "footprints").iterdir() if (ROOT / "footprints").exists() else []:
|
||||
if cat_dir.is_dir() and cat_dir.name not in lc.CATEGORIES:
|
||||
report.warn(f"[DIR] Footprint category not in known list: {cat_dir.name}")
|
||||
|
||||
|
||||
def check_symbols(report: Report) -> list[tuple[str, Path]]:
|
||||
"""Returns [(symbol_name, defining .kicad_sym file), ...] for cross-check.
|
||||
Note: the same symbol name can legitimately appear in multiple files
|
||||
(each file is an independent library), so all occurrences are kept."""
|
||||
symbol_entries: list[tuple[str, Path]] = []
|
||||
seen_hash: dict[str, Path] = {}
|
||||
|
||||
files = lc.discover_symbol_libraries(ROOT)
|
||||
|
||||
if not files:
|
||||
report.warn("[SYM] No .kicad_sym files found under symbols/")
|
||||
|
||||
basenames = defaultdict(list)
|
||||
|
||||
for path in files:
|
||||
basenames[path.name.lower()].append(path)
|
||||
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if not text.lstrip().startswith("(kicad_symbol_lib"):
|
||||
report.error(f"[SYM] {path.relative_to(ROOT)}: does not start with (kicad_symbol_lib")
|
||||
|
||||
if not parens_balanced(text):
|
||||
report.error(f"[SYM] {path.relative_to(ROOT)}: unbalanced parentheses")
|
||||
|
||||
digest = lc.sha256_file(path)
|
||||
if digest in seen_hash:
|
||||
report.warn(
|
||||
f"[SYM] Duplicate content: {path.relative_to(ROOT)} "
|
||||
f"is byte-identical to {seen_hash[digest].relative_to(ROOT)}"
|
||||
)
|
||||
else:
|
||||
seen_hash[digest] = path
|
||||
|
||||
# Each .kicad_sym file is an independent KiCad library: symbols are
|
||||
# always addressed as "LibNickname:SymbolName", so the *same* name
|
||||
# appearing in two different library files is normal and expected
|
||||
# (this whole library mirrors upstream KiCad libraries on purpose).
|
||||
# A duplicate is only a real problem if it appears twice inside the
|
||||
# *same* file, which KiCad would refuse to load correctly.
|
||||
names_in_this_file: set[str] = set()
|
||||
|
||||
for m in re.finditer(r'\(symbol\s+"([^"]+)"', text):
|
||||
sym_name = m.group(1)
|
||||
|
||||
if sym_name in names_in_this_file:
|
||||
report.error(
|
||||
f"[SYM] {path.relative_to(ROOT)}: symbol name "
|
||||
f"'{sym_name}' defined more than once in the same file"
|
||||
)
|
||||
names_in_this_file.add(sym_name)
|
||||
|
||||
if ":" in sym_name:
|
||||
# Sub-unit / alternate-body reference, not a top-level symbol.
|
||||
continue
|
||||
|
||||
symbol_entries.append((sym_name, path))
|
||||
|
||||
for name, paths in basenames.items():
|
||||
if len(paths) > 1:
|
||||
report.error(
|
||||
f"[SYM] Filename collision for '{name}': "
|
||||
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
|
||||
)
|
||||
|
||||
return symbol_entries
|
||||
|
||||
|
||||
def check_footprints(report: Report) -> dict[str, Path]:
|
||||
"""Returns map 'LIBNICK:footprint_name' -> defining .kicad_mod path."""
|
||||
footprint_owner: dict[str, Path] = {}
|
||||
seen_hash: dict[str, list[Path]] = defaultdict(list)
|
||||
|
||||
pretty_dirs = lc.discover_footprint_libraries(ROOT)
|
||||
|
||||
if not pretty_dirs:
|
||||
report.warn("[FP] No .pretty directories found under footprints/")
|
||||
|
||||
# Loose .kicad_mod files directly under a category (not inside .pretty)
|
||||
# are invisible to fp-lib-table and must not exist.
|
||||
if (ROOT / "footprints").exists():
|
||||
for path in (ROOT / "footprints").glob("*/*.kicad_mod"):
|
||||
report.error(
|
||||
f"[FP] Standalone footprint not inside a .pretty library: "
|
||||
f"{path.relative_to(ROOT)}"
|
||||
)
|
||||
|
||||
basenames = defaultdict(list)
|
||||
|
||||
for pretty in pretty_dirs:
|
||||
nickname = lc.fp_nickname(pretty)
|
||||
|
||||
for mod in sorted(pretty.glob("*.kicad_mod")):
|
||||
basenames[mod.name.lower()].append(mod)
|
||||
|
||||
text = mod.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if not text.lstrip().startswith("(footprint") and not text.lstrip().startswith("(module"):
|
||||
report.error(f"[FP] {mod.relative_to(ROOT)}: does not start with (footprint ...)")
|
||||
|
||||
if not parens_balanced(text):
|
||||
report.error(f"[FP] {mod.relative_to(ROOT)}: unbalanced parentheses")
|
||||
|
||||
digest = lc.sha256_file(mod)
|
||||
seen_hash[digest].append(mod)
|
||||
|
||||
key = f"{nickname}:{mod.stem}"
|
||||
footprint_owner[key] = mod
|
||||
|
||||
for model_m in re.finditer(r'\(model\s+([^\s)]+)', text):
|
||||
model_ref = model_m.group(1)
|
||||
check_model_reference(report, mod, model_ref)
|
||||
|
||||
for digest, paths in seen_hash.items():
|
||||
if len(paths) > 1:
|
||||
names = {p.name for p in paths}
|
||||
if len(names) == 1:
|
||||
report.note(
|
||||
f"[FP] Identical footprint content shared across libraries: "
|
||||
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
|
||||
)
|
||||
|
||||
for name, paths in basenames.items():
|
||||
if len(paths) > 1:
|
||||
hashes = {lc.sha256_file(p) for p in paths}
|
||||
if len(hashes) > 1:
|
||||
report.error(
|
||||
f"[FP] REAL collision: filename '{name}' has different content in: "
|
||||
+ ", ".join(str(p.relative_to(ROOT)) for p in paths)
|
||||
)
|
||||
# If all hashes match, it is intentional library duplication
|
||||
# across independent .pretty libraries -- not an error.
|
||||
|
||||
return footprint_owner
|
||||
|
||||
|
||||
def check_model_reference(report: Report, footprint_path: Path, model_ref: str):
|
||||
if any(var in model_ref for var in KNOWN_UNRESOLVED_MODEL_VARS):
|
||||
report.warn(
|
||||
f"[3D] {footprint_path.relative_to(ROOT)}: references undefined "
|
||||
f"env var in '{model_ref}' (known gap: vendor-imported footprint, "
|
||||
f"3D file was never present locally -- see README)"
|
||||
)
|
||||
return
|
||||
|
||||
if model_ref.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", model_ref):
|
||||
report.error(
|
||||
f"[3D] {footprint_path.relative_to(ROOT)}: absolute 3D model path '{model_ref}'"
|
||||
)
|
||||
return
|
||||
|
||||
if "${KIPRJMOD}" in model_ref:
|
||||
resolved = lc.resolve_uri(model_ref, ROOT)
|
||||
if not resolved.exists():
|
||||
report.error(
|
||||
f"[3D] {footprint_path.relative_to(ROOT)}: missing 3D model file "
|
||||
f"'{model_ref}' -> {resolved}"
|
||||
)
|
||||
return
|
||||
|
||||
if "${KISYS3DMOD}" in model_ref:
|
||||
# Standard KiCad env var pointing at the official bundled 3D model
|
||||
# package -- defined automatically by every KiCad install, out of
|
||||
# scope for this library (same as standard footprint libraries).
|
||||
return
|
||||
|
||||
report.warn(
|
||||
f"[3D] {footprint_path.relative_to(ROOT)}: model path uses unrecognized "
|
||||
f"variable/form '{model_ref}'"
|
||||
)
|
||||
|
||||
|
||||
def check_3dmodels(report: Report):
|
||||
models_dir = ROOT / "3dmodels"
|
||||
if not models_dir.exists():
|
||||
report.error("[3D] Missing 3dmodels/ directory")
|
||||
return
|
||||
|
||||
for path in models_dir.rglob("*"):
|
||||
if path.is_file() and path.suffix.lower() not in lc.MODEL_EXTENSIONS:
|
||||
report.warn(f"[3D] Unexpected file type in 3dmodels/: {path.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def check_lib_tables(report: Report):
|
||||
sym_table = ROOT / "sym-lib-table"
|
||||
fp_table = ROOT / "fp-lib-table"
|
||||
|
||||
sym_entries, sym_versions = lc.parse_lib_table(sym_table)
|
||||
fp_entries, fp_versions = lc.parse_lib_table(fp_table)
|
||||
|
||||
if not sym_table.exists():
|
||||
report.error("[TABLE] sym-lib-table is missing")
|
||||
elif sym_versions != 1:
|
||||
report.error(f"[TABLE] sym-lib-table has {sym_versions} (version N) entries, expected exactly 1")
|
||||
|
||||
if not fp_table.exists():
|
||||
report.error("[TABLE] fp-lib-table is missing")
|
||||
elif fp_versions != 1:
|
||||
report.error(f"[TABLE] fp-lib-table has {fp_versions} (version N) entries, expected exactly 1")
|
||||
|
||||
# Duplicate nicknames
|
||||
for kind, entries in (("sym-lib-table", sym_entries), ("fp-lib-table", fp_entries)):
|
||||
seen = defaultdict(int)
|
||||
for e in entries:
|
||||
seen[e.nickname] += 1
|
||||
for nick, count in seen.items():
|
||||
if count > 1:
|
||||
report.error(f"[TABLE] {kind}: nickname '{nick}' registered {count} times")
|
||||
|
||||
# Every referenced library must exist on disk
|
||||
for e in sym_entries:
|
||||
resolved = lc.resolve_uri(e.uri, ROOT)
|
||||
if not resolved.exists():
|
||||
report.error(f"[TABLE] sym-lib-table: '{e.nickname}' -> {resolved} does not exist")
|
||||
|
||||
for e in fp_entries:
|
||||
resolved = lc.resolve_uri(e.uri, ROOT)
|
||||
if not resolved.exists() or not resolved.is_dir():
|
||||
report.error(f"[TABLE] fp-lib-table: '{e.nickname}' -> {resolved} does not exist")
|
||||
|
||||
# Every library on disk must be referenced
|
||||
registered_sym_uris = {lc.resolve_uri(e.uri, ROOT) for e in sym_entries}
|
||||
for path in lc.discover_symbol_libraries(ROOT):
|
||||
if path not in registered_sym_uris:
|
||||
report.error(f"[TABLE] {path.relative_to(ROOT)} exists but is not registered in sym-lib-table")
|
||||
|
||||
registered_fp_uris = {lc.resolve_uri(e.uri, ROOT) for e in fp_entries}
|
||||
for path in lc.discover_footprint_libraries(ROOT):
|
||||
if path not in registered_fp_uris:
|
||||
report.error(f"[TABLE] {path.relative_to(ROOT)} exists but is not registered in fp-lib-table")
|
||||
|
||||
return sym_entries, fp_entries
|
||||
|
||||
|
||||
def check_cross_references(report: Report, symbol_entries: list[tuple[str, Path]], fp_entries):
|
||||
"""Verify symbol -> footprint links that MIKILAB actually owns.
|
||||
|
||||
Symbols mirrored from the official KiCad symbol libraries legitimately
|
||||
reference KiCad's *global/standard* footprint libraries (e.g.
|
||||
'Package_SO', 'RF_Module', 'Package_DFN_QFN') by their upstream
|
||||
nickname. Those libraries are not part of MIKILAB -- they ship with
|
||||
every KiCad install and are resolved via the user's global
|
||||
fp-lib-table, not this project's. Only nicknames claiming to be a
|
||||
MIKILAB library (prefix 'MIKILAB_') are actually within our control,
|
||||
so only those are checked strictly.
|
||||
"""
|
||||
fp_nicknames = {e.nickname for e in fp_entries}
|
||||
external_refs = 0
|
||||
|
||||
by_file: dict[Path, list[str]] = defaultdict(list)
|
||||
for sym_name, sym_path in symbol_entries:
|
||||
by_file[sym_path].append(sym_name)
|
||||
|
||||
for sym_path, names in by_file.items():
|
||||
text = sym_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for sym_name in names:
|
||||
pattern = re.compile(
|
||||
r'\(symbol\s+"' + re.escape(sym_name) + r'"'
|
||||
)
|
||||
m = pattern.search(text)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
window = text[m.end(): m.end() + 4000]
|
||||
fp_m = re.search(
|
||||
r'"Footprint"\s*\n?\s*"([^"]*)"', window
|
||||
)
|
||||
if not fp_m:
|
||||
continue
|
||||
|
||||
fp_value = fp_m.group(1).strip()
|
||||
if not fp_value:
|
||||
continue
|
||||
|
||||
if ":" not in fp_value:
|
||||
continue
|
||||
|
||||
nick, _, fp_name = fp_value.partition(":")
|
||||
|
||||
if not nick.startswith("MIKILAB_"):
|
||||
# Reference to a standard/global KiCad footprint library,
|
||||
# outside MIKILAB's scope.
|
||||
external_refs += 1
|
||||
continue
|
||||
|
||||
if nick not in fp_nicknames:
|
||||
report.error(
|
||||
f"[CROSS] {sym_path.relative_to(ROOT)} symbol '{sym_name}': "
|
||||
f"Footprint references unknown MIKILAB library '{nick}' (footprint '{fp_value}')"
|
||||
)
|
||||
|
||||
if external_refs:
|
||||
report.note(
|
||||
f"[CROSS] {external_refs} symbols reference standard/global KiCad "
|
||||
f"footprint libraries (not MIKILAB_-prefixed) -- resolved via the "
|
||||
f"user's global fp-lib-table, out of scope for this library"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check the MIKILAB KiCad library for consistency.")
|
||||
parser.add_argument("--quiet", action="store_true", help="Only print the summary")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = Report()
|
||||
|
||||
check_directory_structure(report)
|
||||
symbol_entries = check_symbols(report)
|
||||
footprint_owner = check_footprints(report)
|
||||
check_3dmodels(report)
|
||||
sym_entries, fp_entries = check_lib_tables(report)
|
||||
check_cross_references(report, symbol_entries, fp_entries)
|
||||
|
||||
if not args.quiet:
|
||||
if report.info:
|
||||
print(f"\n=== INFO ({len(report.info)}) ===")
|
||||
for line in report.info:
|
||||
print(f" {line}")
|
||||
|
||||
if report.warnings:
|
||||
print(f"\n=== WARNINGS ({len(report.warnings)}) ===")
|
||||
for line in report.warnings:
|
||||
print(f" {line}")
|
||||
|
||||
if report.errors:
|
||||
print(f"\n=== ERRORS ({len(report.errors)}) ===")
|
||||
for line in report.errors:
|
||||
print(f" {line}")
|
||||
|
||||
print()
|
||||
print(f"Symbol libraries: {len(lc.discover_symbol_libraries(ROOT))}")
|
||||
print(f"Footprint libraries: {len(lc.discover_footprint_libraries(ROOT))}")
|
||||
print(f"Symbols defined: {len(symbol_entries)}")
|
||||
print(f"Footprints defined: {len(footprint_owner)}")
|
||||
print()
|
||||
print(f"Errors: {len(report.errors)}")
|
||||
print(f"Warnings: {len(report.warnings)}")
|
||||
print(f"Info: {len(report.info)}")
|
||||
print()
|
||||
|
||||
if report.ok():
|
||||
print("RESULT: OK (no errors)")
|
||||
return 0
|
||||
else:
|
||||
print("RESULT: FAILED")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
import_batch.py
|
||||
================
|
||||
|
||||
Import every component found under a source directory into the MIKILAB
|
||||
library, one subdirectory per component:
|
||||
|
||||
batch_source/
|
||||
TPS7A2018PDBVR/
|
||||
TPS7A2018PDBVR.kicad_sym
|
||||
SOT95P280X145-5N.kicad_mod
|
||||
TPS7A2018PDBVR.step
|
||||
ESP32-S3-WROOM-1/
|
||||
ESP32-S3-WROOM-1.kicad_sym
|
||||
ESP32-S3-WROOM-1.kicad_mod
|
||||
|
||||
Usage:
|
||||
python3 scripts/import_batch.py --source /path/to/batch_source [--category power]
|
||||
|
||||
Each subdirectory name is used as the component --name. Exactly one
|
||||
.kicad_sym is required per subdirectory; a .kicad_mod and a 3D model
|
||||
(.step/.stp/.wrl/.wrz) are optional, matching import_component.py's
|
||||
"symbol only / symbol+footprint / symbol+footprint+model" support.
|
||||
|
||||
This is a thin loop around import_component's core functions -- no
|
||||
separate collision/validation logic is reimplemented here, so behaviour
|
||||
(refuse existing names, dedupe identical footprints, rename real
|
||||
collisions, regenerate lib tables) is identical to a single
|
||||
import_component.py run, just repeated per subdirectory. The lib tables
|
||||
are regenerated once at the end rather than after every component, for
|
||||
speed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
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
|
||||
|
||||
MODEL_EXTS = (".step", ".stp", ".wrl", ".wrz")
|
||||
|
||||
|
||||
def find_one(directory: Path, suffixes: tuple[str, ...]) -> Path | None:
|
||||
matches = [p for p in sorted(directory.rglob("*")) if p.is_file() and p.suffix.lower() in suffixes]
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Batch-import components into the MIKILAB library.")
|
||||
parser.add_argument("--source", required=True, help="Directory containing one subdirectory per component")
|
||||
parser.add_argument("--category", choices=lc.CATEGORIES, help="Force this category for every component (default: auto-detect per component)")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
if not source.is_dir():
|
||||
print(f"ERROR: --source is not a directory: {source}")
|
||||
return 1
|
||||
|
||||
component_dirs = sorted(p for p in source.iterdir() if p.is_dir())
|
||||
if not component_dirs:
|
||||
print(f"No component subdirectories found under {source}")
|
||||
return 1
|
||||
|
||||
results = []
|
||||
|
||||
for comp_dir in component_dirs:
|
||||
name = comp_dir.name
|
||||
symbol = find_one(comp_dir, (".kicad_sym",))
|
||||
footprint = find_one(comp_dir, (".kicad_mod",))
|
||||
model = find_one(comp_dir, MODEL_EXTS)
|
||||
|
||||
if symbol is None:
|
||||
results.append((name, "ERROR", "no .kicad_sym file found in subdirectory"))
|
||||
continue
|
||||
|
||||
category = args.category or lc.classify(name)
|
||||
manifest_rows: list[list[str]] = []
|
||||
report: list[str] = []
|
||||
|
||||
try:
|
||||
ic.import_symbol(name, category, symbol, manifest_rows, report)
|
||||
|
||||
fp_path = None
|
||||
if footprint is not None:
|
||||
fp_path, fp_nick, fp_name, _ = ic.import_footprint(name, category, footprint, manifest_rows, report)
|
||||
|
||||
if model is not None:
|
||||
ic.import_model(name, category, model, fp_path, manifest_rows, report)
|
||||
|
||||
sym_path = ROOT / "symbols" / category / f"{name}.kicad_sym"
|
||||
text = sym_path.read_text(encoding="utf-8")
|
||||
new_text, changed = lc.set_symbol_footprint_property(text, f"{fp_nick}:{fp_name}")
|
||||
if changed:
|
||||
sym_path.write_text(new_text, encoding="utf-8")
|
||||
|
||||
lc.append_manifest_rows(ROOT, manifest_rows)
|
||||
results.append((name, "OK", f"category={category}"))
|
||||
|
||||
except ic.ImportError_ as e:
|
||||
lc.append_manifest_rows(ROOT, manifest_rows)
|
||||
results.append((name, "ERROR", str(e)))
|
||||
|
||||
sym_entries = lc.write_sym_lib_table(ROOT)
|
||||
fp_entries = lc.write_fp_lib_table(ROOT)
|
||||
|
||||
print(f"Batch import from {source}")
|
||||
print()
|
||||
for name, status, note in results:
|
||||
print(f" {status:6} {name:30} {note}")
|
||||
|
||||
print()
|
||||
print(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
|
||||
|
||||
ok = sum(1 for _, status, _ in results if status == "OK")
|
||||
errors = sum(1 for _, status, _ in results if status == "ERROR")
|
||||
print(f"\n{ok} imported, {errors} failed, out of {len(results)} component directories")
|
||||
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/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.
|
||||
|
||||
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) -> Path:
|
||||
existing = lc.find_symbol_by_name(ROOT, name)
|
||||
if existing is not None:
|
||||
raise ImportError_(
|
||||
f"A component named '{name}' already exists: {existing.relative_to(ROOT)}. "
|
||||
f"Refusing to overwrite -- choose a different --name or remove the existing "
|
||||
f"component first."
|
||||
)
|
||||
|
||||
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):
|
||||
"""Returns (footprint_path_or_None_if_reused, fp_nickname, fp_name, status)."""
|
||||
digest = lc.sha256_file(src)
|
||||
|
||||
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"
|
||||
|
||||
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
|
||||
basename = src.name
|
||||
|
||||
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 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", required=True, help="Path to the source .kicad_sym file")
|
||||
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)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
validate_args(args)
|
||||
except ImportError_ as e:
|
||||
print(f"ERROR: {e}")
|
||||
return 1
|
||||
|
||||
name = args.name.strip()
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
lib_common.py
|
||||
=============
|
||||
|
||||
Shared helpers for the MIKILAB KiCad library scripts
|
||||
(check_library.py, import_component.py, add_component.py, import_batch.py).
|
||||
|
||||
This module knows nothing about any external/source repository. It only
|
||||
operates on the MIKILAB library rooted at LIBRARY_ROOT (the directory that
|
||||
contains sym-lib-table, fp-lib-table, symbols/, footprints/, 3dmodels/).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
LIBRARY_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
SYMBOL_EXT = ".kicad_sym"
|
||||
FOOTPRINT_EXT = ".kicad_mod"
|
||||
MODEL_EXTENSIONS = {".step", ".stp", ".wrl", ".wrz"}
|
||||
|
||||
CATEGORIES = [
|
||||
"analog",
|
||||
"audio",
|
||||
"display",
|
||||
"fpga_cpld",
|
||||
"interface",
|
||||
"logic",
|
||||
"mechanical",
|
||||
"memory",
|
||||
"microcontrollers",
|
||||
"other",
|
||||
"power",
|
||||
"rf",
|
||||
]
|
||||
|
||||
CATEGORY_RULES = [
|
||||
("microcontrollers", [
|
||||
"esp32", "esp8266", "esp32-s31", "mcu", "microcontroller",
|
||||
"cpu", "processor",
|
||||
]),
|
||||
("audio", [
|
||||
"adau", "si4684", "codec", "audio", "dsp", "amplifier_audio",
|
||||
]),
|
||||
("power", [
|
||||
"power", "regulator", "converter", "battery", "charger",
|
||||
"bq25896", "bq27441", "ap63203", "tps7a", "tps22918",
|
||||
"ina218", "tca9555",
|
||||
]),
|
||||
("rf", [
|
||||
"bluetooth", "bt1035", "wifi", "wlan", "antenna",
|
||||
"gps", "gnss", "nfc", "transceiver", "phy", "ethernet",
|
||||
"rf",
|
||||
]),
|
||||
("interface", [
|
||||
"interface", "connector", "usb", "uart", "spi", "i2c", "i3c",
|
||||
"can", "hdmi", "displayport", "line_driver",
|
||||
]),
|
||||
("memory", [
|
||||
"memory", "eeprom", "flash", "sram", "dram", "nand", "nor",
|
||||
]),
|
||||
("logic", [
|
||||
"logic", "74xx", "4xxx", "buffer", "gate", "timer",
|
||||
"comparator", "mux", "demux", "flipflop", "counter",
|
||||
]),
|
||||
("analog", [
|
||||
"analog", "sensor", "diode", "transistor", "fet", "mosfet",
|
||||
"opamp", "operational", "reference", "filter", "switch",
|
||||
"relay",
|
||||
]),
|
||||
("fpga_cpld", [
|
||||
"fpga", "cpld", "altera", "xilinx", "lattice",
|
||||
]),
|
||||
("display", [
|
||||
"display", "lcd", "oled", "led_display", "driver_display",
|
||||
]),
|
||||
("mechanical", [
|
||||
"mechanical", "mount", "bracket", "jumper",
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
def norm(value: str) -> str:
|
||||
"""Normalize a string into a safe KiCad library-table nickname token."""
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_")
|
||||
|
||||
|
||||
def classify(name: str) -> str:
|
||||
"""Classify a component/library name into a MIKILAB category."""
|
||||
text = norm(name).lower()
|
||||
|
||||
for category, tokens in CATEGORY_RULES:
|
||||
for token in tokens:
|
||||
if norm(token).lower() in text:
|
||||
return category
|
||||
|
||||
return "other"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def sym_nickname(path: Path) -> str:
|
||||
"""Nickname for a symbol library file (one .kicad_sym == one library)."""
|
||||
return "MIKILAB_" + norm(path.stem)
|
||||
|
||||
|
||||
def fp_nickname(pretty_dir: Path) -> str:
|
||||
"""Nickname for a footprint library directory (one .pretty == one library)."""
|
||||
name = pretty_dir.name
|
||||
if name.endswith(".pretty"):
|
||||
name = name[: -len(".pretty")]
|
||||
return "MIKILAB_" + norm(name)
|
||||
|
||||
|
||||
def unique_nickname(base: str, used: set[str]) -> str:
|
||||
nickname = base
|
||||
index = 2
|
||||
|
||||
while nickname in used:
|
||||
nickname = f"{base}_{index}"
|
||||
index += 1
|
||||
|
||||
used.add(nickname)
|
||||
return nickname
|
||||
|
||||
|
||||
def discover_symbol_libraries(root: Path) -> list[Path]:
|
||||
return sorted((root / "symbols").glob("**/*.kicad_sym"))
|
||||
|
||||
|
||||
def discover_footprint_libraries(root: Path) -> list[Path]:
|
||||
return sorted(
|
||||
p for p in (root / "footprints").glob("**/*.pretty") if p.is_dir()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibEntry:
|
||||
nickname: str
|
||||
uri: str
|
||||
descr: str = ""
|
||||
lib_type: str = "KiCad"
|
||||
options: str = ""
|
||||
|
||||
|
||||
def build_sym_table_entries(root: Path) -> list[LibEntry]:
|
||||
used: set[str] = set()
|
||||
entries = []
|
||||
|
||||
for path in discover_symbol_libraries(root):
|
||||
relative = path.relative_to(root)
|
||||
nickname = unique_nickname(sym_nickname(path), used)
|
||||
uri = "${KIPRJMOD}/" + str(relative)
|
||||
entries.append(LibEntry(nickname=nickname, uri=uri, descr=path.stem))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def build_fp_table_entries(root: Path) -> list[LibEntry]:
|
||||
used: set[str] = set()
|
||||
entries = []
|
||||
|
||||
for path in discover_footprint_libraries(root):
|
||||
relative = path.relative_to(root)
|
||||
nickname = unique_nickname(fp_nickname(path), used)
|
||||
uri = "${KIPRJMOD}/" + str(relative)
|
||||
entries.append(LibEntry(nickname=nickname, uri=uri, descr=path.name))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def render_lib_table(kind: str, entries: list[LibEntry]) -> str:
|
||||
"""kind is 'sym_lib_table' or 'fp_lib_table'."""
|
||||
lines = [f"({kind}", " (version 7)"]
|
||||
|
||||
for e in entries:
|
||||
if kind == "sym_lib_table":
|
||||
lines.append(
|
||||
f' (lib (name "{e.nickname}")(type "{e.lib_type}")'
|
||||
f'(uri "{e.uri}")(options "{e.options}")(descr "{e.descr}"))'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f' (lib (name "{e.nickname}")(type "{e.lib_type}")'
|
||||
f'(uri "{e.uri}")(options "{e.options}")(descr "{e.descr}"))'
|
||||
)
|
||||
|
||||
lines.append(")")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_sym_lib_table(root: Path) -> list[LibEntry]:
|
||||
entries = build_sym_table_entries(root)
|
||||
(root / "sym-lib-table").write_text(
|
||||
render_lib_table("sym_lib_table", entries), encoding="utf-8"
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def write_fp_lib_table(root: Path) -> list[LibEntry]:
|
||||
entries = build_fp_table_entries(root)
|
||||
(root / "fp-lib-table").write_text(
|
||||
render_lib_table("fp_lib_table", entries), encoding="utf-8"
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
LIB_ENTRY_RE = re.compile(
|
||||
r'\(lib\s*\(name\s*"([^"]*)"\)\s*\(type\s*"([^"]*)"\)\s*'
|
||||
r'\(uri\s*"([^"]*)"\)\s*\(options\s*"([^"]*)"\)\s*'
|
||||
r'\(descr\s*"([^"]*)"\)\)'
|
||||
)
|
||||
|
||||
# Also accept the legacy/alternate KiCad lib-table syntax:
|
||||
# (lib "NICK" "URI" (descr "..."))
|
||||
LIB_ENTRY_RE_LEGACY = re.compile(
|
||||
r'\(lib\s+"([^"]*)"\s+"([^"]*)"(?:\s*\(descr\s*"([^"]*)"\))?\s*\)'
|
||||
)
|
||||
|
||||
|
||||
def parse_lib_table(path: Path) -> tuple[list[LibEntry], int]:
|
||||
"""Return (entries, version_count). Tolerates either KiCad lib-table
|
||||
syntax variant. version_count is the number of top-level (version N)
|
||||
occurrences found -- must be exactly 1 for a valid table."""
|
||||
|
||||
if not path.exists():
|
||||
return [], 0
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
version_count = len(re.findall(r"\(version\s+\d+\)", text))
|
||||
|
||||
entries: list[LibEntry] = []
|
||||
|
||||
for m in LIB_ENTRY_RE.finditer(text):
|
||||
nickname, lib_type, uri, options, descr = m.groups()
|
||||
entries.append(
|
||||
LibEntry(
|
||||
nickname=nickname,
|
||||
uri=uri,
|
||||
descr=descr,
|
||||
lib_type=lib_type,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
|
||||
if not entries:
|
||||
for m in LIB_ENTRY_RE_LEGACY.finditer(text):
|
||||
nickname, uri, descr = m.groups()
|
||||
entries.append(
|
||||
LibEntry(nickname=nickname, uri=uri, descr=descr or "")
|
||||
)
|
||||
|
||||
return entries, version_count
|
||||
|
||||
|
||||
def resolve_uri(uri: str, root: Path) -> Path:
|
||||
"""Resolve a lib-table URI (using ${KIPRJMOD}) to an absolute path."""
|
||||
resolved = uri.replace("${KIPRJMOD}", str(root))
|
||||
return Path(resolved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers shared by import_component.py / add_component.py / import_batch.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_symbol_by_name(root: Path, name: str) -> Path | None:
|
||||
"""Any symbols/**/<name>.kicad_sym, regardless of category."""
|
||||
for path in discover_symbol_libraries(root):
|
||||
if path.stem == name:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def find_footprint_by_hash(root: Path, digest: str) -> Path | None:
|
||||
for pretty in discover_footprint_libraries(root):
|
||||
for mod in pretty.glob("*.kicad_mod"):
|
||||
if sha256_file(mod) == digest:
|
||||
return mod
|
||||
return None
|
||||
|
||||
|
||||
def find_footprints_by_basename(root: Path, basename: str) -> list[Path]:
|
||||
matches = []
|
||||
for pretty in discover_footprint_libraries(root):
|
||||
candidate = pretty / basename
|
||||
if candidate.exists():
|
||||
matches.append(candidate)
|
||||
return matches
|
||||
|
||||
|
||||
_FOOTPRINT_PROP_SINGLELINE_RE = re.compile(
|
||||
r'(\(property\s+"Footprint"\s+)"([^"]*)"'
|
||||
)
|
||||
_FOOTPRINT_PROP_MULTILINE_RE = re.compile(
|
||||
r'(\(property\s*\n\s*"Footprint"\s*\n\s*)"([^"]*)"'
|
||||
)
|
||||
|
||||
|
||||
def set_symbol_footprint_property(text: str, new_value: str) -> tuple[str, bool]:
|
||||
"""Replace the value of the (first) 'Footprint' property in a symbol
|
||||
file's text, supporting both single-line and multi-line KiCad property
|
||||
syntax. Returns (new_text, changed)."""
|
||||
|
||||
if _FOOTPRINT_PROP_SINGLELINE_RE.search(text):
|
||||
new_text, n = _FOOTPRINT_PROP_SINGLELINE_RE.subn(
|
||||
lambda m: f'{m.group(1)}"{new_value}"', text, count=1
|
||||
)
|
||||
return new_text, n > 0
|
||||
|
||||
if _FOOTPRINT_PROP_MULTILINE_RE.search(text):
|
||||
new_text, n = _FOOTPRINT_PROP_MULTILINE_RE.subn(
|
||||
lambda m: f'{m.group(1)}"{new_value}"', text, count=1
|
||||
)
|
||||
return new_text, n > 0
|
||||
|
||||
return text, False
|
||||
|
||||
|
||||
def unique_destination(dst: Path, digest: str) -> tuple[Path, str]:
|
||||
"""Given a desired destination path and the sha256 of the source file,
|
||||
return (final_path, status) where status is one of NEW / DUPLICATE /
|
||||
RENAMED_COLLISION. Mirrors the collision policy used across the
|
||||
library: identical content is deduplicated, differing content gets a
|
||||
distinct suffixed name rather than silently overwriting."""
|
||||
|
||||
if not dst.exists():
|
||||
return dst, "NEW"
|
||||
|
||||
if sha256_file(dst) == digest:
|
||||
return dst, "DUPLICATE"
|
||||
|
||||
stem = dst.stem
|
||||
suffix = dst.suffix
|
||||
index = 2
|
||||
|
||||
while True:
|
||||
candidate = dst.with_name(f"{stem}_{index}{suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate, "RENAMED_COLLISION"
|
||||
if sha256_file(candidate) == digest:
|
||||
return candidate, "DUPLICATE"
|
||||
index += 1
|
||||
|
||||
|
||||
MANIFEST_HEADER = ["type", "source", "destination", "status", "hash", "notes"]
|
||||
|
||||
|
||||
def append_manifest_rows(root: Path, rows: list[list[str]]):
|
||||
import csv
|
||||
|
||||
manifest = root / "MANIFEST.csv"
|
||||
is_new = not manifest.exists()
|
||||
|
||||
with manifest.open("a", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
if is_new:
|
||||
writer.writerow(MANIFEST_HEADER)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
Reference in New Issue
Block a user