The easyeda2kicad.py-generated file had an unquoted URL as the (generator ...) value: '(generator https://github.com/uPesy/...)'. Bare/unquoted s-expression tokens can't contain ':' or '/', which broke KiCad's parser and made the whole library silently fail to load (the symbol was present on disk and correctly registered in fp/sym-lib-table, but invisible in KiCad). Wrapped the value in quotes. Also added a check_library.py rule to catch this class of error (an unquoted generator value containing ':' or '/') automatically going forward.
456 lines
16 KiB
Python
456 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")
|
|
|
|
gen_m = re.search(r'\(generator\s+([^\s")]+)', text)
|
|
if gen_m and re.search(r'[:/]', gen_m.group(1)):
|
|
report.error(
|
|
f"[SYM] {path.relative_to(ROOT)}: unquoted '(generator {gen_m.group(1)})' "
|
|
f"contains ':' or '/' -- this breaks KiCad's parser and silently makes the "
|
|
f"whole library fail to load; wrap the value in double quotes"
|
|
)
|
|
|
|
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())
|