Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""`akcli library` — project library workspace: audit, repair, author, lint.
``audit`` cross-checks schematics <-> sym/fp-lib-table <-> library contents <->
3D models and reports lint-style findings (exit 1 on >= WARNING). ``repair``
productizes the two historically hand-``sed``-ed fixes — footprint-nickname
renames and 3D-model path policy — as a dry-run plan; ``--apply`` writes
atomically with a ``.bak`` next to each file, and refuses ``TARGET_LOCKED``
(the shared :func:`akcli.safety.refuse_if_gui_locked` gate) if KiCad holds any
target open — the same invariant as ``draw --apply``.
``new-symbol`` turns a declarative pin table into a ``.kicad_sym``: the caller
supplies the pin *table* (which a datasheet gives up reliably) and akcli
computes the *coordinates* (which is where hand-written symbols acquire two
pins on one point — a mis-join KiCad cannot report, because the symbol is what
told ERC those pins are one node). ``lint-symbols`` runs the same check over a
library akcli did not write.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from .. import contract as _contract
from ..errors import EXIT
from ._shared import (
_add_exit_policy_flags,
_dumps,
_emit,
_ExitWith,
_findings_exit,
_load_cfg,
_require_path,
_stamp,
)
def _workspace(args: argparse.Namespace):
from .. import libtable
project = Path(getattr(args, "project", None) or ".")
if not project.exists():
raise _ExitWith(EXIT["NOT_FOUND"], f"ERROR: no such project: {project}")
return libtable.discover(project)
def _cmd_library_audit(args: argparse.Namespace) -> int:
ws = _workspace(args)
sch_override = [Path(s) for s in (getattr(args, "sch", None) or [])] or None
from .. import libtable
findings = libtable.audit(ws, sch_override)
if args.json:
_emit(_dumps(_stamp({
"project": str(ws.project_dir),
"sym_lib_table": str(ws.sym_table.path) if ws.sym_table else None,
"fp_lib_table": str(ws.fp_table.path) if ws.fp_table else None,
"schematics": [str(s) for s in (sch_override or ws.schematics)],
"findings": [
{"code": f.code, "severity": f.severity.value,
"message": f.message, "anchors": f.anchors}
for f in findings
],
})))
return _findings_exit(findings, args)
_emit(f"library audit: {ws.project_dir}")
_emit(f" sym-lib-table: {ws.sym_table.path.name if ws.sym_table else '(none)'}"
f"{' (' + str(len(ws.sym_table.entries)) + ' libs)' if ws.sym_table else ''}")
_emit(f" fp-lib-table: {ws.fp_table.path.name if ws.fp_table else '(none)'}"
f"{' (' + str(len(ws.fp_table.entries)) + ' libs)' if ws.fp_table else ''}")
_emit(f" schematics: {len(sch_override or ws.schematics)}")
if not findings:
_emit("no findings — nicknames, footprints and 3D references all resolve")
return EXIT["OK"]
for f in findings:
_emit(f"{f.severity.value.upper():<8} {f.code}: {f.message}")
_emit(f"{len(findings)} finding(s)")
return _findings_exit(findings, args)
def _cmd_library_repair(args: argparse.Namespace) -> int:
from .. import libtable
ws = _workspace(args)
edits: list = []
for spec in getattr(args, "rename_footprint_lib", None) or []:
if "=" not in spec:
raise _ExitWith(EXIT["USAGE"],
f"ERROR: --rename-footprint-lib wants OLD=NEW, got {spec!r}")
old, new = spec.split("=", 1)
edits.extend(libtable.plan_rename(ws, old, new))
mode = getattr(args, "model_path", None)
if mode:
try:
edits.extend(libtable.plan_model_paths(ws, mode))
except ValueError as exc:
raise _ExitWith(EXIT["USAGE"], f"ERROR: {exc}")
if not getattr(args, "rename_footprint_lib", None) and not mode:
raise _ExitWith(EXIT["USAGE"],
"ERROR: nothing to repair — pass --rename-footprint-lib "
"OLD=NEW and/or --3d-path MODE")
do_apply = bool(getattr(args, "apply", False))
if do_apply and edits:
# Same invariant as `draw --apply` / `relink --apply`: never write a
# file KiCad holds open (a GUI save would overwrite it from memory).
# Checked before any output so a refusal writes nothing at all.
from .. import safety
safety.refuse_if_gui_locked([e.path for e in edits],
what="the repair write")
if args.json:
_emit(_dumps(_stamp({
"project": str(ws.project_dir),
"applied": do_apply and bool(edits),
"edits": [{"path": str(e.path), "description": e.description}
for e in edits],
})))
else:
if not edits:
_emit("repair: nothing to change")
return EXIT["OK"]
for e in edits:
_emit(f" {e.description}")
if not do_apply:
_emit(f"dry-run: {len(edits)} file(s) would change — re-run with --apply")
if do_apply and edits:
from .. import safety
for e in edits:
safety.atomic_write_with_backup(e.path, e.new_text,
backup_dir=e.path.parent)
if not args.json:
_emit(f"applied {len(edits)} file(s) (each left a .bak) — "
"re-run `akcli library audit` to confirm")
# post-apply re-audit so a bad repair cannot claim success silently
findings = libtable.audit(ws)
bad = [f for f in findings if f.severity.value in ("error", "critical")]
if bad:
sys.stderr.write(
f"WARNING: post-repair audit still has {len(bad)} error(s); "
"run `akcli library audit` for details\n")
return EXIT["OK"]
def _sanitize_fp_name(name: str) -> str:
"""Make a footprint name filesystem/library-safe (declared, reported)."""
out = "".join(("-" if c in '()[]{}<>:;"/\\|?*' or c.isspace() else c)
for c in name).strip("-.")
return out or "footprint"
def _cmd_library_import_altium(args: argparse.Namespace) -> int:
"""`library import-altium part.PcbLib --out lib.pretty` — PcbLib -> .kicad_mod."""
import hashlib
from .. import __version__, safety
from ..readers import footprint_lib
from ..writers import footprint_mod
src = Path(getattr(args, "path", None) or "")
if not str(src):
raise _ExitWith(EXIT["USAGE"], "ERROR: missing input .PcbLib")
lib = footprint_lib.read_pcblib(src)
out_arg = getattr(args, "out", None)
if out_arg:
out_dir = Path(out_arg)
else:
parts_root = _load_cfg(args, None).paths.get("parts_dir")
out_dir = (Path(parts_root) / (src.stem + ".pretty") if parts_root
else Path(src.stem + ".pretty"))
do_apply = bool(getattr(args, "apply", False))
courtyard = getattr(args, "courtyard", None)
plan: list[dict] = []
all_warnings: list[str] = list(lib.warnings)
for fp in lib.footprints:
safe = _sanitize_fp_name(fp.name)
if safe != fp.name:
all_warnings.append(
f"{fp.name}: renamed to {safe!r} (filesystem-safe) — declared "
"transformation")
conv_warnings: list[str] = []
fp_render = fp
if safe != fp.name:
import dataclasses
fp_render = dataclasses.replace(fp, name=safe)
text = footprint_mod.to_kicad_mod(
fp_render, courtyard_mm=courtyard, warnings=conv_warnings)
all_warnings.extend(conv_warnings)
plan.append({"name": safe, "source_name": fp.name,
"pads": len(fp.pads),
"file": str(out_dir / f"{safe}.kicad_mod"),
"text": text})
if do_apply:
out_dir.mkdir(parents=True, exist_ok=True)
for item in plan:
safety.atomic_write_with_backup(item["file"], item["text"])
provenance = {
"schema_version": _contract.SCHEMA_VERSIONS["envelope"],
"source": {"path": str(src),
"sha256": hashlib.sha256(src.read_bytes()).hexdigest()},
"converter": {"tool": "akcli library import-altium",
"version": __version__},
"options": {"courtyard_mm": courtyard},
"footprints": [{k: item[k] for k in ("name", "source_name", "pads", "file")}
for item in plan],
"warnings": all_warnings,
}
safety.atomic_write_with_backup(
out_dir / "provenance.json", _dumps(provenance) + "\n")
if args.json:
_emit(_dumps(_stamp({
"source": str(src),
"out": str(out_dir),
"applied": do_apply,
"footprints": [{k: item[k] for k in ("name", "source_name", "pads", "file")}
for item in plan],
"warnings": all_warnings,
})))
else:
_emit(f"import-altium: {src.name} -> {out_dir} "
f"({len(plan)} footprint(s))")
for item in plan:
_emit(f" {item['name']} pads={item['pads']}")
for w in all_warnings:
_emit(f" warning: {w}")
if not do_apply:
_emit("dry-run: nothing written — re-run with --apply")
else:
_emit(f"wrote {len(plan)} .kicad_mod + provenance.json")
return EXIT["OK"]
def _cmd_library_new_symbol(args: argparse.Namespace) -> int:
"""`library new-symbol spec.json [-o LIB] [--apply]` — pin table -> .kicad_sym.
Dry-run by default: prints the placement it computed (and any findings) so a
caller can read the geometry before anything is written.
The write gate is the deliberate exception to "geometry findings are
advisory": ``SYMLIB_PIN_COINCIDENT`` in our OWN output is a connectivity
defect — KiCad would merge those pins into one node in every placement —
so it refuses the write (``VERIFY_FAILED``, exit 6) instead of warning.
"""
from .. import safety, symbol_spec
from ..checks import symlib
from ..writers import symbol_lib
spec_path = _require_path(getattr(args, "spec", None), "input spec JSON")
if not spec_path.exists():
raise _ExitWith(EXIT["NOT_FOUND"], f"ERROR: no such spec: {spec_path}")
spec = symbol_spec.load(spec_path)
override = getattr(args, "name", None)
if override:
spec.name = override
lay = symbol_spec.layout(spec)
text = symbol_lib.to_kicad_sym(lay)
out_arg = getattr(args, "out", None)
if out_arg:
out_path = Path(out_arg)
else:
parts_root = _load_cfg(args, spec_path).paths.get("parts_dir")
name = f"{lay.name}.kicad_sym"
out_path = Path(parts_root) / name if parts_root else Path(name)
findings = symlib.run_text(text, str(out_path))
blocking = [f for f in findings if f.code == symlib.SYMLIB_PIN_COINCIDENT]
do_apply = bool(getattr(args, "apply", False))
if args.json:
_emit(_dumps(_stamp({
"spec": str(spec_path),
"out": str(out_path),
"applied": do_apply and not blocking,
"symbol": lay.name,
"reference_prefix": lay.reference_prefix,
"body_mil": {"x0": lay.body[0], "y0": lay.body[1],
"x1": lay.body[2], "y1": lay.body[3]},
"pins": [{"number": p.spec.number, "name": p.spec.name,
"type": p.spec.type, "side": p.spec.side,
"group": p.spec.group, "hidden": p.spec.hidden,
"stack_with": p.spec.stack_with,
"at_mil": [p.x_mil, p.y_mil],
"orientation": p.orientation}
for p in lay.pins],
"warnings": list(lay.warnings),
"findings": [{"code": f.code, "severity": f.severity.value,
"message": f.message, "refs": f.refs}
for f in findings],
})))
else:
_emit(f"new-symbol: {spec_path.name} -> {out_path}")
_emit(f" symbol {lay.name} ({lay.reference_prefix}), "
f"{len(lay.pins)} pin(s), body "
f"{lay.body_width_mil}x{lay.body_height_mil} mil")
for p in lay.pins:
stack = f" stacked on {p.spec.stack_with}" if p.spec.stack_with else ""
_emit(f" {p.spec.number:>4} {p.spec.name or '~':<14} "
f"{p.spec.type:<15} {p.spec.side:<6} "
f"at ({p.x_mil},{p.y_mil}) rot {p.orientation}"
f"{' hidden' if p.spec.hidden else ''}{stack}")
for w in lay.warnings:
_emit(f" warning: {w}")
for f in findings:
_emit(f" {f.severity.value.upper():<8} {f.code}: {f.message}")
if blocking:
raise _ExitWith(
EXIT["OPLIST"],
f"ERROR: VERIFY_FAILED: {len(blocking)} coincident-pin finding(s) in "
"the generated symbol; nothing was written. Two pins on one point "
"is an electrical mis-join KiCad's ERC cannot see, so this gates "
"the write rather than warning about it.")
if do_apply:
safety.refuse_if_gui_locked([out_path], what="the symbol write")
replaced = out_path.exists()
safety.atomic_write_with_backup(out_path, text,
backup_dir=out_path.parent)
if not args.json:
_emit(f"wrote {out_path}"
+ (" (left a .bak)" if replaced else ""))
elif not args.json:
_emit("dry-run: nothing written — re-run with --apply")
return EXIT["OK"]
def _cmd_library_lint_symbols(args: argparse.Namespace) -> int:
"""`library lint-symbols lib.kicad_sym` — coincident/untyped/off-grid pins."""
from ..checks import symlib
path = _require_path(getattr(args, "path", None), "input .kicad_sym")
if not path.exists():
raise _ExitWith(EXIT["NOT_FOUND"], f"ERROR: no such library: {path}")
findings = symlib.run(path)
if args.json:
_emit(_dumps(_stamp({
"library": str(path),
"findings": [{"code": f.code, "severity": f.severity.value,
"message": f.message, "refs": f.refs,
"anchors": f.anchors}
for f in findings],
})))
return _findings_exit(findings, args)
_emit(f"lint-symbols: {path}")
if not findings:
_emit("no findings — every pin has its own point, a real electrical "
"type, and lands on the 100-mil grid")
return EXIT["OK"]
for f in findings:
_emit(f"{f.severity.value.upper():<8} {f.code}: {f.message}")
_emit(f"{len(findings)} finding(s)")
return _findings_exit(findings, args)
_LOCKABLE_SUFFIXES = (".kicad_sch", ".kicad_pcb", ".kicad_pro")
def _cmd_library_check_lock(args: argparse.Namespace) -> int:
"""`library check-lock [project]` — is any KiCad file open in the GUI?
KiCad drops a ``~<name>.lck`` next to a document it holds open. akcli's own
write ops already refuse a locked target, but hand scripts / ``sed`` do not —
this exposes the same check so an external flow can gate on it
(``akcli library check-lock . && ./relayout.sh``). Exit 6 (TARGET_LOCKED)
when ANY file is locked, 0 when the tree is writable.
"""
from ..writers import kicad as kwriter
project = Path(getattr(args, "project", None) or ".")
if not project.exists():
raise _ExitWith(EXIT["NOT_FOUND"], f"ERROR: no such project: {project}")
root = project if project.is_dir() else project.parent
files = sorted(
f for suf in _LOCKABLE_SUFFIXES for f in root.rglob(f"*{suf}")
if f.is_file() and not f.name.startswith("~"))
locked = [f for f in files if kwriter.gui_lock_path(f).exists()]
if args.json:
_emit(_dumps(_stamp({
"project": str(root),
"scanned": len(files),
"locked": [{"file": str(f), "lock": str(kwriter.gui_lock_path(f))}
for f in locked],
"writable": not locked,
})))
return EXIT["OPLIST"] if locked else EXIT["OK"]
if not locked:
_emit(f"check-lock: {len(files)} KiCad file(s) under {root} — none open "
"in the GUI (safe to write)")
return EXIT["OK"]
_emit(f"check-lock: {len(locked)} of {len(files)} file(s) appear OPEN in the "
"KiCad GUI — external writes are unsafe (a GUI save would overwrite them):")
for f in locked:
_emit(f" LOCKED {f} ({kwriter.gui_lock_path(f).name})")
_emit("close them in KiCad first, or File>Revert after writing")
return EXIT["OPLIST"]
def register(sub, common) -> None:
p = sub.add_parser(
"library", parents=[common],
help="project library workspace: audit + repair + author "
"(sym/fp-lib-table, footprints, 3D, symbol generation)")
lib_sub = p.add_subparsers(dest="library_command", metavar="<subcommand>")
p.set_defaults(handler=_cmd_library_audit, project=None, sch=None)
pa = lib_sub.add_parser(
"audit", parents=[common],
help="cross-check schematics <-> lib tables <-> libraries <-> 3D models")
pa.add_argument("project", nargs="?", default=".",
help="project directory or .kicad_pro (default: .)")
pa.add_argument("--sch", metavar="FILE", action="append",
help="audit this schematic instead of auto-discovery "
"(repeatable)")
_add_exit_policy_flags(pa)
pa.set_defaults(handler=_cmd_library_audit)
pr = lib_sub.add_parser(
"repair", parents=[common],
help="plan/apply library fixes (dry-run by default)")
pr.add_argument("project", nargs="?", default=".",
help="project directory or .kicad_pro (default: .)")
pr.add_argument("--rename-footprint-lib", metavar="OLD=NEW",
action="append",
help="rewrite Footprint fields 'OLD:*' -> 'NEW:*' in "
"registered symbol libraries and project schematics "
"(repeatable)")
pr.add_argument("--3d-path", dest="model_path", metavar="MODE",
help="rewrite bare-relative 3D model paths in registered "
"footprint libraries: 'absolute' or a '${VAR}' prefix")
pr.add_argument("--apply", action="store_true",
help="write the planned edits (atomic, with .bak)")
pr.set_defaults(handler=_cmd_library_repair)
pi = lib_sub.add_parser(
"import-altium", parents=[common],
help="convert an Altium .PcbLib into a .pretty library "
"(pads verbatim; provenance.json records source hash + "
"declared transformations)")
pi.add_argument("path", nargs="?", help="input .PcbLib")
pi.add_argument("-o", "--out", metavar="DIR",
help="output .pretty directory (default: <name>.pretty, "
"under [paths].parts_dir when configured)")
pi.add_argument("--courtyard", type=float, metavar="MM",
help="synthesize a pad-bbox courtyard with this clearance "
"when the source has none (declared transformation)")
pi.add_argument("--apply", action="store_true",
help="write the files (default: dry-run plan)")
pi.set_defaults(handler=_cmd_library_import_altium)
pn = lib_sub.add_parser(
"new-symbol", parents=[common],
help="generate a .kicad_sym from a pin-table JSON (coordinates are "
"computed, never authored); dry-run unless --apply")
pn.add_argument("spec", nargs="?",
help="input pin table (schemas/symbol-pins.schema.json)")
pn.add_argument("-o", "--out", metavar="FILE",
help="output .kicad_sym (default: <symbol name>.kicad_sym, "
"under [paths].parts_dir when configured)")
pn.add_argument("--name", metavar="NAME",
help="override the spec's symbol name")
pn.add_argument("--apply", action="store_true",
help="write the library (default: dry-run plan). Refuses "
"on any SYMLIB_PIN_COINCIDENT in its own output — two "
"pins on one point is connectivity, not geometry")
pn.set_defaults(handler=_cmd_library_new_symbol)
pls = lib_sub.add_parser(
"lint-symbols", parents=[common],
help="lint a .kicad_sym for coincident pins (an ERC-invisible "
"mis-join), unset electrical types and off-grid pins")
pls.add_argument("path", nargs="?", help="the .kicad_sym to lint")
_add_exit_policy_flags(pls)
pls.set_defaults(handler=_cmd_library_lint_symbols)
pl = lib_sub.add_parser(
"check-lock", parents=[common],
help="report which KiCad files are open in the GUI (~<name>.lck); "
"exit 6 if any are locked, so external flows can gate")
pl.add_argument("project", nargs="?", default=".",
help="project directory or file (default: .)")
pl.set_defaults(handler=_cmd_library_check_lock)
# `library relink` — the library-maintenance home of the top-level
# `relink-symbols` (same handler + flags; the old name stays for compat)
from .checks import _cmd_relink
pk = lib_sub.add_parser(
"relink", parents=[common],
help="re-embed stale lib_symbols cache entries from fresh "
".kicad_sym libraries (alias of `relink-symbols`; dry-run "
"unless --apply)")
pk.add_argument("target", nargs="?", help="the .kicad_sch to relink")
pk.add_argument("--libs", metavar="DIR", action="append",
help="symbol library dir or .kicad_sym file (repeatable); "
"default: the KiCad.app SharedSupport symbols dir "
"if it exists")
pk.add_argument("--only", metavar="NICKS",
help="comma-separated library nicknames (or full lib_ids) "
"to consider")
pk.add_argument("--apply", action="store_true",
help="write the replacements (default: preview only; "
"net-membership equivalence gated, leaves <name>.bak)")
pk.set_defaults(handler=_cmd_relink)