app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
0.9.279: Wiki #71 second cause, measured on ConfRoomROG with KiCad's own parser: one malformed symbol makes KiCad refus…
bad5c5b
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
"""KiCad's OWN verdict on a symbol library, via `kicad-cli sym export svg`.
Why this module exists (wiki #71, 2026-09-03, measured on ConfRoomROG):
the bridge's structural reader (`adom_library`, `kicad_lint_library`) said a
five-symbol Adom.kicad_sym "parsed cleanly", while KiCad itself answered
"Unable to load library" and the Symbol Editor showed the Adom library EMPTY.
Three minimal test symbols carried `hide yes` as bare tokens inside
`(effects ...)`; our reader only balances parentheses, KiCad's parser does
not accept the tokens, and ONE bad symbol makes KiCad drop the WHOLE library.
The two well-formed symbols alone loaded fine.
So: our parser is not KiCad's parser. Anything that writes into a library
KiCad reads must ask KiCad first. `sym export svg` is the one kicad-cli
command that parses a .kicad_sym read-only (the `upgrade` command rewrites
the file) and prints one "Plotting symbol 'NAME' unit N" line per unit it
loaded, so it doubles as a symbol inventory from KiCad's point of view.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
_PLOT_RE = re.compile(r"Plotting symbol '([^']+)' unit (\d+)")
def cli_exe(kicad_info: dict) -> str | None:
cli = (kicad_info or {}).get("kicad_cli_exe")
if cli and Path(cli).exists():
return cli
base = (kicad_info or {}).get("base_dir")
if base:
for cand in (Path(base) / "bin" / "kicad-cli.exe", Path(base) / "bin" / "kicad-cli"):
if cand.exists():
return str(cand)
return None
def verdict_file(kicad_info: dict, lib_path: str | Path, timeout_s: float = 60.0) -> dict:
"""KiCad's verdict on an existing .kicad_sym.
Returns {available, ok, symbols[], units, error, exitCode, durationMs}.
available=False means kicad-cli was not found: no verdict either way, and
callers must SAY that rather than treat it as a pass."""
cli = cli_exe(kicad_info)
if not cli:
return {"available": False, "ok": None, "symbols": [], "units": 0,
"error": "kicad-cli not found; KiCad's parser could not be asked"}
lib_path = str(lib_path)
if not Path(lib_path).exists():
return {"available": True, "ok": False, "symbols": [], "units": 0,
"error": f"file not found: {lib_path}"}
out_dir = tempfile.mkdtemp(prefix="adom-kicad-parse-")
t0 = time.monotonic()
try:
proc = subprocess.run([cli, "sym", "export", "svg", "-o", out_dir, lib_path],
capture_output=True, text=True, timeout=timeout_s,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
names: list[str] = []
units = 0
for m in _PLOT_RE.finditer(text):
units += 1
if m.group(1) not in names:
names.append(m.group(1))
ok = proc.returncode == 0
err = ""
if not ok:
lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not _PLOT_RE.search(ln)]
err = " | ".join(lines[:3])[:400] or f"kicad-cli exit {proc.returncode}"
return {"available": True, "ok": ok, "symbols": names, "units": units,
"error": err, "exitCode": proc.returncode,
"durationMs": int((time.monotonic() - t0) * 1000)}
except subprocess.TimeoutExpired:
return {"available": True, "ok": False, "symbols": [], "units": 0,
"error": f"kicad-cli did not answer within {timeout_s:.0f}s"}
except Exception as e: # pylint: disable=broad-except
return {"available": True, "ok": False, "symbols": [], "units": 0,
"error": f"{type(e).__name__}: {e}"[:300]}
finally:
shutil.rmtree(out_dir, ignore_errors=True)
def verdict_text(kicad_info: dict, lib_text: str, timeout_s: float = 60.0) -> dict:
"""Same verdict for library TEXT that has not been written anywhere yet:
the gate an install runs BEFORE it touches the user's file."""
fd, tmp = tempfile.mkstemp(suffix=".kicad_sym", prefix="adom-candidate-")
os.close(fd)
try:
Path(tmp).write_text(lib_text, encoding="utf-8")
return verdict_file(kicad_info, tmp, timeout_s)
finally:
try:
os.unlink(tmp)
except OSError:
pass
def wrap_symbol_blocks(blocks: list[str]) -> str:
return ('(kicad_symbol_lib (version 20231120) (generator "adom-parse-check")\n'
+ "\n".join(blocks) + "\n)\n")
def refused_symbols(kicad_info: dict, symbol_blocks: list[tuple[str, str]],
timeout_s: float = 60.0) -> dict:
"""Which of these (name, block) symbols does KiCad refuse ON THEIR OWN?
One bad symbol hides a whole library, so this is how a repair finds the
culprit(s): each block is wrapped as a one-symbol library and parsed alone.
Returns {available, accepted[], refused{name: error}}."""
cli = cli_exe(kicad_info)
if not cli:
return {"available": False, "accepted": [], "refused": {}}
accepted, refused = [], {}
for name, blk in symbol_blocks:
v = verdict_text(kicad_info, wrap_symbol_blocks([blk]), timeout_s)
if v.get("ok"):
accepted.append(name)
else:
refused[name] = v.get("error") or "refused"
return {"available": True, "accepted": accepted, "refused": refused}