kicad-mcp/src/mckicad/tools/schematic.py
Ryan Malloy 57872e59c1 Replace kicad-sch-api with internal SchDocument engine
Eliminate the external kicad-sch-api dependency by migrating all MCP
tools to the internal SchDocument class built on sexp_tree.py. This
fixes three serialization bugs (dropped global labels, TypeError on
local labels, mis-quoted property private keywords) and removes ~1,900
lines of workaround code.

New modules:
- sexp_tree.py: S-expression parser and round-trip serializer
- sch_document.py: schematic read/write/mutate API
- lib_resolver.py: symbol library search and resolution
- sch_helpers.py: shared load/validate/expand helpers

Migrated all 9 tool files, resources, and tests. Removed 5 workaround
functions from sexp_parser.py. 531 tests pass, ruff + mypy clean.
2026-07-11 16:47:22 -06:00

978 lines
34 KiB
Python

"""
Schematic creation and manipulation tools for the mckicad MCP server.
Wraps the SchDocument sexp-tree engine for schematic editing through MCP tools.
Designed so that the underlying engine can be swapped to kipy IPC once KiCad
exposes a schematic API over its IPC transport.
"""
import contextlib
import logging
import os
import re
from collections import Counter
from typing import Any
from mckicad.config import INLINE_RESULT_THRESHOLD
from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.lib_resolver import get_symbol_info as _lr_get_info
from mckicad.utils.lib_resolver import search_symbols as _lr_search
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic
from mckicad.utils.sch_helpers import validate_schematic_path as _validate_schematic_path
from mckicad.utils.sexp_parser import (
compute_label_placement,
resolve_pin_position_and_orientation,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Engine abstraction — swap point for future kipy IPC schematic support
# ---------------------------------------------------------------------------
def _get_schematic_engine() -> str:
"""Return the name of the active schematic manipulation engine.
Currently: ``sch_document`` (sexp-tree file-level manipulation)
Future: ``kipy`` IPC when KiCad adds a schematic API over its IPC transport.
"""
return "sch_document"
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
_VALID_PAPER_SIZES = {"A4", "A3", "A2", "A1", "A0", "Letter", "Legal", "Tabloid"}
@mcp.tool()
def create_schematic(
name: str,
output_path: str,
paper_size: str = "A3",
) -> dict[str, Any]:
"""Create a new, empty KiCad schematic file.
Generates a valid .kicad_sch file at the specified location that can be
opened directly in KiCad or extended with add_component / add_wire calls.
Args:
name: Human-readable name for the schematic (e.g. "Power Supply").
output_path: Destination file path. Must end with .kicad_sch.
Parent directory will be created if it does not exist.
paper_size: Paper size for the schematic sheet. Default "A3".
Valid sizes: A4, A3, A2, A1, A0, Letter, Legal, Tabloid.
US schematics typically use A3 or Tabloid (ANSI B, 11x17").
Returns:
Dictionary with ``success``, ``path``, ``paper_size``, and ``engine`` keys.
"""
output_path = _expand(output_path)
if not output_path.endswith(".kicad_sch"):
return {
"success": False,
"error": f"output_path must end with .kicad_sch, got: {output_path}",
}
if paper_size not in _VALID_PAPER_SIZES:
return {
"success": False,
"error": (
f"Invalid paper_size '{paper_size}'. "
f"Valid sizes: {', '.join(sorted(_VALID_PAPER_SIZES))}"
),
}
try:
parent = os.path.dirname(output_path)
if parent:
os.makedirs(parent, exist_ok=True)
sch = SchDocument.create(output_path, paper=paper_size)
sch.set_title_block(title=name)
sch.save(output_path)
logger.info("Created schematic '%s' (%s) at %s", name, paper_size, output_path)
return {
"success": True,
"path": output_path,
"name": name,
"paper_size": paper_size,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to create schematic '%s': %s", name, e)
return {"success": False, "error": str(e)}
@mcp.tool()
def add_component(
schematic_path: str,
lib_id: str,
reference: str,
value: str,
x: float,
y: float,
) -> dict[str, Any]:
"""Place a symbol (component) on a KiCad schematic.
The symbol is identified by its KiCad library ID (e.g. ``Device:R``,
``power:GND``). Position is in KiCad schematic coordinate space where
the origin is top-left.
Args:
schematic_path: Path to an existing .kicad_sch file.
lib_id: KiCad library identifier such as ``Device:R`` or ``Connector:Conn_01x04``.
reference: Reference designator (e.g. ``R1``, ``C3``, ``U2``).
value: Component value string (e.g. ``10k``, ``100nF``, ``ATmega328P``).
x: Horizontal position in schematic units.
y: Vertical position in schematic units.
Returns:
Dictionary with ``success``, component ``reference``, and ``lib_id``.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
sch.components.add(
lib_id=lib_id,
reference=reference,
value=value,
position=(x, y),
)
sch.save(schematic_path)
logger.info("Added %s (%s) to %s at (%.1f, %.1f)", reference, lib_id, schematic_path, x, y)
return {
"success": True,
"reference": reference,
"lib_id": lib_id,
"value": value,
"position": {"x": x, "y": y},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add component %s to %s: %s", reference, schematic_path, e)
return {
"success": False,
"error": str(e),
"schematic_path": schematic_path,
}
@mcp.tool()
def search_components(query: str, library: str | None = None) -> dict[str, Any]:
"""Search KiCad symbol libraries for components matching a query.
Useful for discovering available symbols before placing them with
``add_component``. Results include library IDs that can be passed
directly to ``add_component``'s ``lib_id`` parameter.
Args:
query: Search term (e.g. ``resistor``, ``op amp``, ``STM32``).
library: Optional library name to restrict the search
(e.g. ``Device``, ``MCU_ST_STM32``). Searches all
libraries when omitted.
Returns:
Dictionary with ``success`` and a ``results`` list of matching symbols.
"""
try:
raw_results = _lr_search(query)
# Filter by library when requested
if library:
raw_results = [r for r in raw_results if _matches_library(r, library)]
results = []
for item in raw_results:
entry: dict[str, Any] = {}
if isinstance(item, dict):
entry = item
elif isinstance(item, str):
entry = {"lib_id": item}
else:
# Object with attributes
entry = {
"lib_id": getattr(item, "lib_id", str(item)),
"name": getattr(item, "name", None),
"description": getattr(item, "description", None),
"keywords": getattr(item, "keywords", None),
}
results.append(entry)
logger.info("Symbol search for '%s' returned %d results", query, len(results))
# Large result -> sidecar file with top results inline
if len(results) > INLINE_RESULT_THRESHOLD:
safe_query = re.sub(r"[^\w\-]", "_", query)[:30]
detail_path = write_detail_file(None, f"search_{safe_query}.json", results)
return {
"success": True,
"query": query,
"library": library,
"count": len(results),
"results": results[:10],
"truncated": True,
"detail_file": detail_path,
"hint": f"Showing first 10 of {len(results)} results. Full list in detail_file.",
"engine": _get_schematic_engine(),
}
return {
"success": True,
"query": query,
"library": library,
"count": len(results),
"results": results,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Symbol search failed for '%s': %s", query, e)
return {"success": False, "error": str(e), "query": query}
@mcp.tool()
def add_wire(
schematic_path: str,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
) -> dict[str, Any]:
"""Draw a wire segment between two points on a schematic.
Wires create electrical connections between component pins, labels,
and other wires. For connecting specific pins by reference designator,
see ``connect_pins`` which handles coordinate lookup automatically.
Args:
schematic_path: Path to an existing .kicad_sch file.
start_x: Starting X coordinate.
start_y: Starting Y coordinate.
end_x: Ending X coordinate.
end_y: Ending Y coordinate.
Returns:
Dictionary with ``success`` and the wire ``id``.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
wire = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y))
sch.save(schematic_path)
logger.info(
"Added wire from (%.1f, %.1f) to (%.1f, %.1f) in %s",
start_x,
start_y,
end_x,
end_y,
schematic_path,
)
return {
"success": True,
"wire_id": str(wire.uuid),
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add wire in %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def connect_pins(
schematic_path: str,
from_ref: str,
from_pin: str,
to_ref: str,
to_pin: str,
) -> dict[str, Any]:
"""Wire two component pins together by reference designator and pin name.
This is the high-level wiring tool -- it looks up pin positions from the
component references and draws a wire between them. Prefer this over
``add_wire`` when you know the component references and pin identifiers.
Args:
schematic_path: Path to an existing .kicad_sch file.
from_ref: Source component reference designator (e.g. ``R1``).
from_pin: Pin identifier on the source component (e.g. ``1``, ``A``).
to_ref: Destination component reference designator (e.g. ``R2``).
to_pin: Pin identifier on the destination component (e.g. ``2``, ``K``).
Returns:
Dictionary with ``success`` and the created wire ``id``.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
wire = sch.add_wire_between_pins(from_ref, from_pin, to_ref, to_pin)
sch.save(schematic_path)
logger.info(
"Connected %s pin %s -> %s pin %s in %s",
from_ref,
from_pin,
to_ref,
to_pin,
schematic_path,
)
return {
"success": True,
"wire_id": str(wire.uuid),
"from": {"reference": from_ref, "pin": from_pin},
"to": {"reference": to_ref, "pin": to_pin},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error(
"Failed to connect %s.%s -> %s.%s in %s: %s",
from_ref,
from_pin,
to_ref,
to_pin,
schematic_path,
e,
)
return {
"success": False,
"error": str(e),
"schematic_path": schematic_path,
}
@mcp.tool()
def add_label(
schematic_path: str,
text: str,
x: float | None = None,
y: float | None = None,
global_label: bool = False,
rotation: float = 0,
shape: str = "bidirectional",
pin_ref: str | None = None,
pin_number: str | None = None,
stub_length: float = 2.54,
) -> dict[str, Any]:
"""Add a net label or global label to a schematic.
Local labels connect nets within the same sheet. Global labels connect
nets across hierarchical sheets -- use global labels for power rails,
clock signals, and inter-sheet buses.
Supports two placement modes:
- **Coordinate**: provide ``x`` and ``y`` to place at an exact position.
- **Pin-referenced**: provide ``pin_ref`` and ``pin_number`` to
automatically resolve position and rotation from a component pin.
A wire stub is inserted connecting the pin to the label.
Args:
schematic_path: Path to an existing .kicad_sch file.
text: Label text (becomes the net name, e.g. ``GND``, ``SPI_CLK``).
x: Horizontal position in schematic units (coordinate mode).
y: Vertical position in schematic units (coordinate mode).
global_label: When True, creates a global label visible across all
hierarchical sheets. Defaults to a local label.
rotation: Label rotation in degrees (0, 90, 180, 270).
Ignored when using pin-referenced placement.
shape: Global label shape (``bidirectional``, ``input``, ``output``,
``tri_state``, ``passive``). Ignored for local labels.
pin_ref: Component reference for pin-referenced placement (e.g. ``U8``).
pin_number: Pin number for pin-referenced placement (e.g. ``15``).
stub_length: Wire stub length in schematic units (default 2.54).
Only used with pin-referenced placement.
Returns:
Dictionary with ``success``, the ``label_id``, and label type.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
if not text:
return {"success": False, "error": "Label text must be a non-empty string"}
has_coords = x is not None and y is not None
has_pin_ref = pin_ref is not None and pin_number is not None
if not has_coords and not has_pin_ref:
return {
"success": False,
"error": "Must provide either (x, y) coordinates or (pin_ref, pin_number)",
}
try:
sch = load_schematic(schematic_path)
if has_pin_ref:
# Pin-referenced placement
pin_info = resolve_pin_position_and_orientation(
sch, schematic_path, pin_ref, pin_number, # type: ignore[arg-type]
)
if pin_info is None:
return {
"success": False,
"error": f"Pin {pin_ref}.{pin_number} not found in schematic",
"schematic_path": schematic_path,
}
placement = compute_label_placement(
pin_info["x"], pin_info["y"],
pin_info["schematic_rotation"],
stub_length=stub_length,
)
lx, ly = placement["label_x"], placement["label_y"]
label_rotation = placement["label_rotation"]
if global_label:
gl = sch.add_global_label(
text, lx, ly, shape=shape, rotation=label_rotation,
)
label_id = gl.uuid
label_type = "global"
else:
lbl = sch.add_label(text, lx, ly, rotation=label_rotation)
label_id = lbl.uuid
label_type = "local"
# Add wire stub
sch.add_wire(
placement["stub_start_x"], placement["stub_start_y"],
placement["stub_end_x"], placement["stub_end_y"],
)
x, y = lx, ly
else:
# Coordinate placement
if global_label:
gl = sch.add_global_label(
text, x, y, shape=shape, rotation=rotation, # type: ignore[arg-type]
)
label_id = gl.uuid
label_type = "global"
else:
lbl = sch.add_label(
text, x, y, rotation=rotation, # type: ignore[arg-type]
)
label_id = lbl.uuid
label_type = "local"
sch.save(schematic_path)
logger.info(
"Added %s label '%s' at (%.1f, %.1f) in %s",
label_type, text, x, y, schematic_path,
)
return {
"success": True,
"label_id": label_id,
"text": text,
"label_type": label_type,
"position": {"x": x, "y": y},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add label '%s' in %s: %s", text, schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def add_hierarchical_sheet(
schematic_path: str,
name: str,
filename: str,
x: float,
y: float,
width: float,
height: float,
) -> dict[str, Any]:
"""Add a hierarchical sub-sheet to a schematic.
Hierarchical sheets let you break a design into logical blocks. The
sub-sheet is represented as a rectangle on the parent schematic and
references a separate .kicad_sch file for the child sheet's contents.
Args:
schematic_path: Path to the parent .kicad_sch file.
name: Display name shown on the sheet symbol (e.g. ``Power Supply``).
filename: Filename of the child schematic (e.g. ``power_supply.kicad_sch``).
Will be resolved relative to the parent schematic's directory.
x: Top-left X position of the sheet rectangle.
y: Top-left Y position of the sheet rectangle.
width: Width of the sheet rectangle in schematic units.
height: Height of the sheet rectangle in schematic units.
Returns:
Dictionary with ``success`` and sheet metadata.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
if not filename.endswith(".kicad_sch"):
return {
"success": False,
"error": f"Sheet filename must end with .kicad_sch, got: {filename}",
}
try:
sch = load_schematic(schematic_path)
sheet_result = sch.add_sheet(
name=name,
filename=filename,
x=x,
y=y,
width=width,
height=height,
)
parent_uuid = sch.uuid
sch.save(schematic_path)
logger.info(
"Added hierarchical sheet '%s' (%s) at (%.1f, %.1f) size %.1fx%.1f in %s",
name,
filename,
x,
y,
width,
height,
schematic_path,
)
return {
"success": True,
"sheet_name": name,
"sheet_filename": filename,
"sheet_uuid": sheet_result["uuid"],
"parent_uuid": parent_uuid,
"position": {"x": x, "y": y},
"size": {"width": width, "height": height},
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add sheet '%s' in %s: %s", name, schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def list_components(
schematic_path: str,
reference: str | None = None,
) -> dict[str, Any]:
"""List components in a KiCad schematic, or look up a single component.
When called without ``reference``, returns all components. For large
schematics (>{threshold} components), a compact summary is returned
inline and the full list is written to ``.mckicad/components.json``
-- read that file for complete data.
When ``reference`` is provided, returns only that component's details
(always inline, always compact).
Args:
schematic_path: Path to a .kicad_sch file.
reference: Optional reference designator to look up a single
component (e.g. ``U1``, ``R42``).
Returns:
Dictionary with ``success``, ``count``, and either inline
``components`` list or a ``detail_file`` path.
""".replace("{threshold}", str(INLINE_RESULT_THRESHOLD))
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
components: list[dict[str, Any]] = []
for comp in sch.components:
entry: dict[str, Any] = {
"reference": comp.reference,
"lib_id": comp.lib_id,
"value": comp.value,
"position": {"x": comp.position.x, "y": comp.position.y},
}
components.append(entry)
# Single-component lookup
if reference:
match = [c for c in components if c.get("reference") == reference]
if not match:
return {
"success": False,
"error": f"Component '{reference}' not found in schematic",
"schematic_path": schematic_path,
}
return {
"success": True,
"count": 1,
"components": match,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
logger.info("Listed %d components in %s", len(components), schematic_path)
# Large result -> sidecar file
if len(components) > INLINE_RESULT_THRESHOLD:
prefix_counts = Counter(
re.match(r"[A-Za-z]+", c.get("reference", "") or "").group() # type: ignore[union-attr]
for c in components
if c.get("reference") and re.match(r"[A-Za-z]+", c.get("reference", ""))
)
detail_path = write_detail_file(schematic_path, "components.json", components)
return {
"success": True,
"count": len(components),
"breakdown": dict(prefix_counts),
"detail_file": detail_path,
"hint": "Full component list written to detail_file. Read it for complete data.",
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
return {
"success": True,
"count": len(components),
"components": components,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to list components in %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def get_schematic_info(schematic_path: str) -> dict[str, Any]:
"""Get a compact overview of a KiCad schematic.
Returns statistics and a validation summary inline. For schematics with
many unique symbols, full symbol details and validation issues are
written to ``.mckicad/schematic_info.json`` -- read that file when you
need per-symbol data.
Args:
schematic_path: Path to a .kicad_sch file.
Returns:
Dictionary with ``success``, ``statistics``, ``validation`` summary,
``unique_symbol_count``, and optionally a ``detail_file`` path.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
# Gather statistics
stats = sch.get_statistics()
# Supplement text_elements for backwards compatibility
stats["text_elements"] = {
"label": stats.get("labels", 0),
"global_label": stats.get("global_labels", 0),
"total_text_elements": stats.get("labels", 0) + stats.get("global_labels", 0),
}
# Run validation
issues = sch.validate()
# Collect symbol details
lib_ids_seen: set[str] = set()
symbol_details: list[dict[str, Any]] = []
for comp in sch.components:
lid = comp.lib_id
if lid and lid not in lib_ids_seen:
lib_ids_seen.add(lid)
try:
info = _lr_get_info(lid)
if info is not None:
symbol_details.append(info)
else:
symbol_details.append({"lib_id": lid, "lookup_failed": True})
except Exception:
symbol_details.append({"lib_id": lid, "lookup_failed": True})
validation_passed = len(issues) == 0
logger.info("Retrieved info for %s: %d issues", schematic_path, len(issues))
result: dict[str, Any] = {
"success": True,
"schematic_path": schematic_path,
"statistics": stats,
"validation": {
"passed": validation_passed,
"issue_count": len(issues),
},
"unique_symbol_count": len(symbol_details),
"engine": _get_schematic_engine(),
}
# Write large detail data to sidecar file
if len(symbol_details) > INLINE_RESULT_THRESHOLD or len(issues) > INLINE_RESULT_THRESHOLD:
detail_data = {
"symbol_details": symbol_details,
"validation_issues": issues,
}
result["detail_file"] = write_detail_file(
schematic_path, "schematic_info.json", detail_data
)
result["hint"] = (
"Symbol details and validation issues written to detail_file. "
"Read it for per-symbol data."
)
else:
# Small enough to return inline
result["validation"]["issues"] = issues
result["symbol_details"] = symbol_details
return result
except Exception as e:
logger.error("Failed to get schematic info for %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
"""Get full details for a single component: properties, footprint, pins, position.
Always returns a compact inline result (single component). Use this
for deep inspection after ``list_components`` identifies a component
of interest.
Args:
schematic_path: Path to a .kicad_sch file.
reference: Reference designator (e.g. ``U1``, ``R42``).
Returns:
Dictionary with ``success`` and detailed component data including
properties, pin list, footprint, and validation results.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
comp = sch.components.get(reference)
if comp is None:
return {
"success": False,
"error": f"Component '{reference}' not found in schematic",
"schematic_path": schematic_path,
}
detail: dict[str, Any] = {
"reference": reference,
"lib_id": comp.lib_id,
"value": comp.value,
"footprint": comp.footprint,
"position": {"x": comp.position.x, "y": comp.position.y},
"rotation": comp.rotation,
}
# Properties
detail["properties"] = comp.properties
# Pins via SchDocument.list_component_pins()
pins: list[dict[str, Any]] = []
pin_data = sch.list_component_pins(reference)
for pin_num, pos in pin_data:
pins.append({
"number": pin_num,
"position": {"x": pos.x, "y": pos.y},
})
# Enrich pins with name/type from lib_resolver if available
lib_info = _lr_get_info(comp.lib_id)
if lib_info and "pins" in lib_info:
lib_pins_by_num = {
str(p["number"]): p for p in lib_info["pins"]
}
for pin in pins:
lib_pin = lib_pins_by_num.get(str(pin["number"]))
if lib_pin:
pin["name"] = lib_pin.get("name")
pin["type"] = lib_pin.get("type")
detail["pins"] = pins
detail["pin_count"] = len(pins)
# BOM/board flags
detail["in_bom"] = comp.in_bom
detail["on_board"] = comp.on_board
logger.info("Got detail for %s in %s", reference, schematic_path)
return {
"success": True,
"component": detail,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to get detail for %s in %s: %s", reference, schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
@mcp.tool()
def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
"""Get the hierarchical sheet tree of a KiCad schematic.
Returns the sheet structure with filenames and per-sheet component
counts. Essential for multi-sheet designs -- use this to understand
the design's organization before diving into specific sheets.
Args:
schematic_path: Path to the root .kicad_sch file.
Returns:
Dictionary with ``success`` and a ``hierarchy`` tree of sheets.
"""
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = load_schematic(schematic_path)
schematic_dir = os.path.dirname(schematic_path)
def _count_components(filepath: str) -> int:
"""Load a schematic and count its components. Returns -1 on failure."""
if not os.path.isfile(filepath):
return -1
try:
sheet_sch = load_schematic(filepath)
return len(sheet_sch.components)
except Exception:
return -1
def _walk_sheets(sheets: list, base_dir: str) -> list[dict[str, Any]]:
"""Recursively walk sheets to build the hierarchy tree."""
result_sheets: list[dict[str, Any]] = []
for sheet in sheets:
filename = sheet.filename
info: dict[str, Any] = {
"name": sheet.name,
"filename": filename,
}
if filename:
filepath = os.path.join(base_dir, filename)
count = _count_components(filepath)
if count >= 0:
info["component_count"] = count
else:
info["component_count"] = 0
info["note"] = "file not found or failed to load"
# Recurse into child sheets
if os.path.isfile(filepath):
with contextlib.suppress(Exception):
child_sch = load_schematic(filepath)
if child_sch.sheets:
child_dir = os.path.dirname(filepath)
info["sheets"] = _walk_sheets(child_sch.sheets, child_dir)
else:
info["component_count"] = 0
# Count total sheets
child_sheets = info.get("sheets", [])
if child_sheets:
info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in child_sheets
)
else:
info["total_sheets"] = 1
result_sheets.append(info)
return result_sheets
root_info: dict[str, Any] = {
"name": "root",
"filename": os.path.basename(schematic_path),
"component_count": len(sch.components),
}
if sch.sheets:
root_info["sheets"] = _walk_sheets(sch.sheets, schematic_dir)
root_info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in root_info["sheets"]
)
else:
root_info["total_sheets"] = 1
hierarchy = root_info
logger.info("Got hierarchy for %s: %d total sheets", schematic_path, hierarchy["total_sheets"])
return {
"success": True,
"hierarchy": hierarchy,
"schematic_path": schematic_path,
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to get hierarchy for %s: %s", schematic_path, e)
return {"success": False, "error": str(e), "schematic_path": schematic_path}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _matches_library(item: Any, library: str) -> bool:
"""Check whether a search result belongs to the given library."""
lib_id = None
if isinstance(item, dict):
lib_id = item.get("lib_id", "")
elif isinstance(item, str):
lib_id = item
else:
lib_id = getattr(item, "lib_id", str(item))
if not lib_id:
return False
# lib_id format is "Library:Symbol" -- match on the library portion
if ":" in lib_id:
return lib_id.split(":")[0].lower() == library.lower()
return library.lower() in str(lib_id).lower()