Two additive, optional modules for using KiCadStamp as a library instead of (or alongside) the
CLI/YAML workflow — neither changes the YAML config format or the apply/extract pipeline, both are
thin wrappers around what already exists. For the YAML syntax itself, see
docs/config.md; for the CLI commands, see docs/commands.md.
This page has two parts: an API reference (kicadstamp.explore/kicadstamp.author), then a worked
walkthrough mirroring a real script that ships in this repo
(boards/3ch-awg-tia/scripts/dac_channels.py).
Grew out of a recurring pattern: answering "which components have Role=X", "what net is this pad
on", "which sheet instance (Channel_0/Channel_1/...) is this footprint under" by writing a new
throwaway script every time. Board.select() replaces that with one reusable call.
from kicadstamp.explore import Board
board = Board.connect(config_path="boards/3ch-awg-tia/profiles/power.sexp",
schematic_dir="../../../test_boards/3CH-AWG-TIA")
board.select(role="AD_DAC").show()
# ref role cluster sheet nets
# ---- ------ ------- --------- ----
# IC2 AD_DAC - Channel_0 ...
# IC3 AD_DAC - Channel_1 ...
# IC4 AD_DAC - Channel_2 ...
# same ambiguity that causes a real fatal in apply if left unaddressed: role
# repeats twice per channel — select() shows it up front instead of failing
# mid-run.
board.select(role="R_TERM_P", sheet="Channel_0").show()
# ref role cluster sheet nets
# --- -------- ------- --------- ----
# R33 R_TERM_P - Channel_0 ...
# R39 R_TERM_P - Channel_0 ...
# escape hatch: .fp is the raw FootprintInstance, for anything not covered here
comp = board.select(ref="IC2")[0]
comp.nets # {'21': '/Channel_0/DAC/DAC_OUT_P', ...}
comp.fp.position # raw kipy object (nanometres)select() filters (all optional, AND-combined):
| Filter | Match |
|---|---|
ref |
exact refdes |
role |
exact Role field value |
cluster |
segment-prefix — same as the real anchor_cluster resolver (Channel_1 matches Channel_1/1V2_PLL, not Channel_10) |
sheet |
membership in the footprint's resolved sheet-instance chain |
net |
any pad on this net |
Board is a stable snapshot, taken at connect()/refresh() — it never re-fetches on its own.
Call board.refresh() after any board change (a manual edit in KiCad, or a scripted
apply_config()/cli_main(..., --apply) run) before trusting the next select().
select_items(...) returns the raw mixed list (footprints/vias/tracks) get_selected_items() would,
built from filters instead of a live GUI selection — pass straight to
template_extraction.extract_template_from_selection(items=...) for a scripted extract (net alone
can't disambiguate same-net components across different physical instances of a shared net like GND
— fine for the common case where the net name is already unique, otherwise keep a live GUI selection
for that one subsystem).
Per-channel clone_placements written by hand are exactly where copy-paste mistakes creep in (wrong
nets: key, duplicate anchor_pad:, a sheet name copied from the wrong neighbour) — a for loop
can't make those. ClonePlacement/Rule (kicadstamp.config) are plain dataclasses — build them
directly, with real Python variables instead of {placeholder} YAML substitution:
from kicadstamp.config import ClonePlacement
clones = [
ClonePlacement(
name=f"channel_{i}_ad9707", role="AD_DAC",
anchor_role="FPGA", anchor_sheet=ch,
nets={"AD_DAC": f"/Channel_{i}/DAC/DAC_OUT_P"},
xy=(0.0, 25.0 - 25.0 * i), rotation_deg=270.0 - 90.0 * i,
retired=False, skip=False,
)
for i, ch in enumerate(["Channel_0", "Channel_1", "Channel_2"])
]xy= is a plain 2-tuple (not origin_x_mm=/origin_y_mm= — those were renamed 2026-07-31, see the
clone_placements: section of docs/config.md). retired=/skip= default False on the
dataclass — spelling them out above is only for clarity, not required.
Option (a) — the standard entry point (cli_main):
Every script under boards/*/scripts/*.py uses this — one place for the --apply/--dry-run/
--verbose argparse boilerplate, instead of every script reinventing it. cli_main lives in
kicadstamp.author_cli (split out of kicadstamp.author 2026-08-11 so the author module stays a
pure library):
# boards/3ch-awg-tia/scripts/my_subsystem.py
from pathlib import Path
from kicadstamp.author_cli import cli_main
from kicadstamp.config import ClonePlacement
HERE = Path(__file__).resolve().parent
OUTPUT = HERE.parent / "generated" / "my_subsystem.sexp"
def build() -> list:
return [ClonePlacement(...), ...]
if __name__ == "__main__":
cli_main(build, str(OUTPUT), str(HERE.parent / "profiles/power.sexp"), description=__doc__)python boards/3ch-awg-tia/scripts/my_subsystem.py # writes OUTPUT only, never touches the board
python boards/3ch-awg-tia/scripts/my_subsystem.py --apply --dry-run --verbose # plan, no write
python boards/3ch-awg-tia/scripts/my_subsystem.py --apply # writes OUTPUT, then applies itroot_config_path (the third argument, .../profiles/power.sexp above) is what actually gets loaded
and applied with --apply — it's the one carrying schematic_dir/registry_path, and
(via include:) it's expected to pick up OUTPUT itself, so the registry sees the FULL board config,
not just this one script's slice (a partial Config built from one script alone is unsafe for
registry pruning — see the "Getting it wrong" section below).
Option (b) — lower-level pieces, if cli_main doesn't fit (e.g. no --apply gating wanted at
all):
from kicadstamp.author import dump_clone_placements, dump_rules, dump_template, apply_config
dump_clone_placements(clones, "boards/3ch-awg-tia/generated/dac_channels.sexp") # {'clone_placements': [...]}
dump_rules(rules, "boards/3ch-awg-tia/generated/fpga_spokes.sexp") # {'rules': [...]}
dump_template({"my_cell": {"vias": [...], "components": [...]}}, "templates/my_cell.sexp")
# straight into the live apply pipeline, bypassing the generated-YAML step entirely:
from kicadstamp.config import load_config
cfg, ctx = load_config("boards/3ch-awg-tia/profiles/power.sexp")
cfg.clone_placements.extend(clones)
apply_config(cfg, "boards/3ch-awg-tia/profiles/power.sexp", ctx=ctx, dry_run=True)apply_config's config_path argument is not cosmetic — same reasoning as root_config_path
above: when cfg.registry_path/cfg.track_registry_path are unset, they're derived from it. A
throwaway placeholder path here would misfile or collide registries between unrelated scripted runs.
It's tempting to skip writing OUTPUT and go straight from build() to apply_config() on a Config
assembled from just this one script's clones. Don't — registry.reconcile()'s pruning
(known_anchor_ids) needs the full cfg.clone_placements (every subsystem, via include:) to
know what's still supposed to exist; a Config built from one script's slice alone would make pruning
think every other subsystem's vias/tracks are stale and delete them. Always: write the generated
YAML → load the real root config (which include:s it) → apply. cli_main already does exactly this.
This mirrors a real script in the repo — read it alongside this walkthrough (AD_DAC_LAYOUT/
PASSIVE_LAYOUT/OP_AMPS are per-channel lookup tables, not formulas, since each channel's DAC sits
on a different side of the FPGA).
Before writing any placement code, use explore to see what you're actually dealing with — don't
guess at Role/Cluster/net names or assume a Role is unique:
from kicadstamp.explore import Board
board = Board.connect(config_path="boards/3ch-awg-tia/profiles/power.sexp",
schematic_dir="../../../test_boards/3CH-AWG-TIA")
board.select(role="AD_DAC").show()xy: is a flat shift from the anchor — never auto-rotated by the engine (see
docs/config.md's note on xy:'s three meanings). Copying Channel_0's offset numbers onto
a differently-rotated Channel_1/2 would silently misplace the passive. dac_channels.py handles this
by rotating the verified Channel_0 baseline with the same primitive the engine itself uses
(kicadstamp.domain.geometry.Vector2.rotate(), matching geometry/spoke_layout.py's rotate_local_offset) —
computed once, then visually verified live in KiCad, not hand-guessed per channel:
AD_DAC_LAYOUT = {
0: (0.0, 25.0, 270.0),
1: (25.0, 0.0, 0.0),
2: (0.0, -25.0, 90.0),
}
def build() -> list:
clones = []
for channel, (x, y, rot) in AD_DAC_LAYOUT.items():
clones.append(ClonePlacement(
name=f"channel_{channel}_ad9707", role="AD_DAC",
anchor_role="FPGA", anchor_sheet=f"Channel_{channel}",
nets={"AD_DAC": f"/Channel_{channel}/DAC/DAC_OUT_P"},
xy=(x, y), rotation_deg=rot,
))
# ... PASSIVE_LAYOUT/OP_AMPS follow the same per-channel-table shape
return clonesA for loop physically cannot make the mistakes that come from copy-pasting three similar YAML blocks
by hand: a wrong nets: key, a duplicated anchor_pad: line, a sheet name copied from the wrong
neighbour — all real bugs hit while writing this exact subsystem by hand before it was scripted.
python boards/3ch-awg-tia/scripts/dac_channels.py --apply --dry-run --verboseThen re-run board.refresh() + board.select(...) to confirm the result with the same tool used to
investigate ambiguity in Step 1 — closes the "did it actually do what I meant" question without
opening KiCad.
python boards/3ch-awg-tia/scripts/dac_channels.py --applyOUTPUT (boards/3ch-awg-tia/generated/dac_channels.sexp) is committed — plain, diffable s-expr, even
though a Python script authored it. boards/3ch-awg-tia/profiles/dac_channels.sexp picks it up via
include:, the normal way. The script stays in the repo too, so re-running it after a real board
change (or extending it to a 4th channel) regenerates the same file instead of hand-editing it.
Not every script needs cli_main/--apply at all — boards/3ch-awg-tia/scripts/build_p3v3_ldo_cell.py
only ever reads the live board (kicadstamp.explore.Board, never mutates anything) to measure real pad
positions, then writes a Cell definition via dump_template():
from kicadstamp.author import dump_template
from kicadstamp.explore import Board
from kicadstamp.utils.units import MM
board = Board.connect(config_path="boards/3ch-awg-tia/profiles/power.sexp")
ldo_fp = board.select(role="LDO_3V3")[0].fp
origin_x_mm, origin_y_mm = ldo_fp.position.x / MM, ldo_fp.position.y / MM
# ... measure other live pad positions, subtract origin ...
dump_template({"p3v3_ldo_composite": {"clone_placements": [...]}},
"boards/3ch-awg-tia/profiles/templates/p3v3_ldo_composite.sexp")This is the general pattern for anything geometry-dependent that isn't safely derivable from YAML
numbers alone (component-centre-to-pad offsets, footprint dimensions) — measure it against the real
board with explore, don't hand-guess it. See the script's own docstring for the full rationale (it
exists specifically because CellPlacement, the nested-cell type, has no live anchor fields at all —
only a literal xy: relative to the parent cell — so turning an anchor-resolved position into that
literal number needs a real measurement).
- docs/config.md — the YAML schema these Python objects mirror field-for-field.
- docs/commands.md — the CLI (
apply/extract) these scripts wrap or replace. - docs/placement.md — what
apply_config()actually does once it's called (dependency ordering, the registry, collision handling) — same pipeline either way.