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.
"""``lib_symbols`` cache resolution & copy for the KiCad writer (SPEC §3.5).
A ``.kicad_sch`` is self-contained: every symbol it instantiates is copied,
verbatim and **library-qualified**, into a top-level ``(lib_symbols ...)`` node.
A placed ``(symbol (lib_id "Device:R") ...)`` instance carries only pin numbers;
KiCad (and our ERC) reads the symbol's *electrical truth* — pin names, numbers
and **electrical types** — out of this cache. So before the executor can place a
component, the symbol must be present in ``lib_symbols``.
:func:`ensure_cached` is that step. For a requested ``lib_id`` (e.g.
``"Device:C_Polarized"``) it:
* locates the symbol across the configured *sources* (``.kicad_sym`` libraries
and/or a template ``.kicad_sch``'s inline ``lib_symbols``) via
:mod:`..readers.kicad_lib`, raising ``SYMBOL_NOT_FOUND`` on a miss;
* copies the raw ``(symbol ...)`` body (``SymbolDef.body_sexpr``) **deep**, so the
source library node is never mutated and **full pin electrical types are
preserved** (ERC needs them);
* **requalifies the parent symbol name** to ``Nick:Name`` (the instance's
``lib_id``) while keeping the child unit sub-symbol names unqualified
(``Name_0_1`` / ``Name_1_1``);
* **flattens an ``(extends ...)`` derived symbol** into a standalone definition:
the base's units/pins/graphics are copied under the derived name (unit
sub-symbols renamed ``Base_u_s`` -> ``Derived_u_s``) with the derived symbol's
own properties/settings overlaid, and the ``(extends)`` clause dropped. This is
what KiCad itself does when caching a derived symbol into a schematic —
KiCad's loader does *not* resolve a bare ``(extends "Base")`` against a
library-qualified cached base, so an unflattened cache entry loses its pins
(``lib_symbol_mismatch`` + every wire to the part dangling in eeschema);
* **dedups by qualified lib_id**: a symbol already present is left untouched, so
``ensure_cached`` is idempotent and safe to call once per placed component.
Geometry-free: this module only shuffles S-expression nodes; no coordinates are
touched. The serializer (``sexpr.dumps``) renders
the mutated ``doc`` back to text.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from .. import model
from ..errors import fail
from ..kicad_escape import escape_lib_id, unescape_string
from .. import sexpr
from ..readers import kicad_lib
from ..kicad_escape import quote_atom
from ..sexpr import SNode
from ..safety import read_text_guarded
__all__ = ["ensure_cached", "find_cached"]
# Guard against a pathological / cyclic ``(extends ...)`` chain. Real chains are
# 1-2 deep; we follow at most this many hops before declaring the library bad.
_MAX_EXTENDS = 64
# Trailing ``_<unit>_<body-style>`` of a unit sub-symbol name (``R_0_1`` -> ``_0_1``).
_UNIT_SUFFIX_RE = re.compile(r"_\d+_\d+$")
# Default whitespace used when synthesizing a fresh ``(lib_symbols ...)`` node or
# when a sibling-derived indent cannot be recovered (KiCad uses tab indentation).
_DOC_CHILD_WS = "\n\t" # one level deep: a direct child of (kicad_sch ...)
_SYM_CHILD_WS = "\n\t\t" # two levels deep: a (symbol ...) inside (lib_symbols ...)
# Tags that mark the start of a schematic's *body* (instances/graphics). A freshly
# created ``lib_symbols`` is inserted just before the first of these so it lands in
# its canonical position right after the header block.
_BODY_TAGS = frozenset(
{
"junction",
"no_connect",
"bus_entry",
"wire",
"bus",
"polyline",
"text",
"label",
"global_label",
"hierarchical_label",
"symbol",
"sheet",
"sheet_instances",
"symbol_instances",
}
)
# --------------------------------------------------------------------------- #
# Public API
# --------------------------------------------------------------------------- #
def ensure_cached(
doc: SNode,
lib_id: str,
sources: object,
) -> SNode:
"""Ensure ``lib_id`` (and any ``(extends)`` base) is present in ``doc``'s cache.
``doc`` is the parsed root :class:`SNode` of a ``.kicad_sch`` document; it is
mutated in place (its ``(lib_symbols ...)`` node is created if absent and the
resolved symbol(s) appended). ``sources`` is an iterable whose items are any
mix of:
* a :class:`model.Library` (already parsed),
* a path to a ``.kicad_sym`` library file, or
* a path to a template ``.kicad_sch`` (its inline ``lib_symbols`` is used).
Returns the cached ``(symbol ...)`` body — the freshly appended clone, or the
pre-existing cache entry — so callers can resolve pins from that single node
instead of re-parsing the whole (growing) cache per placement.
Raises ``SYMBOL_NOT_FOUND`` if ``lib_id`` (or an ``extends`` base) resolves in
none of the sources. Idempotent: a symbol already cached is left untouched.
"""
libs = _coerce_sources(sources)
libsyms = _ensure_lib_symbols_node(doc)
cached = _existing_names(libsyms)
# The requested symbol keeps the instance's exact qualified id; a derived
# symbol is flattened into a single standalone entry (no base is cached).
return _cache_symbol(libsyms, request=lib_id, qualified=lib_id, libs=libs, cached=cached)
def find_cached(libsyms: SNode | None, lib_id: str) -> SNode | None:
"""The cached ``(symbol ...)`` body for ``lib_id`` (exact, then unqualified)."""
if libsyms is None:
return None
unq = _unqual(lib_id)
fallback = None
for s in libsyms.find_all("symbol"):
nm = _symbol_name(s)
if nm == lib_id:
return s
if fallback is None and _unqual(nm) == unq:
fallback = s
return fallback
# --------------------------------------------------------------------------- #
# Caching core
# --------------------------------------------------------------------------- #
def _cache_symbol(
libsyms: SNode,
request: str,
qualified: str,
libs: list[model.Library],
cached: set[str],
) -> SNode:
"""Resolve ``request`` and copy it as ``qualified`` (flattening any base)."""
if qualified in cached:
existing = find_cached(libsyms, qualified)
if existing is not None:
return existing # already present (pre-existing or just added) -> dedup
sym = _find_symbol(request, libs)
if sym is None:
fail("SYMBOL_NOT_FOUND", f"symbol {request!r} not found in any source library")
if not isinstance(sym.body_sexpr, SNode):
# Only KiCad-sourced SymbolDefs carry a raw body we can copy.
fail("SYMBOL_NOT_FOUND", f"symbol {request!r} has no copyable KiCad body")
body = _flattened_body(sym, libs)
_set_symbol_name(body, qualified)
_append_symbol(libsyms, body)
cached.add(qualified)
return body
# --------------------------------------------------------------------------- #
# Derived-symbol flattening (the KiCad-save behavior)
# --------------------------------------------------------------------------- #
def _flattened_body(sym: model.SymbolDef, libs: list[model.Library]) -> SNode:
"""Deep-copied standalone body for ``sym``, its ``(extends)`` chain flattened.
KiCad never caches a derived symbol as-is into a ``.kicad_sch``: its save
path flattens (``LIB_SYMBOL::Flatten``), and its loader does not resolve a
bare ``(extends "Base")`` against a library-qualified cached base — an
unflattened entry therefore loses its unit sub-symbols and pins, and every
wire to the placed part dangles in eeschema. We mirror the flatten: start
from the root base's body, rename its unit sub-symbols to the derived name,
then overlay each descendant's own children (derived-most last) with
property-by-name / tag replacement, dropping ``(extends)`` itself.
"""
if not sym.extends:
return _clone(sym.body_sexpr)
# Resolve derived -> ... -> root base (cycle- and depth-guarded).
chain: list[model.SymbolDef] = [sym]
seen = {_unqual(sym.name)}
cur = sym
while cur.extends:
if len(chain) > _MAX_EXTENDS:
fail("SYMBOL_NOT_FOUND", f"extends chain too deep resolving {sym.name!r}")
base = _find_symbol(cur.extends, libs)
if base is None:
fail(
"SYMBOL_NOT_FOUND",
f"extends base {cur.extends!r} of {cur.name!r} not found in any source library",
)
if not isinstance(base.body_sexpr, SNode):
fail("SYMBOL_NOT_FOUND", f"extends base {cur.extends!r} has no copyable KiCad body")
if _unqual(base.name) in seen:
fail("SYMBOL_NOT_FOUND", f"cyclic extends chain at {base.name!r}")
seen.add(_unqual(base.name))
chain.append(base)
cur = base
derived_unqual = _unqual(sym.name)
body = _clone(chain[-1].body_sexpr)
_rename_child_units(body, derived_unqual)
# Overlay from the root-most derived layer up to ``sym`` (nearest wins).
for layer in chain[-2::-1]:
_overlay_symbol(body, layer.body_sexpr, derived_unqual)
return body
def _overlay_symbol(body: SNode, overlay: SNode, derived_unqual: str) -> None:
"""Merge ``overlay``'s list children into ``body`` (KiCad flatten semantics).
``(property "Name" ...)`` replaces the same-named property (else is inserted
after the last property); a ``(symbol "..._u_s")`` unit replaces the unit
with the same ``_u_s`` suffix (else appended), renamed to the derived name;
``(extends ...)`` is dropped; any other tag replaces its first same-tag
sibling (else is inserted before the properties).
"""
for ch in list(overlay.children or []):
if not ch.is_list:
continue
tag = ch.tag
if tag == "extends":
continue
copy = _clone(ch)
if tag == "property":
_merge_property(body, copy)
elif tag == "symbol":
_merge_child_unit(body, copy, derived_unqual)
else:
_merge_plain(body, copy)
def _merge_property(body: SNode, prop: SNode) -> None:
name = _property_name(prop)
last_prop = -1
for i, ch in enumerate(body.children or []):
if ch.is_list and ch.tag == "property":
last_prop = i
if name is not None and _property_name(ch) == name:
body.children[i] = prop
return
if last_prop >= 0:
_insert_child(body, last_prop + 1, prop)
else:
_insert_child(body, _first_child_unit_index(body), prop)
def _merge_child_unit(body: SNode, unit: SNode, derived_unqual: str) -> None:
suffix = _unit_suffix(_symbol_name(unit))
if suffix is not None:
_set_symbol_name(unit, f"{derived_unqual}{suffix}")
for i, ch in enumerate(body.children or []):
if ch.is_list and ch.tag == "symbol" and _unit_suffix(_symbol_name(ch)) == suffix:
body.children[i] = unit
return
_insert_child(body, len(body.children), unit)
def _merge_plain(body: SNode, node: SNode) -> None:
for i, ch in enumerate(body.children or []):
if ch.is_list and ch.tag == node.tag:
body.children[i] = node
return
# New setting: keep it in the header region, before the first property/unit.
idx = _first_child_unit_index(body)
for i, ch in enumerate(body.children or []):
if ch.is_list and ch.tag == "property":
idx = i
break
_insert_child(body, idx, node)
def _rename_child_units(body: SNode, derived_unqual: str) -> None:
"""Rename every ``(symbol "Base_u_s")`` child to ``Derived_u_s`` in place."""
for ch in body.children or []:
if ch.is_list and ch.tag == "symbol":
suffix = _unit_suffix(_symbol_name(ch))
if suffix is not None:
_set_symbol_name(ch, f"{derived_unqual}{suffix}")
def _unit_suffix(name: str | None) -> str | None:
"""The trailing ``_<unit>_<style>`` of a unit sub-symbol name (or ``None``)."""
if not name:
return None
m = _UNIT_SUFFIX_RE.search(name)
return m.group(0) if m else None
def _property_name(prop: SNode) -> str | None:
kids = prop.children or []
if len(kids) >= 2 and kids[1].is_atom:
return kids[1].value
return None
def _first_child_unit_index(body: SNode) -> int:
for i, ch in enumerate(body.children or []):
if ch.is_list and ch.tag == "symbol":
return i
return len(body.children or [])
def _insert_child(body: SNode, idx: int, node: SNode) -> None:
"""Insert ``node`` at ``idx`` with the body's child indentation."""
indent = _body_child_indent(body)
body.children.insert(idx, node)
# ``ws[i]`` precedes ``children[i]``; the trailing entry belongs to ")".
body.ws.insert(idx, indent)
def _body_child_indent(body: SNode) -> str:
for w in (body.ws or [])[1:-1]:
if "\n" in w:
return w
return _SYM_CHILD_WS + "\t"
def _find_symbol(name: str, libs: list[model.Library]) -> model.SymbolDef | None:
"""Find a symbol by exact name/lib_id, falling back to an unqualified match.
Mirrors :func:`kicad_lib._find_symbol` without depending on a private name:
an exact pass matches ``Device:R`` or a fully qualified request; the fallback
lets a bare ``(extends "C")`` resolve against a qualified ``Device:C`` entry
and a qualified ``Device:R`` request resolve against an unqualified ``R`` lib.
"""
for lib in libs:
for s in lib.symbols:
if s.name == name or s.lib_id == name:
return s
target = _unqual(name)
for lib in libs:
for s in lib.symbols:
if _unqual(s.name) == target:
return s
return None
# --------------------------------------------------------------------------- #
# ``lib_symbols`` node management
# --------------------------------------------------------------------------- #
def _ensure_lib_symbols_node(doc: SNode) -> SNode:
"""Return ``doc``'s ``(lib_symbols ...)`` node, creating + inserting one if absent."""
if not isinstance(doc, SNode) or doc.is_atom:
fail("SYMBOL_NOT_FOUND", "document is not a parsed kicad_sch node")
existing = doc.find("lib_symbols")
if existing is not None:
return existing
# Fresh node: ``(lib_symbols\n\t)`` — head atom + a closing-paren indent so the
# first appended symbol nests cleanly.
node = SNode(False, children=[SNode.atom("lib_symbols")], ws=["", _DOC_CHILD_WS])
_insert_doc_child(doc, node)
return node
def _insert_doc_child(doc: SNode, node: SNode) -> None:
"""Insert ``node`` as a child of ``doc`` just before its body (instances/graphics)."""
kids = doc.children or []
indent = _doc_child_indent(doc)
idx = len(kids) # default: append before the closing paren
for i, child in enumerate(kids):
if child.is_list and child.tag in _BODY_TAGS:
idx = i
break
kids.insert(idx, node)
# ``ws[i]`` is the whitespace before ``children[i]``; inserting at ``idx`` pushes
# the old ``ws[idx]`` (which belonged to the node now at ``idx+1``) along.
doc.ws.insert(idx, indent)
def _append_symbol(libsyms: SNode, body: SNode) -> None:
"""Append a ``(symbol ...)`` ``body`` as the last child of ``libsyms``."""
indent = _sym_child_indent(libsyms)
libsyms.children.append(body)
# Insert the new child's leading whitespace just before the closing-paren ws.
libsyms.ws.insert(len(libsyms.ws) - 1, indent)
def _existing_names(libsyms: SNode) -> set[str]:
"""Set of qualified symbol names already cached in ``libsyms``."""
out: set[str] = set()
for s in libsyms.find_all("symbol"):
nm = _symbol_name(s)
if nm is not None:
out.add(nm)
return out
# --------------------------------------------------------------------------- #
# Node helpers
# --------------------------------------------------------------------------- #
def _symbol_name(sym: SNode) -> str | None:
"""Decoded, **unescaped** name of a ``(symbol "Name" ...)`` node, or ``None``.
Unescaping KiCad's ``{slash}`` tokens here makes every cache comparison
(``find_cached``, dedup, unit-suffix matching) agree with the unescaped
``lib_id``s the writer resolves against — regardless of whether the on-disk
name was written escaped (KiCad) or raw (older akcli)."""
kids = sym.children or []
if len(kids) >= 2 and kids[1].is_atom:
return unescape_string(kids[1].value)
return None
def _set_symbol_name(sym: SNode, name: str) -> None:
"""Requalify a ``(symbol "Old" ...)`` name atom in place, **escaped** for KiCad.
``name`` is an unescaped lib_id / sub-symbol name; it is written in KiCad's
``{slash}`` form so a save through KiCad does not rewrite the file. Escaping
is a no-op for names without special characters (the overwhelming majority)."""
kids = sym.children or []
if len(kids) < 2 or not kids[1].is_atom:
fail("SYMBOL_NOT_FOUND", "malformed symbol body (missing name atom)")
kids[1].text = _quote(escape_lib_id(name))
def _doc_child_indent(doc: SNode) -> str:
"""Indentation of a direct child of the document (e.g. before ``(version ...)``)."""
for w in (doc.ws or [])[:-1]:
if "\n" in w:
return w
return _DOC_CHILD_WS
def _sym_child_indent(libsyms: SNode) -> str:
"""Indentation of a ``(symbol ...)`` child inside ``lib_symbols``.
Recovered from an existing newline-bearing child whitespace (never the final
closing-paren ws, which is one level shallower); falls back to two tabs.
"""
for w in (libsyms.ws or [])[:-1]:
if "\n" in w:
return w
return _SYM_CHILD_WS
# --------------------------------------------------------------------------- #
# Deep clone (iterative — no native recursion on attacker-influenced trees)
# --------------------------------------------------------------------------- #
def _clone(node: SNode) -> SNode:
"""Deep-copy an :class:`SNode` so the source library tree is never mutated."""
if node.is_atom:
return _clone_atom(node)
root = _new_list(node)
# Each stack frame pairs a source list node with its fresh copy; children are
# appended in source order within each frame, so ordering is preserved.
stack: list[tuple[SNode, SNode]] = [(node, root)]
while stack:
src, dst = stack.pop()
for ch in src.children or []:
if ch.is_atom:
dst.children.append(_clone_atom(ch))
else:
copy = _new_list(ch)
dst.children.append(copy)
stack.append((ch, copy))
return root
def _clone_atom(node: SNode) -> SNode:
a = SNode.atom(node.text)
a.prefix = node.prefix
a.suffix = node.suffix
return a
def _new_list(node: SNode) -> SNode:
nl = SNode(False, children=[], ws=list(node.ws or []))
nl.prefix = node.prefix
nl.suffix = node.suffix
return nl
# --------------------------------------------------------------------------- #
# Name / quoting helpers
# --------------------------------------------------------------------------- #
def _unqual(name: str | None) -> str:
"""Library-unqualified symbol name (``Device:R`` -> ``R``)."""
return name.split(":")[-1] if name else ""
# The ONE quoter (kicad_escape.quote_atom); local name kept for callers.
_quote = quote_atom
# --------------------------------------------------------------------------- #
# Source coercion
# --------------------------------------------------------------------------- #
def _coerce_sources(sources: object) -> list[model.Library]:
"""Normalize ``sources`` into a list of :class:`model.Library`.
Accepts a single source or an iterable of sources; each item is a
:class:`model.Library`, a ``.kicad_sym`` path, or a template ``.kicad_sch``
path (its inline ``lib_symbols`` is harvested).
"""
if sources is None:
return []
if isinstance(sources, (model.Library, str, bytes, os.PathLike)):
items: list = [sources]
else:
items = list(sources) # type: ignore[arg-type]
return [_coerce_one(item) for item in items]
def _coerce_one(src: object) -> model.Library:
if isinstance(src, model.Library):
return src
if not isinstance(src, (str, bytes, os.PathLike)):
fail("SYMBOL_NOT_FOUND", f"unsupported symbol source: {type(src).__name__}")
p = Path(os.fspath(src))
if p.suffix.lower() == ".kicad_sym":
return kicad_lib.read(p)
root = sexpr.parse(_read_text(p))
if root.tag == "kicad_symbol_lib":
return kicad_lib.read(p)
libnode = root.find("lib_symbols")
if libnode is None:
fail("SYMBOL_NOT_FOUND", f"no lib_symbols in template source {p}")
return kicad_lib.library_from_lib_symbols(libnode, str(p))
def _read_text(path: os.PathLike | str) -> str:
"""Read a UTF-8 text source bounded by ``MAX_FILE_BYTES``.
One guarded reader for the whole package (see
:func:`akcli.safety.read_text_guarded`): a directory / unreadable file /
non-UTF-8 bytes become structured errors instead of a traceback.
"""
return read_text_guarded(path)