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,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())
|
||||
Reference in New Issue
Block a user