project
Wolfgang's KiCad library
Public Unreviewedby John Lauer
Wolfgang's KiCad library as one KiCad Plugin and Content Manager URL: his fork of realAscot/kicad-symlib (95 cabinet symbols by Adam Skotarczak), DIN-rail footprints and 3D models.
main
John Lauer
Wolfgang's KiCad library as a hardware/project page: README, realAscot sub-readme, hero screenshot, PCM repository 0.7.1 to 0.8.2
1befa16
7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
#!/usr/bin/env python3
"""build_pcm.py: turn pcm/src/<package>/ into a KiCad PCM repository served from the wiki page.
What it writes (all under pcm/, all pushed to the page with `adom-wiki repo push`):
pcm/packages/<identifier>-<version>.zip the package archive, metadata.json at the root
pcm/packages.json the PackageArray (every version, with download_*)
pcm/repository.json the Repository (points at packages.json + its sha256)
Rules it enforces (KiCad's, see docs/pcm.md):
* the archive's metadata.json carries ONLY the version being packed and NO download_* keys
(KiCad's docs: a valid download_sha256 cannot exist inside the archive it hashes);
* packages.json carries every version still on disk, so PCM sees an update when a newer
version than the installed one is listed;
* repository.json and packages.json validate against KiCad's pcm.v1.schema.json
(vendored at pcm/pcm.v1.schema.json, fetched from the KiCad 10.0 branch);
* the zip is deterministic (fixed timestamps, sorted entries), so re-running the packer
with nothing changed rewrites nothing: same bytes, same sha256, same update_timestamp.
Usage:
python3 tools/build_pcm.py build the newest version in metadata.json
python3 tools/build_pcm.py --bump 1.1.0 add version 1.1.0 to metadata.json, then build it
python3 tools/build_pcm.py --bump 1.1.0 --stamp same, and stamp AdomSample_R's Value with the version
so the change is visible in the Symbol Editor
python3 tools/build_pcm.py --check validate only, write nothing
stdlib only. `jsonschema` is used for a second opinion when it is importable.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import re
import sys
import time
import zipfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parent
PCM = REPO / "pcm"
SRC = PCM / "src"
PACKAGES_DIR = PCM / "packages"
SCHEMA_PATH = PCM / "pcm.v1.schema.json"
DEFAULT_BASE_URL = "https://wiki.adom.inc/api/pages/john/wolfgang-kicad-library/files/pcm"
REPOSITORY_NAME = "Wolfgang's KiCad library (Adom wiki)"
MAINTAINER = {"name": "John Lauer", "contact": {"web": "https://wiki.adom.inc/john/wolfgang-kicad-library"}}
# PCM's extract() only knows these folders (KiCad 10.0 kicad/pcm/pcm.h PCM_PACKAGE_DIRECTORIES,
# which also lists templates and scripts); anything else in the archive is dead weight.
CONTENT_FOLDERS = ("plugins", "footprints", "symbols", "3dmodels", "resources", "colors", "templates")
ZIP_DATE = (2026, 1, 1, 0, 0, 0) # fixed so the archive bytes depend on content only
DOWNLOAD_KEYS = ("download_url", "download_sha256", "download_size", "install_size")
# ── versions ─────────────────────────────────────────────────────────────────
def version_key(v: str) -> tuple:
"""major.minor.patch as ints, missing parts 0, like the bridge's compare_versions."""
parts = []
for p in v.split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
while len(parts) < 3:
parts.append(0)
return tuple(parts)
# ── mini JSON-schema validator (draft-07 subset used by pcm.v1.schema.json) ─────
class SchemaError(Exception):
pass
def _resolve(schema: dict, ref: str) -> dict:
assert ref.startswith("#/"), ref
node = schema
for part in ref[2:].split("/"):
node = node[part]
return node
def validate(schema: dict, node: dict, data, path: str = "$") -> None:
"""Enough of draft-07 to check this schema: $ref, type, required, properties,
additionalProperties, patternProperties, pattern, enum, items, minItems, uniqueItems,
minimum, maxLength. Raises SchemaError with the JSON path of the first problem."""
if "$ref" in node:
node = _resolve(schema, node["$ref"])
t = node.get("type")
if t:
ok = {"object": lambda d: isinstance(d, dict), "array": lambda d: isinstance(d, list),
"string": lambda d: isinstance(d, str),
"integer": lambda d: isinstance(d, int) and not isinstance(d, bool)}[t](data)
if not ok:
raise SchemaError(f"{path}: expected {t}, got {type(data).__name__}")
if "enum" in node and data not in node["enum"]:
raise SchemaError(f"{path}: {data!r} is not one of the allowed values")
if isinstance(data, str):
if "pattern" in node and not re.search(node["pattern"], data):
raise SchemaError(f"{path}: {data!r} does not match {node['pattern']}")
if "maxLength" in node and len(data) > node["maxLength"]:
raise SchemaError(f"{path}: longer than {node['maxLength']} characters")
if isinstance(data, int) and not isinstance(data, bool) and "minimum" in node and data < node["minimum"]:
raise SchemaError(f"{path}: {data} is below {node['minimum']}")
if isinstance(data, dict):
for r in node.get("required", []):
if r not in data:
raise SchemaError(f"{path}: missing required key {r!r}")
props = node.get("properties", {})
pprops = node.get("patternProperties", {})
for k, v in data.items():
if k in props:
validate(schema, props[k], v, f"{path}.{k}")
continue
matched = False
for pat, sub in pprops.items():
if re.search(pat, k):
matched = True
validate(schema, sub, v, f"{path}.{k}")
if not matched and node.get("additionalProperties") is False:
raise SchemaError(f"{path}: key {k!r} is not allowed here")
if isinstance(data, list):
if "minItems" in node and len(data) < node["minItems"]:
raise SchemaError(f"{path}: fewer than {node['minItems']} items")
if node.get("uniqueItems"):
seen = [json.dumps(x, sort_keys=True) for x in data]
if len(seen) != len(set(seen)):
raise SchemaError(f"{path}: items are not unique")
if "items" in node:
for i, item in enumerate(data):
validate(schema, node["items"], item, f"{path}[{i}]")
def check_against_schema(schema: dict, definition: str, data, label: str) -> None:
validate(schema, {"$ref": f"#/definitions/{definition}"}, data, label)
try:
import jsonschema # type: ignore
except ImportError:
return
sub = dict(schema)
sub["$ref"] = f"#/definitions/{definition}"
jsonschema.validate(data, sub) # raises jsonschema.ValidationError
# ── KiCad file structure checks (mirror kicad_core::lint) ─────────────────────
def quote_aware_top_children(text: str) -> tuple[str, list[str]]:
"""(root head token, direct child heads) of the first top-level s-expression; raises on imbalance."""
depth = 0
in_s = esc = False
root_open = None
child_start = None
children = []
for i, ch in enumerate(text):
if in_s:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_s = False
continue
if ch == '"':
in_s = True
elif ch == "(":
depth += 1
if depth == 1:
root_open = i
elif depth == 2 and child_start is None:
child_start = i
elif ch == ")":
depth -= 1
if depth == 1 and child_start is not None:
children.append(text[child_start:i + 1])
child_start = None
elif depth == 0:
if text[i + 1:].strip():
raise ValueError("trailing content after the closing paren")
head = text[root_open + 1:].split(None, 1)[0]
return head, children
elif depth < 0:
raise ValueError(f"extra ')' at offset {i}")
raise ValueError("unbalanced s-expression")
def check_symbol_lib(path: Path) -> list[str]:
head, children = quote_aware_top_children(path.read_text(encoding="utf-8"))
if head != "kicad_symbol_lib":
raise ValueError(f"{path.name}: not a symbol library ({head})")
names = []
for c in children:
m = re.match(r'\(symbol\s+"([^"]+)"', c)
if m:
names.append(m.group(1))
return names
def check_footprint(path: Path) -> str:
text = path.read_text(encoding="utf-8")
head, _ = quote_aware_top_children(text)
if head != "footprint":
raise ValueError(f"{path.name}: not a footprint ({head})")
m = re.match(r'\(footprint\s+"([^"]+)"', text)
name = m.group(1) if m else ""
if name != path.stem:
raise ValueError(f"{path.name}: declares footprint {name!r}, file stem is {path.stem!r}")
return name
def check_package_tree(pkg_dir: Path) -> dict:
"""Layout per dev-docs.kicad.org/en/addons: metadata.json at the root, content folders only."""
meta_path = pkg_dir / "metadata.json"
if not meta_path.is_file():
raise ValueError(f"{pkg_dir}: no metadata.json")
report = {"symbols": {}, "footprints": {}, "extra": []}
for child in sorted(pkg_dir.iterdir()):
if child.name == "metadata.json":
continue
if not child.is_dir() or child.name not in CONTENT_FOLDERS:
report["extra"].append(child.name)
if report["extra"]:
raise ValueError(f"{pkg_dir}: entries PCM would ignore: {report['extra']} (only {CONTENT_FOLDERS} and metadata.json belong in a package)")
for sym in sorted((pkg_dir / "symbols").glob("*.kicad_sym")) if (pkg_dir / "symbols").is_dir() else []:
report["symbols"][sym.name] = check_symbol_lib(sym)
if (pkg_dir / "footprints").is_dir():
for pretty in sorted((pkg_dir / "footprints").glob("*.pretty")):
report["footprints"][pretty.name] = [check_footprint(m) for m in sorted(pretty.glob("*.kicad_mod"))]
icon = pkg_dir / "resources" / "icon.png"
if icon.is_file():
head = icon.read_bytes()[:24]
if head[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError("resources/icon.png is not a PNG")
w, h = int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
if (w, h) != (64, 64):
raise ValueError(f"resources/icon.png is {w}x{h}; PCM wants 64x64")
return report
# ── the archive ───────────────────────────────────────────────────────────────
def build_zip(pkg_dir: Path, meta: dict, version: dict) -> tuple[bytes, int]:
"""Deterministic zip: metadata.json (this version only, no download_*) at the root,
then every file under the content folders, sorted. Returns (bytes, install_size)."""
archive_meta = {k: v for k, v in meta.items() if k != "versions"}
archive_meta["versions"] = [{k: v for k, v in version.items() if k not in DOWNLOAD_KEYS}]
meta_bytes = (json.dumps(archive_meta, indent=4, ensure_ascii=False) + "\n").encode("utf-8")
files: list[tuple[str, bytes]] = [("metadata.json", meta_bytes)]
for folder in CONTENT_FOLDERS:
d = pkg_dir / folder
if not d.is_dir():
continue
for f in sorted(p for p in d.rglob("*") if p.is_file()):
files.append((f.relative_to(pkg_dir).as_posix(), f.read_bytes()))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in files:
zi = zipfile.ZipInfo(name, date_time=ZIP_DATE)
zi.compress_type = zipfile.ZIP_DEFLATED
zi.external_attr = 0o644 << 16
zf.writestr(zi, data)
return buf.getvalue(), sum(len(d) for _, d in files)
def write_if_changed(path: Path, data: bytes) -> bool:
if path.is_file() and path.read_bytes() == data:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return True
def dump_json(obj) -> bytes:
return (json.dumps(obj, indent=4, ensure_ascii=False) + "\n").encode("utf-8")
# ── main ──────────────────────────────────────────────────────────────────────
def stamp_symbol(pkg_dir: Path, symbol: str, version: str) -> None:
"""Make the bump visible: set the symbol's Value property to '<base> v<version>'."""
for sym in (pkg_dir / "symbols").glob("*.kicad_sym"):
text = sym.read_text(encoding="utf-8")
start = text.find(f'(symbol "{symbol}"')
if start < 0:
continue
m = re.compile(r'\(property "Value" "([^"]*)"').search(text, start)
if not m:
raise ValueError(f"{symbol}: no Value property")
base = re.sub(r" v\d[\d.]*$", "", m.group(1))
text = text[:m.start()] + f'(property "Value" "{base} v{version}"' + text[m.end():]
sym.write_text(text, encoding="utf-8")
print(f" stamped {symbol} Value = {base!r} v{version} in {sym.name}")
return
raise ValueError(f"symbol {symbol} not found under {pkg_dir}/symbols")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--package", default="realascot-kicad-symlib", help="folder under pcm/src (default adom-sample-lib)")
ap.add_argument("--bump", metavar="X.Y.Z", help="append this version to metadata.json before building")
ap.add_argument("--stamp", action="store_true", help="with --bump: stamp AdomSample_R's Value with the new version")
ap.add_argument("--stamp-symbol", default="AdomSample_R")
ap.add_argument("--status", default="stable", choices=["stable", "testing", "development", "deprecated"])
ap.add_argument("--kicad-version", default="10.0")
ap.add_argument("--base-url", default=DEFAULT_BASE_URL, help="where pcm/ is served from (page file URL)")
ap.add_argument("--check", action="store_true", help="validate the source and the current outputs; write nothing")
args = ap.parse_args()
pkg_dir = SRC / args.package
meta_path = pkg_dir / "metadata.json"
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
base_url = args.base_url.rstrip("/")
if args.bump:
if not re.fullmatch(r"\d{1,4}(\.\d{1,4}(\.\d{1,6})?)?", args.bump):
print(f"--bump {args.bump}: not a PCM version (major[.minor[.patch]])", file=sys.stderr)
return 2
existing = [v["version"] for v in meta["versions"]]
if args.bump in existing:
print(f"--bump {args.bump}: already in metadata.json ({existing})", file=sys.stderr)
return 2
if any(version_key(args.bump) <= version_key(v) for v in existing):
print(f"--bump {args.bump}: not newer than {max(existing, key=version_key)}", file=sys.stderr)
return 2
if args.check:
print("--check with --bump: nothing written", file=sys.stderr)
return 2
meta["versions"].append({"version": args.bump, "status": args.status, "kicad_version": args.kicad_version})
meta["versions"].sort(key=lambda v: version_key(v["version"]))
meta_path.write_bytes(dump_json(meta))
print(f" metadata.json: added version {args.bump}")
if args.stamp:
stamp_symbol(pkg_dir, args.stamp_symbol, args.bump)
# 1. Source checks.
report = check_package_tree(pkg_dir)
for lib, names in report["symbols"].items():
print(f" symbols/{lib}: {len(names)} symbols: {', '.join(names)}")
for lib, names in report["footprints"].items():
print(f" footprints/{lib}: {len(names)} footprints")
check_against_schema(schema, "Package", meta, "metadata.json")
if len(meta["description"]) > 150:
raise ValueError("description is over 150 characters (the PCM dialog limit in KiCad's docs)")
for v in meta["versions"]:
for k in DOWNLOAD_KEYS:
if k in v:
raise ValueError(f"metadata.json versions[{v['version']}] carries {k}: the packer computes download_* itself")
ident = meta["identifier"]
versions = sorted(meta["versions"], key=lambda v: version_key(v["version"]))
newest = versions[-1]
# 2. The archive for the newest version (older versions keep the zip already on disk).
zip_bytes, install_size = build_zip(pkg_dir, meta, newest)
zip_name = f"{ident}-{newest['version']}.zip"
zip_path = PACKAGES_DIR / zip_name
if not args.check:
changed = write_if_changed(zip_path, zip_bytes)
print(f" {zip_path.relative_to(REPO)}: {len(zip_bytes)} bytes, install_size {install_size}{' (unchanged)' if not changed else ''}")
# 3. packages.json: every version whose zip exists, download_* computed from the bytes.
old_packages = {}
if (PCM / "packages.json").is_file():
for p in json.loads((PCM / "packages.json").read_text(encoding="utf-8")).get("packages", []):
old_packages[p["identifier"]] = p
published_versions = []
for v in versions:
zp = PACKAGES_DIR / f"{ident}-{v['version']}.zip"
if v is newest:
data, isize = zip_bytes, install_size
elif zp.is_file():
data = zp.read_bytes()
with zipfile.ZipFile(io.BytesIO(data)) as zf:
isize = sum(i.file_size for i in zf.infolist())
else:
print(f" WARNING: {zp.name} is not on disk; version {v['version']} is dropped from packages.json", file=sys.stderr)
continue
entry = {k: val for k, val in v.items() if k not in DOWNLOAD_KEYS}
entry.update({
"download_url": f"{base_url}/packages/{zp.name}",
"download_sha256": hashlib.sha256(data).hexdigest(),
"download_size": len(data),
"install_size": isize,
})
published_versions.append(entry)
package_entry = {k: v for k, v in meta.items() if k not in ("versions", "$schema")}
package_entry["versions"] = published_versions
# Other packages already in packages.json (a second pcm/src folder built earlier) are kept.
packages = [p for i, p in old_packages.items() if i != ident] + [package_entry]
packages_doc = {"packages": packages}
check_against_schema(schema, "PackageArray", packages_doc, "packages.json")
packages_bytes = dump_json(packages_doc)
# 4. repository.json: keep update_timestamp when packages.json did not change.
old_repo = json.loads((PCM / "repository.json").read_text(encoding="utf-8")) if (PCM / "repository.json").is_file() else {}
sha = hashlib.sha256(packages_bytes).hexdigest()
if old_repo.get("packages", {}).get("sha256") == sha and old_repo["packages"].get("url") == f"{base_url}/packages.json":
ts = int(old_repo["packages"]["update_timestamp"])
else:
ts = int(time.time())
repo_doc = {
"$schema": "https://go.kicad.org/pcm/schemas/v1",
"name": REPOSITORY_NAME,
"maintainer": MAINTAINER,
"packages": {
"url": f"{base_url}/packages.json",
"sha256": sha,
"update_timestamp": ts,
"update_time_utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts)),
},
}
check_against_schema(schema, "Repository", repo_doc, "repository.json")
if args.check:
cur = (PCM / "packages.json").read_bytes() if (PCM / "packages.json").is_file() else b""
print(" packages.json:", "up to date" if cur == packages_bytes else "WOULD CHANGE")
return 0 if cur == packages_bytes else 1
c1 = write_if_changed(PCM / "packages.json", packages_bytes)
c2 = write_if_changed(PCM / "repository.json", dump_json(repo_doc))
print(f" pcm/packages.json: {len(published_versions)} version(s) of {ident}{' (unchanged)' if not c1 else ''}")
print(f" pcm/repository.json: packages sha256 {sha[:16]}..., update_timestamp {ts}{' (unchanged)' if not c2 else ''}")
print(f" repository URL for KiCad: {base_url}/repository.json")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (ValueError, SchemaError) as e:
print(f"build_pcm: {e}", file=sys.stderr)
sys.exit(1)