Files
mikilab-kicad-library/scripts/check_library.py
T
michele 6207faf3dc Add global-install support and document installation in README
- scripts/generate_global_tables.py generates sym-lib-table.global /
  fp-lib-table.global using a custom env var (default MIKILAB) instead
  of ${KIPRJMOD}, for use across every KiCad project
- README §1 now has concrete, tested install steps for this machine's
  KiCad 10.0 setup (per-project vs. global), including a merge script
  and a note about stale kicad-personal-library entries already
  present in the user's global sym-lib-table
2026-08-16 13:13:42 +02:00

448 lines
16 KiB
Python

#!/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", "sym-lib-table.global", "fp-lib-table.global",
"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())