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
#!/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)