Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""The zero-dependency op-list validator (+ loading and the prepare pipeline).
Mirrors ``schemas/ops.schema.json`` structurally without pulling in
``jsonschema`` at runtime (``jsonschema`` is a dev/test-only dependency) —
both are views of :mod:`akcli.opspec`, so the mirror cannot drift. It rejects
free rotation angles, malformed wire vertex arrays, unknown ops and protocol
mismatches using the frozen ERROR codes from :mod:`akcli.errors`, and
additionally lints: per-field TYPES, unknown fields (with a difflib
did-you-mean, or an explicit "renamed in protocol 2" line for the fields
:data:`_RENAMED_IN_V2` moved; keys starting with ``_`` are ignored as
annotations), and duplicate ``(designator, unit)`` placements across the
document.
"""
from __future__ import annotations
import difflib
import json
from dataclasses import dataclass
from pathlib import Path
from ..classify import refdes_prefix as _classify_refdes_prefix
from ..errors import AkcliError
from .base import (
PROTOCOL_VERSION,
OpError,
_in,
_is_anchor,
_is_anchor_ref,
_is_endpoint,
_is_point,
)
from .groups import _GROUP_META_FIELDS, is_resolved, resolve_groups
from .lints import (
_ANNOTATION_KEY_TAGS,
_duplicate_placements,
_duplicate_power_ports,
)
from .macros import expand_macros
from .vocab import (
_MACRO_DESIGNATOR_ROLES,
_MATCH_KINDS,
_OP_FIELDS,
_OP_REQUIRED,
_ROLE_PREFIXES,
_SHEET_PIN_SIDES,
_SHEET_PIN_TYPES,
_VALID_FILLS,
_VALID_MIRRORS,
_VALID_ROTATIONS,
_VALID_ROUTE_STYLES,
_VALID_SCOPES,
_VALID_TARGETS,
MACRO_OPS,
MACRO_OPTIONAL,
MACRO_REQUIRED,
OP_NAMES,
)
# The ONE refdes-prefix implementation lives in classify (strips a
# synthesized ``$`` — irrelevant for op-list-authored designators, identical
# everywhere else); alias kept for this module's macro-role checks.
_refdes_prefix = _classify_refdes_prefix
def _macro_role_slots(name: str, op: dict) -> tuple[str, tuple[str, ...]] | None:
"""``(field, roles)`` for a macro's designator slots, or ``None`` if unknown.
``place_divider``/``place_decoupling``/``place_pullup`` expose ``lib_id``:
an override that is still the same part class (``Device:R_Small``) keeps
the check, an override to a different class hands the role question back
to the caller and disables it.
"""
entry = _MACRO_DESIGNATOR_ROLES.get(name)
if entry is None:
return None
field, roles = entry
lib = op.get("lib_id")
if isinstance(lib, str) and lib:
part = lib.split(":")[-1]
if _refdes_prefix(part) not in _ROLE_PREFIXES[roles[0]]:
return None
return field, roles
def _check_macro_designator_roles(idx: int, name: str, op: dict,
out: list[OpError]) -> None:
"""Reject a designator whose prefix contradicts its positional slot's part."""
entry = _macro_role_slots(name, op)
if entry is None:
return
field, roles = entry
raw = op.get(field)
if raw is None:
return
values = [raw] if field == "designator" else raw
if not isinstance(values, list):
return # shape errors belong to the expander
# the slot order the user should see is the template's own default
# designators (["Y1", "C1", "C2"] -> "Y, C, C") — one canonical spelling
default = MACRO_OPTIONAL.get(name, {}).get(field)
defaults = [default] if field == "designator" else default
order = ", ".join(_refdes_prefix(d) for d in defaults
if isinstance(d, str)) if isinstance(defaults, list) else ""
for slot, (value, role) in enumerate(zip(values, roles)):
if not isinstance(value, str) or not value:
continue
want = _ROLE_PREFIXES[role]
got = _refdes_prefix(value)
if got in want:
continue
where = field if field == "designator" else f"{field}[{slot}]"
out.append(OpError(
idx, name, "OP_UNSUPPORTED",
f"{name}.{where} {value!r}: slot {slot} is the {role} — expected a "
f"designator prefixed {'/'.join(sorted(want))}, got {got or value!r}"
f" (slot order is [{order}]; see `akcli ops template {name}`)"))
def _check_rotation(idx: int, name: str, op: dict, key: str, out: list[OpError]) -> None:
if key in op and not _in(op[key], _VALID_ROTATIONS):
out.append(OpError(idx, name, "BAD_ANGLE", f"{key} {op[key]!r} not in {{0,90,180,270}}"))
# Generic kind checkers: predicate + human description for the error message.
_KIND_CHECKS: dict[str, tuple] = {
"str": (lambda v: isinstance(v, str), "a string"),
"num": (lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"a number"),
"posint": (lambda v: isinstance(v, int) and not isinstance(v, bool) and v >= 1,
"an integer >= 1"),
"bool": (lambda v: isinstance(v, bool), "true or false"),
"point": (_is_point, "an [x, y] point"),
"endpoint": (_is_endpoint, 'an [x, y] point or "REF.PIN"'),
"anchor": (_is_anchor,
'an [x, y] point, "REF.PIN" or "mid(REF.PIN,REF.PIN)"'),
"dict": (lambda v: isinstance(v, dict), "an object"),
"fill": (lambda v: _in(v, _VALID_FILLS),
'one of "none", "outline", "background"'),
"anchor_ref": (_is_anchor_ref, '"REF" or "REF.PIN"'),
"route_style": (lambda v: _in(v, _VALID_ROUTE_STYLES),
'one of "auto", "hv", "vh", "z"'),
}
# Fields renamed by protocol 2, PER OP: a v1 document keeps the old spelling
# and would otherwise get a bare "unknown field" (difflib has no chance on
# ``from`` -> ``old_name``). Scoped by op on purpose — ``route_net`` and
# ``connect_and_label`` still take ``from``/``to`` (pin refs), and
# ``connect_and_label`` still takes ``orientation``; only the ops whose field
# actually moved may claim the rename.
_RENAMED_IN_V2: dict[str, dict[str, str]] = {
"add_net_label": {"orientation": "rotation"},
"set_component_parameters": {
"reference": "new_designator",
"instance_references": "instance_designators",
},
"rename_net": {"from": "old_name", "to": "new_name"},
}
def _unknown_field(idx: int, name: str, field: str, known,
out: list[OpError]) -> None:
renamed = _RENAMED_IN_V2.get(name, {}).get(field)
if renamed is not None:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}: field {field!r} was renamed to "
f"{renamed!r} in protocol {PROTOCOL_VERSION}"))
return
known_list = list(known)
hint = difflib.get_close_matches(field, known_list, n=1)
suggestion = f" (did you mean {hint[0]!r}?)" if hint else ""
if not suggestion and field == "at" and "x_mil" in known_list:
# the most common coordinate-field mixup: at:[x,y] ops vs x_mil/y_mil
suggestion = f" ({name} takes \"x_mil\"/\"y_mil\", not \"at\")"
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}: unknown field {field!r}{suggestion}"))
def _check_match(idx: int, name: str, m: object, out: list[OpError]) -> None:
"""Validate a delete_object ``match`` selector {kind, name?, at?}."""
if not isinstance(m, dict):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.match must be an object {{kind, name?, at?}}"))
return
kind = m.get("kind")
if not isinstance(kind, str) or kind not in _MATCH_KINDS:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.match.kind must be one of "
f"{{{', '.join(sorted(_MATCH_KINDS))}}}"))
if "name" in m and not isinstance(m["name"], str):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.match.name must be a string"))
if "at" in m and not _is_point(m["at"]):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.match.at must be an [x, y] point"))
for k in m:
if k not in ("kind", "name", "at") and not k.startswith("_"):
hint = difflib.get_close_matches(k, ["kind", "name", "at"], n=1)
suggestion = f" (did you mean {hint[0]!r}?)" if hint else ""
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}: unknown field 'match.{k}'{suggestion}"))
def _check_sheet_pins(idx: int, name: str, pins: object, out: list[OpError]) -> None:
"""Validate an add_sheet ``pins`` array of {name, type, side, offset_mil}."""
if not isinstance(pins, list):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins must be an array of pin objects"))
return
for i, pin in enumerate(pins):
if not isinstance(pin, dict):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}] must be an object "
"{name, type, side, offset_mil}"))
continue
for req in ("name", "type", "side", "offset_mil"):
if req not in pin:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}] missing required field {req!r}"))
if "name" in pin and not isinstance(pin["name"], str):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}].name must be a string"))
if "type" in pin and not _in(pin["type"], _SHEET_PIN_TYPES):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}].type {pin['type']!r} not in "
f"{{{', '.join(sorted(_SHEET_PIN_TYPES))}}}"))
if "side" in pin and not _in(pin["side"], _SHEET_PIN_SIDES):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}].side {pin['side']!r} not in "
f"{{left, right, top, bottom}}"))
off = pin.get("offset_mil")
if "offset_mil" in pin and (not isinstance(off, (int, float))
or isinstance(off, bool)):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.pins[{i}].offset_mil must be a number"))
for k in pin:
if k not in ("name", "type", "side", "offset_mil") and not k.startswith("_"):
_unknown_field(idx, name, f"pins[{i}].{k}",
["name", "type", "side", "offset_mil"], out)
def _validate_op(idx: int, op: object) -> list[OpError]:
out: list[OpError] = []
if not isinstance(op, dict):
out.append(OpError(idx, None, "OP_UNSUPPORTED", "op must be a JSON object"))
return out
name = op.get("op")
# "group" and "sheet" are universally-allowed optional tags (see
# resolve_groups / the multi-sheet envelope) — validated here once
# instead of polluting every per-op field table.
if "group" in op and not isinstance(op["group"], str):
out.append(OpError(idx, name if isinstance(name, str) else None,
"OP_UNSUPPORTED",
"group must be a string (a key of the envelope "
"'groups' map)"))
if "sheet" in op and not isinstance(op["sheet"], str):
out.append(OpError(idx, name if isinstance(name, str) else None,
"OP_UNSUPPORTED",
"sheet must be a string (a key of the envelope "
"'sheets' map)"))
if _in(name, MACRO_OPS):
# macros are part of the document vocabulary: a not-yet-expanded
# op-list must validate; plan/draw expand them before execution
for req in MACRO_REQUIRED.get(name, []):
if req not in op:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"missing required field {req!r}"))
known = set(MACRO_REQUIRED.get(name, [])) | set(MACRO_OPTIONAL.get(name, {}))
for field in op:
if field in ("op", "group", "sheet") or field in known or field.startswith("_"):
continue
_unknown_field(idx, name, field, known, out)
_check_macro_designator_roles(idx, str(name), op, out)
return out
if not _in(name, OP_NAMES):
hint = difflib.get_close_matches(
str(name), list(OP_NAMES | MACRO_OPS), n=1) if isinstance(name, str) else []
suggestion = f" (did you mean {hint[0]!r}?)" if hint else ""
out.append(OpError(idx, name if isinstance(name, str) else None,
"OP_UNSUPPORTED", f"unknown op {name!r}{suggestion}"))
return out
for req in _OP_REQUIRED.get(name, []):
if req not in op:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name} missing required field {req!r}"))
if name == "delete_object" and ("uuid" in op) == ("match" in op):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
"delete_object needs exactly one of 'uuid' or 'match'"))
if name == "set_title_block" and not any(
f in op for f in _OP_FIELDS["set_title_block"]):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
"set_title_block needs at least one field "
"(title/date/rev/company/comment1..9)"))
if name in ("place_component", "move_component"):
# exactly one position form: x_mil+y_mil (absolute) XOR anchor
# (+offset_mil, world-frame offset from a "REF"/"REF.PIN" anchor)
if "anchor" in op:
if "x_mil" in op or "y_mil" in op:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name} takes either x_mil/y_mil or "
"anchor (+offset_mil), not both"))
else:
for req in ("x_mil", "y_mil"):
if req not in op:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name} missing required field {req!r} "
"(or place relative: anchor + offset_mil)"))
if "offset_mil" in op:
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.offset_mil requires anchor"))
# field-TYPE table: unknown fields (did-you-mean) + per-field kind checks.
# Keys starting with "_" are ignored (annotation escape, schema-compatible).
fields = _OP_FIELDS.get(name, {})
for field, value in op.items():
if field in ("op", "group", "sheet") or field.startswith("_"):
continue
kind = fields.get(field)
if kind is None:
_unknown_field(idx, name, field, fields, out)
elif kind == "rotation":
_check_rotation(idx, name, op, field, out)
elif kind == "mirror":
if not _in(value, _VALID_MIRRORS):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"mirror {value!r} not in {{none,x,y}}"))
elif kind == "scope":
if not _in(value, _VALID_SCOPES):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"scope {value!r} not in {{local,global,hierarchical}}"))
elif kind == "match":
_check_match(idx, name, value, out)
elif kind == "sheetpins":
_check_sheet_pins(idx, name, value, out)
elif kind != "vertices": # wire block below
check, desc = _KIND_CHECKS[kind]
if not check(value):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.{field} must be {desc}, got {value!r}"))
if name in ("add_wire", "add_bus") and "vertices" in op:
# absent vertices is already a missing-required-field error above —
# re-reporting it here as a wire-shape problem only misleads.
verts = op["vertices"]
if not isinstance(verts, list):
out.append(OpError(idx, name, "OP_UNSUPPORTED",
f"{name}.vertices must be an array of >= 2 "
f"points, got {verts!r}"))
elif len(verts) < 2:
out.append(OpError(idx, name, "NON_ORTHOGONAL_WIRE",
"vertices must be an array of >= 2 points"))
else:
endpoint_ok = _is_endpoint if name == "add_wire" else _is_point
for v in verts:
if not endpoint_ok(v):
out.append(OpError(idx, name, "NON_ORTHOGONAL_WIRE",
f"malformed vertex {v!r}"))
break
return out
def validate_oplist(doc: dict) -> list[OpError]:
"""Structurally validate an op-list document; return all problems found.
Never raises for structural issues — returns a complete ``OpError`` list so a
caller can report everything at once. Document-level issues use ``op_index=-1``.
"""
errs: list[OpError] = []
if not isinstance(doc, dict):
return [OpError(-1, None, "OP_UNSUPPORTED", "op-list must be a JSON object")]
pv = doc.get("protocol_version")
if pv != PROTOCOL_VERSION:
errs.append(OpError(-1, None, "PROTOCOL_MISMATCH",
f"protocol_version {pv!r} != {PROTOCOL_VERSION}"))
tf = doc.get("target_format")
if not _in(tf, _VALID_TARGETS):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
f"target_format {tf!r} not in {{kicad,altium}}"))
groups = doc.get("groups")
if groups is not None:
if not isinstance(groups, dict):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
"'groups' must be an object of "
"{name: {origin: [x_mil, y_mil], ...}}"))
else:
for gname, meta in groups.items():
if not isinstance(meta, dict) or not _is_point(meta.get("origin")):
errs.append(OpError(-1, None, "GROUP_NO_ORIGIN",
f"group {gname!r} needs an 'origin': "
"[x_mil, y_mil]"))
continue
if "title" in meta and not isinstance(meta["title"], str):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
f"groups[{gname!r}].title must be a string"))
if "frame" in meta and not isinstance(meta["frame"], bool):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
f"groups[{gname!r}].frame must be true or false"))
for k in meta:
if k not in _GROUP_META_FIELDS and not k.startswith("_"):
hint = difflib.get_close_matches(k, _GROUP_META_FIELDS, n=1)
suggestion = f" (did you mean {hint[0]!r}?)" if hint else ""
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
f"groups[{gname!r}]: unknown field "
f"{k!r}{suggestion}"))
sheets = doc.get("sheets")
if sheets is not None:
if not isinstance(sheets, dict):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
"'sheets' must be an object of "
"{name: {file: '<child.kicad_sch>'}}"))
sheets = None
else:
for sname, meta in sheets.items():
if not isinstance(meta, dict) or not isinstance(
meta.get("file"), str) or not meta.get("file"):
errs.append(OpError(-1, None, "SHEET_UNKNOWN",
f"sheets[{sname!r}] needs a 'file': "
"'<child.kicad_sch>'"))
continue
for k in meta:
if k != "file" and not k.startswith("_"):
errs.append(OpError(-1, None, "OP_UNSUPPORTED",
f"sheets[{sname!r}]: unknown "
f"field {k!r}"))
ops = doc.get("ops")
if not isinstance(ops, list):
errs.append(OpError(-1, None, "OP_UNSUPPORTED", "ops must be an array"))
return errs
# Every 'sheet'-tagged op must reference a declared sheets entry (the tag
# is validated per-op for type; the MAP membership is a document check).
declared = set(sheets or ())
for idx, op in enumerate(ops):
tag = op.get("sheet") if isinstance(op, dict) else None
if isinstance(tag, str) and tag not in declared:
hint = difflib.get_close_matches(tag, declared, n=1) if declared else []
suggestion = f" (did you mean {hint[0]!r}?)" if hint else ""
errs.append(OpError(idx, op.get("op") if isinstance(op, dict) else None,
"SHEET_UNKNOWN",
f"op's sheet {tag!r} is not declared in the "
f"envelope 'sheets' map{suggestion}"))
for i, op in enumerate(ops):
errs.extend(_validate_op(i, op))
# document-level lint: the same (designator, unit) placed twice
for i, d, u, first in _duplicate_placements(ops):
errs.append(OpError(i, "place_component", "OP_UNSUPPORTED",
f"duplicate placement of designator {d!r} unit {u} "
f"(first placed at op [{first}])"))
# document-level lint: two power ports dropped on the same anchor. Their
# symbol bodies coincide and the writer derives both node uuids from that
# one coordinate, so the second silently owns the spot — always an authoring
# slip (a rail needs its own anchor), never a valid intent.
for i, opname, at, first in _duplicate_power_ports(ops):
errs.append(OpError(i, opname, "POWER_PORT_STACKED",
f"duplicate power port at {at!r} (first placed at "
f"op [{first}]) — two power symbols stacked on one "
"anchor overlap and only one survives; give each "
"its own point/pin"))
# document-level lint: two annotation ops sharing a stable key — the
# writer derives the node uuid from (tag, key) alone, so the second op
# would silently REPLACE the first's node while both report ok.
seen_keys: dict[tuple[str, str], int] = {}
for i, op in enumerate(ops):
if not isinstance(op, dict):
continue
tag = _ANNOTATION_KEY_TAGS.get(str(op.get("op", "")))
key = op.get("key")
if tag is None or not isinstance(key, str) or not key:
continue
first_i = seen_keys.setdefault((tag, key), i)
if first_i != i:
errs.append(OpError(i, str(op.get("op")), "OP_UNSUPPORTED",
f"duplicate annotation key {key!r} for "
f"{op.get('op')} (first used at op "
f"[{first_i}]) — the second op would replace "
"the first node in place; use distinct keys"))
return errs
@dataclass(frozen=True)
class PreparedOpList:
"""The result of :func:`prepare_oplist` — the document plus its verdict.
A frozen record instead of the old ``(doc, errors)`` tuple because the
third piece of state, "were group-local coordinates translated?", used to
be smuggled INSIDE ``doc`` as a ``_groups_resolved`` key and leaked into
every payload that echoed the prepared op-list. Pipeline state belongs
beside the document, not in it.
``doc`` is macro-expanded and group-resolved (unchanged input when
``errors`` is non-empty — a document with errors is never expanded).
"""
doc: dict
errors: tuple[OpError, ...] = ()
groups_resolved: bool = False
@property
def ok(self) -> bool:
"""True when nothing is wrong with the document."""
return not self.errors
def prepare_oplist(doc: dict) -> PreparedOpList:
"""Validate, expand macros + resolve groups, validate again.
THE order matters. Expanding first (what plan/draw/``ops validate`` used to
do) means the macro branch of :func:`_validate_op` never sees a macro op —
macros leave the document before anything looks at their fields, so a typo
(``valu``) or a hallucinated field sailed straight through into a
silently-different circuit. Validating the AUTHOR'S ops first closes that;
the expansion is validated too, because a well-formed macro can still emit
core ops that clash with hand-written ones (duplicate designators, stacked
power ports).
Every returned :class:`OpError` carries the author's op index — errors
found on expanded ops are mapped back through the expansion trace, so
``[3]`` always means "the fourth op in YOUR file".
Returns a :class:`PreparedOpList`. Bad macro arguments / bad group
references still raise :class:`AkcliError` from the expansion step, exactly
as before.
"""
errs = validate_oplist(doc)
if errs:
# a macro with a bogus field must not be expanded: the expander would
# ignore the field and manufacture a plausible-looking circuit.
return PreparedOpList(doc, tuple(errs))
origins: list[int] = []
expanded = resolve_groups(expand_macros(doc, origins=origins))
mapped: list[OpError] = []
for e in validate_oplist(expanded):
if 0 <= e.op_index < len(origins):
mapped.append(OpError(origins[e.op_index], e.op, e.code, e.message))
else:
mapped.append(e)
return PreparedOpList(expanded, tuple(mapped), groups_resolved=is_resolved(expanded))
def load_oplist(path: str | Path) -> dict:
"""Load and JSON-parse an op-list file. Malformed JSON -> ``OP_UNSUPPORTED``."""
p = Path(path)
try:
text = p.read_bytes().decode("utf-8")
except FileNotFoundError:
raise
except OSError as exc: # a directory, mode 000, ... -> exit 4, not INTERNAL
raise AkcliError("FILE_UNREADABLE", f"{p}: {exc.strerror or exc}") from exc
except UnicodeDecodeError as exc:
raise AkcliError(
"OP_UNSUPPORTED",
f"op-list {p} is not UTF-8 text (byte at offset {exc.start}); "
"an op-list is a JSON document") from exc
try:
doc = json.loads(text)
except json.JSONDecodeError as exc:
raise AkcliError("OP_UNSUPPORTED", f"invalid op-list JSON: {exc}") from exc
if not isinstance(doc, dict):
if isinstance(doc, list):
raise AkcliError(
"OP_UNSUPPORTED",
"op-list root must be a JSON object, not a top-level array — "
f'wrap your ops as {{"protocol_version": {PROTOCOL_VERSION}, '
'"target_format": "kicad", "ops": [...]}')
raise AkcliError("OP_UNSUPPORTED", "op-list root must be a JSON object")
return doc
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.