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.
"""Discover, parse and validate ``akcli.toml`` (SPEC §3.10).
Discovery walks up from the cwd; ``-C/--config`` overrides. Paths in ``[paths]``
resolve relative to the TOML file's directory. Unknown keys are rejected with
``BAD_CONFIG`` so typos never silently disable a rule.
``[project] grid`` sets the schematic pin grid the net checks measure against:
a bare number is mils, a string carries its unit (``"50mil"``, ``"1.27mm"``,
``"0.5mm"`` — metric grids are first-class). Stored as exact integer
nanometres (``grid_nm``); default 50 mil.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from . import units
from .errors import fail
CONFIG_FILENAME = "akcli.toml"
# The tool was once named altium-kicad-cli; keep reading a legacy config file so
# an existing checkout does not silently lose its settings after the rename.
_LEGACY_CONFIG_FILENAME = "altium-kicad-cli.toml"
# Default schematic grid: 50 mil, in exact integer nanometres.
DEFAULT_GRID_NM: int = 50 * units.NM_PER_MIL
# Allowed top-level tables/keys and their allowed sub-keys.
_TOP_KEYS: frozenset[str] = frozenset(
{"project", "rail", "paths", "erc_waiver", "waiver", "check", "bom",
"arrange"}
)
# [arrange] — project-pinned group-layout policy (mils), the per-invocation
# flags override: group_margin (inside a group), group_gap (between group
# blocks, both axes), row_width (bundle-shelf wrap), page_width (group-block
# wrap; setting it turns on 2D side-by-side packing).
_ARRANGE_KEYS: frozenset[str] = frozenset(
{"group_margin", "group_gap", "row_width", "page_width"}
)
_BOM_KEYS: frozenset[str] = frozenset(
{"coverage_floor", "coverage_min_parts", "min_stock", "classes",
"extended_fee"}
)
_BOM_CLASSES_KEYS: frozenset[str] = frozenset({"no_part"})
_CHECK_KEYS: frozenset[str] = frozenset(
{"pairs", "pair_suffixes", "bus_min_family", "group_clearance"}
)
_PROJECT_KEYS: frozenset[str] = frozenset({"mcu_designator", "grid", "backup_depth"})
_RAIL_KEYS: frozenset[str] = frozenset({"name", "voltage", "tolerance_pct"})
_WAIVER_KEYS: frozenset[str] = frozenset({"net", "rule", "reason"})
# Generic, checker-agnostic waiver (applied centrally, see report.apply_waivers).
_NEW_WAIVER_KEYS: frozenset[str] = frozenset({"code", "refs", "severity", "reason"})
_WAIVE_SEVERITIES: frozenset[str] = frozenset({"note", "info", "off"})
@dataclass
class Config:
"""Parsed project configuration.
``rails``, ``erc_waivers`` and ``waivers`` are lists of plain dicts; ``paths``
maps a name (``schematic``/``dts``/``pinout_md``/``parts_dir``/``pcb``/
``intent``/``bom_lock``/...) to an absolute path string — ``akcli gate``
is the reference consumer of ``schematic``/``pcb``/``intent``/``bom_lock``
(``intent`` is also ``check --intent``'s fallback). ``grid_nm`` is the
schematic pin grid in integer nanometres.
``waivers`` are the generic ``[[waiver]]`` entries applied centrally to every
checker's findings (:func:`report.apply_waivers`); ``erc_waivers`` is the
older ERC-only mechanism, kept as a back-compat alias.
"""
mcu_designator: str | None = None
rails: list[dict] = field(default_factory=list)
paths: dict[str, str] = field(default_factory=dict)
erc_waivers: list[dict] = field(default_factory=list)
waivers: list[dict] = field(default_factory=list)
source_path: str | None = None
grid_nm: int = DEFAULT_GRID_NM
backup_depth: int | None = None # [project] backup_depth; None = writer default
check: dict = field(default_factory=dict) # [check] table (pairs, pair_suffixes, ...)
bom: dict = field(default_factory=dict) # [bom] table (coverage_floor, classes, ...)
arrange: dict = field(default_factory=dict) # [arrange] table (group_gap, page_width, ...)
def _parse_grid(value: object) -> int:
"""``[project].grid`` -> integer nm. Number = mils; string carries a unit."""
if isinstance(value, bool):
fail("BAD_CONFIG", "[project].grid must be a number (mils) or 'Nmil'/'Nmm'")
if isinstance(value, (int, float)):
nm = units.mil_to_nm(float(value))
elif isinstance(value, str):
s = value.strip().lower()
try:
if s.endswith("mil"):
nm = units.mil_to_nm(float(s[:-3]))
elif s.endswith("mm"):
nm = units.mm_to_nm(float(s[:-2]))
else:
nm = units.mil_to_nm(float(s))
except ValueError:
fail("BAD_CONFIG", f"[project].grid: cannot parse {value!r}")
else:
fail("BAD_CONFIG", "[project].grid must be a number (mils) or 'Nmil'/'Nmm'")
if nm <= 0:
fail("BAD_CONFIG", f"[project].grid must be positive, got {value!r}")
return nm
def load_explicit_config(path: Path | str) -> Config:
"""Load a config the caller named EXPLICITLY (``-C/--config``).
The one rule, in one place (``cli.main`` applies it to every invocation;
``commands._shared._load_cfg`` re-uses it for library callers), because
``-C`` is a promise, not a hint: a path that does not exist, or is a
directory, or fails to parse is ``BAD_CONFIG`` — never a silent fallback
to defaults. Silently ignoring a typo'd ``-C`` left every rail/waiver/path
setting the run depended on absent while the command still exited 0, which
is why ``BAD_CONFIG`` existed but was unreachable.
Walk-up discovery (:func:`find_config`) keeps the opposite policy on
purpose: finding no ``akcli.toml`` is the ordinary no-config case.
"""
p = Path(path)
if not p.exists():
fail("BAD_CONFIG", f"-C/--config {p}: no such file")
if not p.is_file():
fail("BAD_CONFIG", f"-C/--config {p}: is a directory, not a file")
return load_config(p)
def find_config(start: Path | str | None = None) -> Path | None:
"""Walk up from ``start`` (default cwd) returning the first config file found."""
here = Path(start) if start is not None else Path.cwd()
here = here.resolve()
if here.is_file():
here = here.parent
for d in [here, *here.parents]:
for name in (CONFIG_FILENAME, _LEGACY_CONFIG_FILENAME):
candidate = d / name
if candidate.is_file():
return candidate
return None
def _reject_unknown(table: dict, allowed: frozenset[str], where: str) -> None:
extra = set(table) - allowed
if extra:
fail("BAD_CONFIG", f"unknown key(s) in {where}: {', '.join(sorted(extra))}")
def load_config(path: Path | str) -> Config:
"""Parse and validate a config file; unknown keys -> ``BAD_CONFIG``."""
p = Path(path)
try:
raw = p.read_bytes()
except FileNotFoundError:
raise
try:
data = tomllib.loads(raw.decode("utf-8"))
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
fail("BAD_CONFIG", f"invalid TOML in {p}: {exc}")
_reject_unknown(data, _TOP_KEYS, "top level")
project = data.get("project", {})
if not isinstance(project, dict):
fail("BAD_CONFIG", "[project] must be a table")
_reject_unknown(project, _PROJECT_KEYS, "[project]")
rails: list[dict] = []
for r in data.get("rail", []) or []:
if not isinstance(r, dict):
fail("BAD_CONFIG", "[[rail]] entries must be tables")
_reject_unknown(r, _RAIL_KEYS, "[[rail]]")
rails.append(dict(r))
waivers: list[dict] = []
for w in data.get("erc_waiver", []) or []:
if not isinstance(w, dict):
fail("BAD_CONFIG", "[[erc_waiver]] entries must be tables")
_reject_unknown(w, _WAIVER_KEYS, "[[erc_waiver]]")
waivers.append(dict(w))
gen_waivers: list[dict] = []
for w in data.get("waiver", []) or []:
if not isinstance(w, dict):
fail("BAD_CONFIG", "[[waiver]] entries must be tables")
_reject_unknown(w, _NEW_WAIVER_KEYS, "[[waiver]]")
code = w.get("code")
if not code or not isinstance(code, str):
fail("BAD_CONFIG", "[[waiver]].code is required (a finding code or fnmatch glob)")
refs = w.get("refs")
if refs is not None and not (
isinstance(refs, str)
or (isinstance(refs, list) and all(isinstance(r, str) for r in refs))
):
fail("BAD_CONFIG", "[[waiver]].refs must be a string or list of strings")
sev = w.get("severity", "off")
if not isinstance(sev, str) or sev.lower() not in _WAIVE_SEVERITIES:
fail("BAD_CONFIG", "[[waiver]].severity must be one of note/info/off")
gen_waivers.append({**w, "severity": sev.lower()})
raw_paths = data.get("paths", {})
if not isinstance(raw_paths, dict):
fail("BAD_CONFIG", "[paths] must be a table")
base = p.resolve().parent
paths: dict[str, str] = {}
for key, val in raw_paths.items():
if not isinstance(val, str):
fail("BAD_CONFIG", f"[paths].{key} must be a string")
paths[key] = str((base / val).resolve())
grid_nm = DEFAULT_GRID_NM
if "grid" in project:
grid_nm = _parse_grid(project["grid"])
backup_depth = None
if "backup_depth" in project:
raw_depth = project["backup_depth"]
if not isinstance(raw_depth, int) or isinstance(raw_depth, bool) \
or not (1 <= raw_depth <= 99):
fail("BAD_CONFIG",
"[project] backup_depth must be an integer 1..99")
backup_depth = raw_depth
check_tbl = data.get("check", {})
if not isinstance(check_tbl, dict):
fail("BAD_CONFIG", "[check] must be a table")
_reject_unknown(check_tbl, _CHECK_KEYS, "[check]")
if "pairs" in check_tbl and not isinstance(check_tbl["pairs"], bool):
fail("BAD_CONFIG", "[check].pairs must be a boolean")
if "pair_suffixes" in check_tbl:
ps = check_tbl["pair_suffixes"]
if (not isinstance(ps, list)
or any(not (isinstance(pair, list) and len(pair) == 2
and all(isinstance(s, str) and s for s in pair))
for pair in ps)):
fail("BAD_CONFIG",
"[check].pair_suffixes must be a list of [\"_P\", \"_N\"]-style"
" two-string pairs")
if "bus_min_family" in check_tbl:
bmf = check_tbl["bus_min_family"]
if not isinstance(bmf, int) or isinstance(bmf, bool) or bmf < 2:
fail("BAD_CONFIG", "[check].bus_min_family must be an integer >= 2")
if "group_clearance" in check_tbl:
gc = check_tbl["group_clearance"]
if isinstance(gc, bool) or not isinstance(gc, (int, float)) or gc < 0:
fail("BAD_CONFIG",
"[check].group_clearance must be a number >= 0 (mils; 0 = off)")
arrange_tbl = data.get("arrange", {})
if not isinstance(arrange_tbl, dict):
fail("BAD_CONFIG", "[arrange] must be a table")
_reject_unknown(arrange_tbl, _ARRANGE_KEYS, "[arrange]")
for key in _ARRANGE_KEYS:
if key in arrange_tbl:
v = arrange_tbl[key]
if isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0:
fail("BAD_CONFIG",
f"[arrange].{key} must be a positive number (mils)")
bom_tbl = data.get("bom", {})
if not isinstance(bom_tbl, dict):
fail("BAD_CONFIG", "[bom] must be a table")
_reject_unknown(bom_tbl, _BOM_KEYS, "[bom]")
if "coverage_floor" in bom_tbl:
cf = bom_tbl["coverage_floor"]
if isinstance(cf, bool) or not isinstance(cf, (int, float)) \
or not (0.0 <= float(cf) <= 1.0):
fail("BAD_CONFIG", "[bom].coverage_floor must be a number in 0..1")
for key in ("coverage_min_parts", "min_stock"):
if key in bom_tbl:
v = bom_tbl[key]
if isinstance(v, bool) or not isinstance(v, int) or v < 0:
fail("BAD_CONFIG", f"[bom].{key} must be an integer >= 0")
if "extended_fee" in bom_tbl:
v = bom_tbl["extended_fee"]
if isinstance(v, bool) or not isinstance(v, (int, float)) or v < 0:
fail("BAD_CONFIG", "[bom].extended_fee must be a number >= 0")
if "classes" in bom_tbl:
cls = bom_tbl["classes"]
if not isinstance(cls, dict):
fail("BAD_CONFIG", "[bom.classes] must be a table")
_reject_unknown(cls, _BOM_CLASSES_KEYS, "[bom.classes]")
np = cls.get("no_part")
if np is not None and (
not isinstance(np, list)
or not all(isinstance(x, str) and x for x in np)):
fail("BAD_CONFIG",
"[bom.classes].no_part must be a list of refdes prefixes")
return Config(
mcu_designator=project.get("mcu_designator"),
rails=rails,
paths=paths,
erc_waivers=waivers,
waivers=gen_waivers,
source_path=str(p.resolve()),
grid_nm=grid_nm,
backup_depth=backup_depth,
check=dict(check_tbl),
bom=dict(bom_tbl),
arrange=dict(arrange_tbl),
)