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.
This commit is contained in:
Ryan Malloy 2026-07-11 16:47:22 -06:00
parent 6c6c85b7dc
commit 57872e59c1
27 changed files with 5306 additions and 1903 deletions

View File

@ -25,7 +25,7 @@ dependencies = [
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"kicad-python>=0.5.0", "kicad-python>=0.5.0",
"kicad-sch-api>=0.5.6",
"requests>=2.32.5", "requests>=2.32.5",
] ]
@ -80,7 +80,7 @@ warn_unused_ignores = true
show_error_codes = true show_error_codes = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["kipy.*", "kicad_sch_api.*", "requests.*"] module = ["kipy.*", "requests.*"]
ignore_missing_imports = true ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]

View File

@ -11,22 +11,11 @@ import os
from typing import Any from typing import Any
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
pass
def _expand(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
@mcp.resource("kicad://schematic/{path}/components") @mcp.resource("kicad://schematic/{path}/components")
def schematic_components_resource(path: str) -> str: def schematic_components_resource(path: str) -> str:
@ -35,25 +24,22 @@ def schematic_components_resource(path: str) -> str:
Returns a JSON array of all components with reference, lib_id, value, Returns a JSON array of all components with reference, lib_id, value,
and position. and position.
""" """
if not _HAS_SCH_API:
return json.dumps({"error": "kicad-sch-api not installed"})
expanded = _expand(path) expanded = _expand(path)
if not os.path.isfile(expanded): if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"}) return json.dumps({"error": f"File not found: {expanded}"})
try: try:
sch = _ksa_load(expanded) sch = load_schematic(expanded)
components: list[dict[str, Any]] = [] components: list[dict[str, Any]] = []
for comp in sch.components: for comp in sch.components:
entry: dict[str, Any] = { entry: dict[str, Any] = {
"reference": getattr(comp, "reference", None), "reference": comp.reference,
"lib_id": getattr(comp, "lib_id", None), "lib_id": comp.lib_id,
"value": getattr(comp, "value", None), "value": comp.value,
} }
pos = getattr(comp, "position", None) pos = comp.position
if pos is not None and isinstance(pos, (list, tuple)) and len(pos) >= 2: if pos is not None:
entry["position"] = {"x": pos[0], "y": pos[1]} entry["position"] = {"x": pos.x, "y": pos.y}
components.append(entry) components.append(entry)
return json.dumps(components, indent=2) return json.dumps(components, indent=2)
except Exception as e: except Exception as e:
@ -66,36 +52,19 @@ def schematic_nets_resource(path: str) -> str:
"""Browsable net list for a KiCad schematic. """Browsable net list for a KiCad schematic.
Returns a JSON object mapping net names to lists of connected pins. Returns a JSON object mapping net names to lists of connected pins.
Uses the connectivity builder for accurate net resolution.
""" """
if not _HAS_SCH_API:
return json.dumps({"error": "kicad-sch-api not installed"})
expanded = _expand(path) expanded = _expand(path)
if not os.path.isfile(expanded): if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"}) return json.dumps({"error": f"File not found: {expanded}"})
try: try:
sch = _ksa_load(expanded) sch = load_schematic(expanded)
nets_attr = getattr(sch, "nets", None)
if nets_attr is None: from mckicad.tools.schematic_analysis import _build_connectivity
return json.dumps({"error": "Schematic has no nets attribute"})
net_data: dict[str, list[dict[str, str]]] = {} net_graph, _unconnected = _build_connectivity(sch, expanded)
if isinstance(nets_attr, dict): return json.dumps(net_graph, indent=2)
for net_name, pins in nets_attr.items():
pin_list = []
for p in (pins if isinstance(pins, (list, tuple)) else []):
if isinstance(p, dict):
pin_list.append(p)
else:
pin_list.append({
"reference": str(getattr(p, "reference", "")),
"pin": str(getattr(p, "pin", getattr(p, "number", ""))),
})
net_data[str(net_name)] = pin_list
return json.dumps(net_data, indent=2)
except Exception as e: except Exception as e:
logger.error("Failed to load schematic nets for resource: %s", e) logger.error("Failed to load schematic nets for resource: %s", e)
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
@ -108,36 +77,32 @@ def schematic_hierarchy_resource(path: str) -> str:
Returns the hierarchical sheet tree with filenames and per-sheet Returns the hierarchical sheet tree with filenames and per-sheet
component counts. Useful for understanding multi-sheet designs. component counts. Useful for understanding multi-sheet designs.
""" """
if not _HAS_SCH_API:
return json.dumps({"error": "kicad-sch-api not installed"})
expanded = _expand(path) expanded = _expand(path)
if not os.path.isfile(expanded): if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"}) return json.dumps({"error": f"File not found: {expanded}"})
try: try:
sch = _ksa_load(expanded) sch = load_schematic(expanded)
def _build(sheet_sch: Any, name: str, filename: str) -> dict[str, Any]: def _build(sheet_sch: Any, name: str, filename: str) -> dict[str, Any]:
info: dict[str, Any] = { info: dict[str, Any] = {
"name": name, "name": name,
"filename": filename, "filename": filename,
"component_count": len(list(sheet_sch.components)) if hasattr(sheet_sch, "components") else 0, "component_count": len(list(sheet_sch.components)),
} }
children: list[dict[str, Any]] = [] children: list[dict[str, Any]] = []
if hasattr(sheet_sch, "sheets"): for child in sheet_sch.sheets:
for child in sheet_sch.sheets: child_name = child.name
child_name = getattr(child, "name", "unknown") child_file = child.filename
child_file = getattr(child, "filename", "unknown") child_path = os.path.join(os.path.dirname(expanded), child_file)
child_path = os.path.join(os.path.dirname(expanded), child_file) if os.path.isfile(child_path):
if os.path.isfile(child_path): try:
try: child_sch = load_schematic(child_path)
child_sch = _ksa_load(child_path) children.append(_build(child_sch, child_name, child_file))
children.append(_build(child_sch, child_name, child_file)) except Exception:
except Exception: children.append({"name": child_name, "filename": child_file, "error": "load failed"})
children.append({"name": child_name, "filename": child_file, "error": "load failed"}) else:
else: children.append({"name": child_name, "filename": child_file, "note": "file not found"})
children.append({"name": child_name, "filename": child_file, "note": "file not found"})
if children: if children:
info["sheets"] = children info["sheets"] = children
return info return info

View File

@ -23,41 +23,11 @@ from mckicad.autowire.strategy import (
from mckicad.config import TIMEOUT_CONSTANTS from mckicad.config import TIMEOUT_CONSTANTS
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.kicad_cli import find_kicad_cli from mckicad.utils.kicad_cli import find_kicad_cli
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import validate_schematic_path as _validate_schematic_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
pass
def _require_sch_api() -> dict[str, Any] | None:
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
}
return None
def _expand(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
def _validate_schematic_path(path: str) -> dict[str, Any] | None:
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def _auto_export_netlist(schematic_path: str) -> tuple[str | None, str | None]: def _auto_export_netlist(schematic_path: str) -> tuple[str | None, str | None]:
"""Export a netlist via kicad-cli. Returns (path, error).""" """Export a netlist via kicad-cli. Returns (path, error)."""
@ -163,10 +133,6 @@ def autowire_schematic(
Dictionary with strategy summary (counts by method), the full Dictionary with strategy summary (counts by method), the full
plan, batch JSON, and apply results (when dry_run=False). plan, batch JSON, and apply results (when dry_run=False).
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
@ -178,7 +144,8 @@ def autowire_schematic(
try: try:
# 1. Load schematic and build connectivity state # 1. Load schematic and build connectivity state
sch = _ksa_load(schematic_path) from mckicad.utils.sch_helpers import load_schematic as _load_sch
sch = _load_sch(schematic_path)
from mckicad.tools.schematic_analysis import _build_connectivity_state from mckicad.tools.schematic_analysis import _build_connectivity_state
@ -285,32 +252,15 @@ def autowire_schematic(
# 12. Apply if not dry_run # 12. Apply if not dry_run
if not dry_run and batch_data: if not dry_run and batch_data:
from mckicad.tools.batch import ( from mckicad.tools.batch import _apply_batch_operations
_apply_batch_operations,
_register_project_libraries,
)
from mckicad.utils.sexp_parser import (
fix_property_private_keywords,
insert_sexp_before_close,
)
# Re-load schematic fresh for application # Re-load schematic fresh for application
sch = _ksa_load(schematic_path) sch = _load_sch(schematic_path)
_register_project_libraries(batch_data, schematic_path)
summary = _apply_batch_operations(sch, batch_data, schematic_path) summary = _apply_batch_operations(sch, batch_data, schematic_path)
sch.save(schematic_path) sch.save(schematic_path)
private_fixes = fix_property_private_keywords(schematic_path)
if private_fixes:
summary["property_private_fixes"] = private_fixes
pending_sexps = summary.pop("_pending_label_sexps", [])
if pending_sexps:
combined_sexp = "".join(pending_sexps)
insert_sexp_before_close(schematic_path, combined_sexp)
result["applied"] = { result["applied"] = {
"components_placed": summary.get("components_placed", 0), "components_placed": summary.get("components_placed", 0),
"power_symbols_placed": summary.get("power_symbols_placed", 0), "power_symbols_placed": summary.get("power_symbols_placed", 0),

View File

@ -17,58 +17,26 @@ from typing import Any
from mckicad.config import BATCH_LIMITS, LABEL_DEFAULTS from mckicad.config import BATCH_LIMITS, LABEL_DEFAULTS
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.sch_helpers import (
expand as _expand,
load_schematic,
validate_schematic_path as _validate_schematic_path,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Map user-facing direction names to the pin schematic rotation that # Map user-facing direction names to the pin schematic rotation that
# produces that label placement direction (label goes opposite to body). # produces that label placement direction (label goes opposite to body).
_DIRECTION_TO_ROTATION: dict[str, float] = { _DIRECTION_TO_ROTATION: dict[str, float] = {
"left": 0, # body right label left "left": 0, # body right -> label left
"right": 180, # body left label right "right": 180, # body left -> label right
"up": 90, # body down label up "up": 90, # body down -> label up
"down": 270, # body up label down "down": 270, # body up -> label down
} }
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed — batch tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _require_sch_api() -> dict[str, Any] | None:
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
"engine": "none",
}
return None
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
return "kicad-sch-api" if _HAS_SCH_API else "none" return "sch_document"
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if must_exist and not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def _expand(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
def _resolve_batch_file(batch_file: str, schematic_path: str) -> str: def _resolve_batch_file(batch_file: str, schematic_path: str) -> str:
@ -277,75 +245,26 @@ def _validate_batch_data(data: dict[str, Any], sch: Any) -> list[str]:
return errors return errors
def _register_project_libraries(
data: dict[str, Any], schematic_path: str,
) -> list[str]:
"""Register project-local symbol libraries with kicad-sch-api's cache.
Scans the batch data for library names not already known to the global
``SymbolLibraryCache``, locates the ``.kicad_sym`` files via mckicad's
own library search (which reads ``sym-lib-table``), and registers them
so that ``components.add()`` can resolve the lib_id.
Returns list of newly registered library names.
"""
try:
from kicad_sch_api.library.cache import get_symbol_cache
except ImportError:
return []
from mckicad.utils.sexp_parser import _find_library_file
cache = get_symbol_cache()
# Collect unique library names from batch components
lib_names: set[str] = set()
for comp in data.get("components", []):
lib_id = comp.get("lib_id", "")
if ":" in lib_id:
lib_names.add(lib_id.split(":")[0])
registered: list[str] = []
for lib_name in lib_names:
# Skip if already indexed
if lib_name in cache._library_index:
continue
lib_path = _find_library_file(schematic_path, lib_name)
if lib_path and cache.add_library_path(lib_path):
logger.info("Registered project-local library: %s -> %s", lib_name, lib_path)
registered.append(lib_name)
elif lib_path is None:
logger.debug("Library '%s' not found in project paths", lib_name)
return registered
def _apply_batch_operations( def _apply_batch_operations(
sch: Any, data: dict[str, Any], schematic_path: str, sch: Any, data: dict[str, Any], schematic_path: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Apply all batch operations to a loaded schematic. Returns summary. """Apply all batch operations to a loaded schematic. Returns summary.
Labels are returned as pending sexp strings (``_pending_label_sexps``) Labels are inserted directly into the sexp tree via
that must be inserted into the file *after* ``sch.save()``, because ``sch.add_label()`` / ``sch.add_global_label()`` and wire stubs via
kicad-sch-api's serializer drops global labels and raises TypeError ``sch.add_wire()``, so no post-save fixup is needed.
on local labels.
Args: Args:
sch: A kicad-sch-api SchematicDocument instance. sch: A SchDocument instance.
data: Parsed batch JSON data. data: Parsed batch JSON data.
schematic_path: Path to the .kicad_sch file (needed for sexp schematic_path: Path to the .kicad_sch file (needed for pin
pin fallback and label insertion). position resolution).
""" """
from mckicad.patterns._geometry import add_power_symbol_to_pin from mckicad.patterns._geometry import add_power_symbol_to_pin
from mckicad.utils.sexp_parser import ( from mckicad.utils.sexp_parser import (
WireSegment, WireSegment,
clamp_stub_length, clamp_stub_length,
compute_label_placement, compute_label_placement,
generate_global_label_sexp,
generate_label_sexp,
generate_wire_sexp,
resolve_label_collision, resolve_label_collision,
resolve_pin_position, resolve_pin_position,
resolve_pin_position_and_orientation, resolve_pin_position_and_orientation,
@ -357,7 +276,6 @@ def _apply_batch_operations(
placed_wires: list[str] = [] placed_wires: list[str] = []
placed_labels: list[str] = [] placed_labels: list[str] = []
placed_no_connects = 0 placed_no_connects = 0
pending_label_sexps: list[str] = []
occupied_positions: dict[tuple[float, float], str] = {} occupied_positions: dict[tuple[float, float], str] = {}
placed_wire_segments: list[WireSegment] = [] placed_wire_segments: list[WireSegment] = []
collisions_resolved = 0 collisions_resolved = 0
@ -371,7 +289,7 @@ def _apply_batch_operations(
except Exception: except Exception:
logger.warning("Could not set paper_size to '%s'", batch_paper) logger.warning("Could not set paper_size to '%s'", batch_paper)
# 1. Components (multi-unit supported natively by kicad-sch-api) # 1. Components
for comp in data.get("components", []): for comp in data.get("components", []):
ref = comp.get("reference") ref = comp.get("reference")
unit = comp.get("unit", 1) unit = comp.get("unit", 1)
@ -426,10 +344,10 @@ def _apply_batch_operations(
for wire in data.get("wires", []): for wire in data.get("wires", []):
if "from_ref" in wire: if "from_ref" in wire:
wire_id = sch.add_wire_between_pins( wire_id = sch.add_wire_between_pins(
component1_ref=wire["from_ref"], from_ref=wire["from_ref"],
pin1_number=wire["from_pin"], from_pin=wire["from_pin"],
component2_ref=wire["to_ref"], to_ref=wire["to_ref"],
pin2_number=wire["to_pin"], to_pin=wire["to_pin"],
) )
else: else:
wire_id = sch.add_wire( wire_id = sch.add_wire(
@ -481,7 +399,7 @@ def _apply_batch_operations(
if _info: if _info:
_register_obstacle(_nc["pin_ref"], _info) _register_obstacle(_nc["pin_ref"], _info)
# 4. Labels — generate sexp strings for post-save insertion # 4. Labels -- added directly to the sexp tree via SchDocument
for label in data.get("labels", []): for label in data.get("labels", []):
is_global = label.get("global", False) is_global = label.get("global", False)
rotation = label.get("rotation", 0) rotation = label.get("rotation", 0)
@ -535,7 +453,7 @@ def _apply_batch_operations(
lx, ly = placement["label_x"], placement["label_y"] lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"] rotation = placement["label_rotation"]
# Resolve collisions before generating sexp # Resolve collisions before placing
new_x, new_y = resolve_label_collision( new_x, new_y = resolve_label_collision(
lx, ly, rotation, label["text"], occupied_positions, lx, ly, rotation, label["text"], occupied_positions,
) )
@ -544,17 +462,16 @@ def _apply_batch_operations(
lx, ly = new_x, new_y lx, ly = new_x, new_y
if is_global: if is_global:
sexp = generate_global_label_sexp( sch.add_global_label(
text=label["text"], x=lx, y=ly, rotation=rotation, text=label["text"], x=lx, y=ly, rotation=rotation,
shape=label.get("shape", "bidirectional"), shape=label.get("shape", "bidirectional"),
) )
else: else:
sexp = generate_label_sexp( sch.add_label(
text=label["text"], x=lx, y=ly, rotation=rotation, text=label["text"], x=lx, y=ly, rotation=rotation,
) )
pending_label_sexps.append(sexp)
# Wire stub from pin to (possibly shifted) label with # Wire stub from pin to (possibly shifted) label -- with
# wire-level collision detection for collinear overlaps # wire-level collision detection for collinear overlaps
w_sx, w_sy, w_lx, w_ly = resolve_wire_collision( w_sx, w_sy, w_lx, w_ly = resolve_wire_collision(
placement["stub_start_x"], placement["stub_start_y"], placement["stub_start_x"], placement["stub_start_y"],
@ -566,12 +483,11 @@ def _apply_batch_operations(
): ):
wire_collisions_resolved += 1 wire_collisions_resolved += 1
lx, ly = w_lx, w_ly lx, ly = w_lx, w_ly
wire_sexp = generate_wire_sexp(w_sx, w_sy, w_lx, w_ly) sch.add_wire(w_sx, w_sy, w_lx, w_ly)
pending_label_sexps.append(wire_sexp)
else: else:
# Coordinate-based label (original path) # Coordinate-based label (original path)
if is_global: if is_global:
sexp = generate_global_label_sexp( sch.add_global_label(
text=label["text"], text=label["text"],
x=label["x"], x=label["x"],
y=label["y"], y=label["y"],
@ -579,17 +495,16 @@ def _apply_batch_operations(
shape=label.get("shape", "bidirectional"), shape=label.get("shape", "bidirectional"),
) )
else: else:
sexp = generate_label_sexp( sch.add_label(
text=label["text"], text=label["text"],
x=label["x"], x=label["x"],
y=label["y"], y=label["y"],
rotation=rotation, rotation=rotation,
) )
pending_label_sexps.append(sexp)
placed_labels.append(label["text"]) placed_labels.append(label["text"])
# 4b. Label connections pin-ref labels sharing a net name # 4b. Label connections -- pin-ref labels sharing a net name
for lc in data.get("label_connections", []): for lc in data.get("label_connections", []):
net = lc["net"] net = lc["net"]
is_global = lc.get("global", False) is_global = lc.get("global", False)
@ -643,7 +558,7 @@ def _apply_batch_operations(
lx, ly = placement["label_x"], placement["label_y"] lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"] rotation = placement["label_rotation"]
# Resolve collisions before generating sexp # Resolve collisions before placing
new_x, new_y = resolve_label_collision( new_x, new_y = resolve_label_collision(
lx, ly, rotation, net, occupied_positions, lx, ly, rotation, net, occupied_positions,
) )
@ -652,16 +567,15 @@ def _apply_batch_operations(
lx, ly = new_x, new_y lx, ly = new_x, new_y
if is_global: if is_global:
sexp = generate_global_label_sexp( sch.add_global_label(
text=net, x=lx, y=ly, rotation=rotation, shape=shape, text=net, x=lx, y=ly, rotation=rotation, shape=shape,
) )
else: else:
sexp = generate_label_sexp( sch.add_label(
text=net, x=lx, y=ly, rotation=rotation, text=net, x=lx, y=ly, rotation=rotation,
) )
pending_label_sexps.append(sexp)
# Wire stub from pin to (possibly shifted) label with # Wire stub from pin to (possibly shifted) label -- with
# wire-level collision detection for collinear overlaps # wire-level collision detection for collinear overlaps
w_sx, w_sy, w_lx, w_ly = resolve_wire_collision( w_sx, w_sy, w_lx, w_ly = resolve_wire_collision(
placement["stub_start_x"], placement["stub_start_y"], placement["stub_start_x"], placement["stub_start_y"],
@ -673,8 +587,7 @@ def _apply_batch_operations(
): ):
wire_collisions_resolved += 1 wire_collisions_resolved += 1
lx, ly = w_lx, w_ly lx, ly = w_lx, w_ly
wire_sexp = generate_wire_sexp(w_sx, w_sy, w_lx, w_ly) sch.add_wire(w_sx, w_sy, w_lx, w_ly)
pending_label_sexps.append(wire_sexp)
placed_labels.append(net) placed_labels.append(net)
# 5. No-connects (coordinate or pin-referenced) # 5. No-connects (coordinate or pin-referenced)
@ -689,9 +602,9 @@ def _apply_batch_operations(
nc["pin_ref"], nc["pin_number"], nc["pin_ref"], nc["pin_number"],
) )
continue continue
sch.no_connects.add(position=pin_pos) sch.add_no_connect(position=pin_pos)
else: else:
sch.no_connects.add(position=(nc["x"], nc["y"])) sch.add_no_connect(x=nc["x"], y=nc["y"])
placed_no_connects += 1 placed_no_connects += 1
return { return {
@ -713,7 +626,6 @@ def _apply_batch_operations(
+ len(placed_labels) + len(placed_labels)
+ placed_no_connects + placed_no_connects
), ),
"_pending_label_sexps": pending_label_sexps,
} }
@ -807,10 +719,6 @@ def apply_batch(
Dictionary with ``success``, counts of each operation type applied, Dictionary with ``success``, counts of each operation type applied,
and wire/label IDs for the created elements. and wire/label IDs for the created elements.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
@ -846,11 +754,7 @@ def apply_batch(
} }
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
# Register project-local symbol libraries with the cache so
# components.add() can resolve non-standard lib_ids.
_register_project_libraries(data, schematic_path)
# Set hierarchy context for sub-sheets so kicad-cli resolves # Set hierarchy context for sub-sheets so kicad-cli resolves
# power symbol nets correctly during netlist export. # power symbol nets correctly during netlist export.
@ -898,27 +802,10 @@ def apply_batch(
# Apply all operations # Apply all operations
summary = _apply_batch_operations(sch, data, schematic_path) summary = _apply_batch_operations(sch, data, schematic_path)
# Single save (components, power symbols, wires, no-connects) # Single save -- labels and wires are already in the sexp tree,
# no post-save fixup needed.
sch.save(schematic_path) sch.save(schematic_path)
# Fix kicad-sch-api's mis-serialization of (property private ...)
# keywords before any further file modifications.
from mckicad.utils.sexp_parser import (
fix_property_private_keywords,
insert_sexp_before_close,
)
private_fixes = fix_property_private_keywords(schematic_path)
if private_fixes:
summary["property_private_fixes"] = private_fixes
# Insert labels via sexp AFTER save — kicad-sch-api's serializer
# drops global labels and raises TypeError on local labels.
pending_sexps = summary.pop("_pending_label_sexps", [])
if pending_sexps:
combined_sexp = "".join(pending_sexps)
insert_sexp_before_close(schematic_path, combined_sexp)
logger.info( logger.info(
"Batch applied: %d operations from %s to %s", "Batch applied: %d operations from %s to %s",
summary["total_operations"], summary["total_operations"],

View File

@ -7,53 +7,17 @@ Uses the shared geometry helpers from the patterns library.
""" """
import logging import logging
import os
from typing import Any from typing import Any
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed — power symbol tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _require_sch_api() -> dict[str, Any] | None:
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
"engine": "none",
}
return None
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
return "kicad-sch-api" if _HAS_SCH_API else "none" return "sch_document"
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if must_exist and not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def _expand(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
@mcp.tool() @mcp.tool()
@ -97,14 +61,10 @@ def add_power_symbol(
Dictionary with ``success``, placed ``reference``, ``lib_id``, Dictionary with ``success``, placed ``reference``, ``lib_id``,
symbol position, wire ID, and direction. symbol position, wire ID, and direction.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not net: if not net:
return {"success": False, "error": "net must be a non-empty string"} return {"success": False, "error": "net must be a non-empty string"}
@ -117,7 +77,7 @@ def add_power_symbol(
from mckicad.patterns._geometry import add_power_symbol_to_pin from mckicad.patterns._geometry import add_power_symbol_to_pin
from mckicad.utils.sexp_parser import resolve_pin_position from mckicad.utils.sexp_parser import resolve_pin_position
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
# Look up the target pin position (with sexp fallback for custom symbols) # Look up the target pin position (with sexp fallback for custom symbols)
pin_pos_tuple = resolve_pin_position(sch, schematic_path, pin_ref, pin_number) pin_pos_tuple = resolve_pin_position(sch, schematic_path, pin_ref, pin_number)

View File

@ -1,31 +1,30 @@
""" """
Schematic creation and manipulation tools for the mckicad MCP server. Schematic creation and manipulation tools for the mckicad MCP server.
Wraps the kicad-sch-api library to provide schematic editing through MCP tools. 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 Designed so that the underlying engine can be swapped to kipy IPC once KiCad
exposes a schematic API over its IPC transport. exposes a schematic API over its IPC transport.
""" """
from collections import Counter
import contextlib import contextlib
import logging import logging
import os import os
import re import re
from collections import Counter
from typing import Any from typing import Any
from mckicad.config import INLINE_RESULT_THRESHOLD from mckicad.config import INLINE_RESULT_THRESHOLD
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file 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 ( from mckicad.utils.sexp_parser import (
compute_label_placement, compute_label_placement,
generate_global_label_sexp,
generate_label_sexp,
generate_wire_sexp,
insert_sexp_before_close,
parse_global_labels,
parse_lib_symbol_pins,
resolve_pin_position_and_orientation, resolve_pin_position_and_orientation,
transform_pin_to_schematic,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,69 +33,14 @@ logger = logging.getLogger(__name__)
# Engine abstraction — swap point for future kipy IPC schematic support # Engine abstraction — swap point for future kipy IPC schematic support
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_HAS_SCH_API = False
try:
from kicad_sch_api import create_schematic as _ksa_create
from kicad_sch_api import get_symbol_info as _ksa_get_symbol_info
from kicad_sch_api import load_schematic as _ksa_load
from kicad_sch_api import search_symbols as _ksa_search
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed — schematic tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
"""Return the name of the active schematic manipulation engine. """Return the name of the active schematic manipulation engine.
Currently: ``kicad-sch-api`` (file-level manipulation) Currently: ``sch_document`` (sexp-tree file-level manipulation)
Future: ``kipy`` IPC when KiCad adds a schematic API over its IPC transport. Future: ``kipy`` IPC when KiCad adds a schematic API over its IPC transport.
""" """
if _HAS_SCH_API: return "sch_document"
return "kicad-sch-api"
return "none"
def _require_sch_api() -> dict[str, Any] | None:
"""Return an error dict if kicad-sch-api is unavailable, else None."""
if not _HAS_SCH_API:
return {
"success": False,
"error": ("kicad-sch-api is not installed. Install it with: uv add kicad-sch-api"),
"engine": "none",
}
return None
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
"""Validate a schematic file path. Returns an error dict on failure, else None."""
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {
"success": False,
"error": f"Path must end with .kicad_sch, got: {path}",
}
if must_exist and not os.path.isfile(expanded):
return {
"success": False,
"error": f"Schematic file not found: {expanded}",
}
return None
def _expand(path: str) -> str:
"""Expand ~ and return an absolute path."""
return os.path.abspath(os.path.expanduser(path))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -129,10 +73,6 @@ def create_schematic(
Returns: Returns:
Dictionary with ``success``, ``path``, ``paper_size``, and ``engine`` keys. Dictionary with ``success``, ``path``, ``paper_size``, and ``engine`` keys.
""" """
err = _require_sch_api()
if err:
return err
output_path = _expand(output_path) output_path = _expand(output_path)
if not output_path.endswith(".kicad_sch"): if not output_path.endswith(".kicad_sch"):
@ -155,8 +95,8 @@ def create_schematic(
if parent: if parent:
os.makedirs(parent, exist_ok=True) os.makedirs(parent, exist_ok=True)
sch = _ksa_create(name) sch = SchDocument.create(output_path, paper=paper_size)
sch.set_paper_size(paper_size) sch.set_title_block(title=name)
sch.save(output_path) sch.save(output_path)
logger.info("Created schematic '%s' (%s) at %s", name, paper_size, output_path) logger.info("Created schematic '%s' (%s) at %s", name, paper_size, output_path)
@ -198,17 +138,13 @@ def add_component(
Returns: Returns:
Dictionary with ``success``, component ``reference``, and ``lib_id``. Dictionary with ``success``, component ``reference``, and ``lib_id``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sch.components.add( sch.components.add(
lib_id=lib_id, lib_id=lib_id,
reference=reference, reference=reference,
@ -217,10 +153,6 @@ def add_component(
) )
sch.save(schematic_path) sch.save(schematic_path)
from mckicad.utils.sexp_parser import fix_property_private_keywords
fix_property_private_keywords(schematic_path)
logger.info("Added %s (%s) to %s at (%.1f, %.1f)", reference, lib_id, schematic_path, x, y) logger.info("Added %s (%s) to %s at (%.1f, %.1f)", reference, lib_id, schematic_path, x, y)
return { return {
"success": True, "success": True,
@ -257,12 +189,8 @@ def search_components(query: str, library: str | None = None) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success`` and a ``results`` list of matching symbols. Dictionary with ``success`` and a ``results`` list of matching symbols.
""" """
err = _require_sch_api()
if err:
return err
try: try:
raw_results = _ksa_search(query) raw_results = _lr_search(query)
# Filter by library when requested # Filter by library when requested
if library: if library:
@ -287,7 +215,7 @@ def search_components(query: str, library: str | None = None) -> dict[str, Any]:
logger.info("Symbol search for '%s' returned %d results", query, len(results)) logger.info("Symbol search for '%s' returned %d results", query, len(results))
# Large result sidecar file with top results inline # Large result -> sidecar file with top results inline
if len(results) > INLINE_RESULT_THRESHOLD: if len(results) > INLINE_RESULT_THRESHOLD:
safe_query = re.sub(r"[^\w\-]", "_", query)[:30] safe_query = re.sub(r"[^\w\-]", "_", query)[:30]
detail_path = write_detail_file(None, f"search_{safe_query}.json", results) detail_path = write_detail_file(None, f"search_{safe_query}.json", results)
@ -340,18 +268,14 @@ def add_wire(
Returns: Returns:
Dictionary with ``success`` and the wire ``id``. Dictionary with ``success`` and the wire ``id``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
wire_id = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y)) wire = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y))
sch.save(schematic_path) sch.save(schematic_path)
logger.info( logger.info(
@ -364,7 +288,7 @@ def add_wire(
) )
return { return {
"success": True, "success": True,
"wire_id": wire_id, "wire_id": str(wire.uuid),
"start": {"x": start_x, "y": start_y}, "start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y}, "end": {"x": end_x, "y": end_y},
"schematic_path": schematic_path, "schematic_path": schematic_path,
@ -399,23 +323,14 @@ def connect_pins(
Returns: Returns:
Dictionary with ``success`` and the created wire ``id``. Dictionary with ``success`` and the created wire ``id``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
wire_id = sch.add_wire_between_pins( wire = sch.add_wire_between_pins(from_ref, from_pin, to_ref, to_pin)
component1_ref=from_ref,
pin1_number=from_pin,
component2_ref=to_ref,
pin2_number=to_pin,
)
sch.save(schematic_path) sch.save(schematic_path)
logger.info( logger.info(
@ -428,7 +343,7 @@ def connect_pins(
) )
return { return {
"success": True, "success": True,
"wire_id": wire_id, "wire_id": str(wire.uuid),
"from": {"reference": from_ref, "pin": from_pin}, "from": {"reference": from_ref, "pin": from_pin},
"to": {"reference": to_ref, "pin": to_pin}, "to": {"reference": to_ref, "pin": to_pin},
"schematic_path": schematic_path, "schematic_path": schematic_path,
@ -477,10 +392,6 @@ def add_label(
automatically resolve position and rotation from a component pin. automatically resolve position and rotation from a component pin.
A wire stub is inserted connecting the pin to the label. A wire stub is inserted connecting the pin to the label.
Uses direct s-expression file insertion to bypass kicad-sch-api
serializer bugs that silently drop global labels and raise TypeError
on local labels.
Args: Args:
schematic_path: Path to an existing .kicad_sch file. schematic_path: Path to an existing .kicad_sch file.
text: Label text (becomes the net name, e.g. ``GND``, ``SPI_CLK``). text: Label text (becomes the net name, e.g. ``GND``, ``SPI_CLK``).
@ -518,11 +429,10 @@ def add_label(
} }
try: try:
sch = load_schematic(schematic_path)
if has_pin_ref: if has_pin_ref:
# Pin-referenced placement # Pin-referenced placement
from kicad_sch_api import load_schematic as _ksa_load
sch = _ksa_load(schematic_path)
pin_info = resolve_pin_position_and_orientation( pin_info = resolve_pin_position_and_orientation(
sch, schematic_path, pin_ref, pin_number, # type: ignore[arg-type] sch, schematic_path, pin_ref, pin_number, # type: ignore[arg-type]
) )
@ -541,42 +451,38 @@ def add_label(
label_rotation = placement["label_rotation"] label_rotation = placement["label_rotation"]
if global_label: if global_label:
sexp = generate_global_label_sexp( gl = sch.add_global_label(
text, lx, ly, rotation=label_rotation, shape=shape, text, lx, ly, shape=shape, rotation=label_rotation,
) )
label_id = gl.uuid
label_type = "global" label_type = "global"
else: else:
sexp = generate_label_sexp(text, lx, ly, rotation=label_rotation) lbl = sch.add_label(text, lx, ly, rotation=label_rotation)
label_id = lbl.uuid
label_type = "local" label_type = "local"
wire_sexp = generate_wire_sexp( # Add wire stub
sch.add_wire(
placement["stub_start_x"], placement["stub_start_y"], placement["stub_start_x"], placement["stub_start_y"],
placement["stub_end_x"], placement["stub_end_y"], placement["stub_end_x"], placement["stub_end_y"],
) )
combined = sexp + wire_sexp
insert_sexp_before_close(schematic_path, combined)
x, y = lx, ly x, y = lx, ly
else: else:
# Coordinate placement (original path) # Coordinate placement
if global_label: if global_label:
sexp = generate_global_label_sexp( gl = sch.add_global_label(
text, x, y, rotation=rotation, shape=shape, # type: ignore[arg-type] text, x, y, shape=shape, rotation=rotation, # type: ignore[arg-type]
) )
label_id = gl.uuid
label_type = "global" label_type = "global"
else: else:
sexp = generate_label_sexp( lbl = sch.add_label(
text, x, y, rotation=rotation, # type: ignore[arg-type] text, x, y, rotation=rotation, # type: ignore[arg-type]
) )
label_id = lbl.uuid
label_type = "local" label_type = "local"
insert_sexp_before_close(schematic_path, sexp) sch.save(schematic_path)
# Extract the UUID we generated from the sexp block
import re as _re
uuid_match = _re.search(r'\(uuid "([^"]+)"\)', sexp)
label_id = uuid_match.group(1) if uuid_match else "unknown"
logger.info( logger.info(
"Added %s label '%s' at (%.1f, %.1f) in %s", "Added %s label '%s' at (%.1f, %.1f) in %s",
@ -589,7 +495,7 @@ def add_label(
"label_type": label_type, "label_type": label_type,
"position": {"x": x, "y": y}, "position": {"x": x, "y": y},
"schematic_path": schematic_path, "schematic_path": schematic_path,
"engine": "sexp-direct", "engine": _get_schematic_engine(),
} }
except Exception as e: except Exception as e:
logger.error("Failed to add label '%s' in %s: %s", text, schematic_path, e) logger.error("Failed to add label '%s' in %s: %s", text, schematic_path, e)
@ -625,10 +531,6 @@ def add_hierarchical_sheet(
Returns: Returns:
Dictionary with ``success`` and sheet metadata. Dictionary with ``success`` and sheet metadata.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
@ -641,12 +543,14 @@ def add_hierarchical_sheet(
} }
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sheet_uuid = sch.add_sheet( sheet_result = sch.add_sheet(
name=name, name=name,
filename=filename, filename=filename,
position=(x, y), x=x,
size=(width, height), y=y,
width=width,
height=height,
) )
parent_uuid = sch.uuid parent_uuid = sch.uuid
sch.save(schematic_path) sch.save(schematic_path)
@ -665,7 +569,7 @@ def add_hierarchical_sheet(
"success": True, "success": True,
"sheet_name": name, "sheet_name": name,
"sheet_filename": filename, "sheet_filename": filename,
"sheet_uuid": sheet_uuid, "sheet_uuid": sheet_result["uuid"],
"parent_uuid": parent_uuid, "parent_uuid": parent_uuid,
"position": {"x": x, "y": y}, "position": {"x": x, "y": y},
"size": {"width": width, "height": height}, "size": {"width": width, "height": height},
@ -687,7 +591,7 @@ def list_components(
When called without ``reference``, returns all components. For large When called without ``reference``, returns all components. For large
schematics (>{threshold} components), a compact summary is returned schematics (>{threshold} components), a compact summary is returned
inline and the full list is written to ``.mckicad/components.json`` inline and the full list is written to ``.mckicad/components.json``
read that file for complete data. -- read that file for complete data.
When ``reference`` is provided, returns only that component's details When ``reference`` is provided, returns only that component's details
(always inline, always compact). (always inline, always compact).
@ -701,9 +605,6 @@ def list_components(
Dictionary with ``success``, ``count``, and either inline Dictionary with ``success``, ``count``, and either inline
``components`` list or a ``detail_file`` path. ``components`` list or a ``detail_file`` path.
""".replace("{threshold}", str(INLINE_RESULT_THRESHOLD)) """.replace("{threshold}", str(INLINE_RESULT_THRESHOLD))
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
@ -711,23 +612,16 @@ def list_components(
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
components: list[dict[str, Any]] = [] components: list[dict[str, Any]] = []
for comp in sch.components: for comp in sch.components:
entry: dict[str, Any] = { entry: dict[str, Any] = {
"reference": getattr(comp, "reference", None), "reference": comp.reference,
"lib_id": getattr(comp, "lib_id", None), "lib_id": comp.lib_id,
"value": getattr(comp, "value", None), "value": comp.value,
"position": {"x": comp.position.x, "y": comp.position.y},
} }
pos = getattr(comp, "position", None)
if pos is not None:
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
entry["position"] = {"x": pos[0], "y": pos[1]}
else:
entry["position"] = str(pos)
components.append(entry) components.append(entry)
# Single-component lookup # Single-component lookup
@ -749,7 +643,7 @@ def list_components(
logger.info("Listed %d components in %s", len(components), schematic_path) logger.info("Listed %d components in %s", len(components), schematic_path)
# Large result sidecar file # Large result -> sidecar file
if len(components) > INLINE_RESULT_THRESHOLD: if len(components) > INLINE_RESULT_THRESHOLD:
prefix_counts = Counter( prefix_counts = Counter(
re.match(r"[A-Za-z]+", c.get("reference", "") or "").group() # type: ignore[union-attr] re.match(r"[A-Za-z]+", c.get("reference", "") or "").group() # type: ignore[union-attr]
@ -785,7 +679,7 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]:
Returns statistics and a validation summary inline. For schematics with Returns statistics and a validation summary inline. For schematics with
many unique symbols, full symbol details and validation issues are many unique symbols, full symbol details and validation issues are
written to ``.mckicad/schematic_info.json`` read that file when you written to ``.mckicad/schematic_info.json`` -- read that file when you
need per-symbol data. need per-symbol data.
Args: Args:
@ -795,83 +689,43 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]:
Dictionary with ``success``, ``statistics``, ``validation`` summary, Dictionary with ``success``, ``statistics``, ``validation`` summary,
``unique_symbol_count``, and optionally a ``detail_file`` path. ``unique_symbol_count``, and optionally a ``detail_file`` path.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
# Gather statistics # Gather statistics
stats = sch.get_statistics() stats = sch.get_statistics()
# Supplement label statistics — sch.get_statistics() often reports 0 # Supplement text_elements for backwards compatibility
# for labels because the raw data parser doesn't count them. stats["text_elements"] = {
# Use sch.labels.get_statistics() for accurate counts. "label": stats.get("labels", 0),
with contextlib.suppress(Exception): "global_label": stats.get("global_labels", 0),
label_stats = sch.labels.get_statistics() "total_text_elements": stats.get("labels", 0) + stats.get("global_labels", 0),
if isinstance(label_stats, dict): }
if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict):
stats["text_elements"] = {}
label_count = label_stats.get("total_labels", label_stats.get("item_count", 0))
stats["text_elements"]["label"] = label_count
with contextlib.suppress(Exception):
hier_labels = list(getattr(sch, "hierarchical_labels", []))
if "text_elements" not in stats or not isinstance(stats.get("text_elements"), dict):
stats["text_elements"] = {}
stats["text_elements"]["global_label"] = len(hier_labels)
# Fallback: parse (global_label ...) nodes from raw file when
# kicad-sch-api reports 0 — it has no sch.global_labels attribute.
if isinstance(stats.get("text_elements"), dict):
if stats["text_elements"].get("global_label", 0) == 0:
raw_global = parse_global_labels(schematic_path)
if raw_global:
stats["text_elements"]["global_label"] = len(raw_global)
stats["text_elements"]["total_text_elements"] = (
stats["text_elements"].get("label", 0)
+ stats["text_elements"].get("global_label", 0)
)
# Run validation # Run validation
issues = sch.validate() issues = sch.validate()
# Collect symbol details (for sidecar file) # Collect symbol details
lib_ids_seen: set[str] = set() lib_ids_seen: set[str] = set()
symbol_details: list[dict[str, Any]] = [] symbol_details: list[dict[str, Any]] = []
for comp in sch.components: for comp in sch.components:
lid = getattr(comp, "lib_id", None) lid = comp.lib_id
if lid and lid not in lib_ids_seen: if lid and lid not in lib_ids_seen:
lib_ids_seen.add(lid) lib_ids_seen.add(lid)
try: try:
info = _ksa_get_symbol_info(lid) info = _lr_get_info(lid)
if isinstance(info, dict): if info is not None:
symbol_details.append(info) symbol_details.append(info)
else: else:
symbol_details.append( symbol_details.append({"lib_id": lid, "lookup_failed": True})
{
"lib_id": lid,
"name": getattr(info, "name", str(info)),
"description": getattr(info, "description", None),
"pin_count": getattr(info, "pin_count", None),
}
)
except Exception: except Exception:
symbol_details.append({"lib_id": lid, "lookup_failed": True}) symbol_details.append({"lib_id": lid, "lookup_failed": True})
# Normalise stats and issues
if not isinstance(stats, dict):
stats = {"raw": str(stats)}
if not isinstance(issues, list):
issues = [str(issues)] if issues else []
validation_passed = len(issues) == 0 validation_passed = len(issues) == 0
logger.info("Retrieved info for %s: %d issues", schematic_path, len(issues)) logger.info("Retrieved info for %s: %d issues", schematic_path, len(issues))
@ -928,17 +782,13 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
Dictionary with ``success`` and detailed component data including Dictionary with ``success`` and detailed component data including
properties, pin list, footprint, and validation results. properties, pin list, footprint, and validation results.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
comp = sch.components.get(reference) comp = sch.components.get(reference)
if comp is None: if comp is None:
return { return {
@ -949,119 +799,43 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
detail: dict[str, Any] = { detail: dict[str, Any] = {
"reference": reference, "reference": reference,
"lib_id": getattr(comp, "lib_id", None), "lib_id": comp.lib_id,
"value": getattr(comp, "value", None), "value": comp.value,
"footprint": getattr(comp, "footprint", None), "footprint": comp.footprint,
"position": {"x": comp.position.x, "y": comp.position.y},
"rotation": comp.rotation,
} }
# Position
pos = getattr(comp, "position", None)
if pos is not None:
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
detail["position"] = {"x": pos[0], "y": pos[1]}
else:
detail["position"] = str(pos)
# Rotation
detail["rotation"] = getattr(comp, "rotation", None)
# Properties # Properties
try: detail["properties"] = comp.properties
if hasattr(comp, "to_dict"):
comp_dict = comp.to_dict()
detail["properties"] = comp_dict.get("properties", {})
elif hasattr(comp, "properties"):
detail["properties"] = (
dict(comp.properties.items())
if hasattr(comp.properties, "items")
else str(comp.properties)
)
except Exception:
detail["properties"] = None
# Pins — use get_symbol_definition().pins for metadata and # Pins via SchDocument.list_component_pins()
# sch.list_component_pins() for schematic-transformed positions.
# comp.pins is empty on loaded schematics (kicad-sch-api gap).
pins: list[dict[str, Any]] = [] 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},
})
pin_positions: dict[str, Any] = {} # Enrich pins with name/type from lib_resolver if available
with contextlib.suppress(Exception): lib_info = _lr_get_info(comp.lib_id)
for pin_num, pos in sch.list_component_pins(reference): if lib_info and "pins" in lib_info:
pin_positions[str(pin_num)] = pos lib_pins_by_num = {
str(p["number"]): p for p in lib_info["pins"]
raw_pins: list[Any] | None = None }
with contextlib.suppress(Exception): for pin in pins:
sym_def = comp.get_symbol_definition() lib_pin = lib_pins_by_num.get(str(pin["number"]))
if sym_def is not None: if lib_pin:
raw_pins = list(sym_def.pins) pin["name"] = lib_pin.get("name")
pin["type"] = lib_pin.get("type")
if not raw_pins:
if hasattr(comp, "list_pins"):
with contextlib.suppress(Exception):
raw_pins = list(comp.list_pins())
if not raw_pins:
raw_pins = list(getattr(comp, "pins", []))
# Last resort: parse pins from the raw (lib_symbols ...) section.
# Handles custom library symbols where get_symbol_definition() fails.
if not raw_pins:
lib_id = getattr(comp, "lib_id", None)
if lib_id:
sexp_pins = parse_lib_symbol_pins(schematic_path, str(lib_id))
if sexp_pins:
comp_pos = getattr(comp, "position", None)
comp_rot = float(getattr(comp, "rotation", 0) or 0)
comp_mirror = getattr(comp, "mirror", None)
mirror_x = comp_mirror in ("x", True) if comp_mirror else False
cx = float(comp_pos.x) if comp_pos is not None and hasattr(comp_pos, "x") else 0.0
cy = float(comp_pos.y) if comp_pos is not None and hasattr(comp_pos, "y") else 0.0
for sp in sexp_pins:
sx, sy = transform_pin_to_schematic(
sp["x"], sp["y"], cx, cy, comp_rot, mirror_x
)
pins.append({
"number": sp["number"],
"name": sp["name"],
"type": sp["type"],
"position": {"x": sx, "y": sy},
})
for p in (raw_pins or []):
if isinstance(p, dict):
pins.append(p)
else:
pin_num = str(getattr(p, "number", getattr(p, "pin_number", "")))
pin_entry: dict[str, Any] = {
"number": pin_num,
"name": getattr(p, "name", getattr(p, "pin_name", None)),
"type": str(getattr(p, "pin_type", getattr(p, "electrical_type", getattr(p, "type", None)))),
}
if pin_num in pin_positions:
spos = pin_positions[pin_num]
pin_entry["position"] = {"x": spos.x, "y": spos.y}
else:
ppos = getattr(p, "position", None)
if ppos is not None:
if hasattr(ppos, "x"):
pin_entry["position"] = {"x": ppos.x, "y": ppos.y}
else:
pin_entry["position"] = str(ppos)
pins.append(pin_entry)
detail["pins"] = pins detail["pins"] = pins
detail["pin_count"] = len(pins) detail["pin_count"] = len(pins)
# BOM/board flags # BOM/board flags
detail["in_bom"] = getattr(comp, "in_bom", None) detail["in_bom"] = comp.in_bom
detail["on_board"] = getattr(comp, "on_board", None) detail["on_board"] = comp.on_board
# Validation
try:
if hasattr(comp, "validate"):
detail["validation"] = comp.validate()
except Exception:
detail["validation"] = None
logger.info("Got detail for %s in %s", reference, schematic_path) logger.info("Got detail for %s in %s", reference, schematic_path)
return { return {
@ -1080,7 +854,7 @@ def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
"""Get the hierarchical sheet tree of a KiCad schematic. """Get the hierarchical sheet tree of a KiCad schematic.
Returns the sheet structure with filenames and per-sheet component Returns the sheet structure with filenames and per-sheet component
counts. Essential for multi-sheet designs use this to understand counts. Essential for multi-sheet designs -- use this to understand
the design's organization before diving into specific sheets. the design's organization before diving into specific sheets.
Args: Args:
@ -1089,17 +863,13 @@ def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success`` and a ``hierarchy`` tree of sheets. Dictionary with ``success`` and a ``hierarchy`` tree of sheets.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
schematic_dir = os.path.dirname(schematic_path) schematic_dir = os.path.dirname(schematic_path)
def _count_components(filepath: str) -> int: def _count_components(filepath: str) -> int:
@ -1107,77 +877,68 @@ def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
if not os.path.isfile(filepath): if not os.path.isfile(filepath):
return -1 return -1
try: try:
sheet_sch = _ksa_load(filepath) sheet_sch = load_schematic(filepath)
return len(list(sheet_sch.components)) return len(sheet_sch.components)
except Exception: except Exception:
return -1 return -1
def _walk_hierarchy(node: dict[str, Any], base_dir: str) -> dict[str, Any]: def _walk_sheets(sheets: list, base_dir: str) -> list[dict[str, Any]]:
"""Convert a SheetManager hierarchy node into our output format.""" """Recursively walk sheets to build the hierarchy tree."""
filename = node.get("filename", "") result_sheets: list[dict[str, Any]] = []
info: dict[str, Any] = { for sheet in sheets:
"name": node.get("name", "unknown"), filename = sheet.filename
"filename": filename, info: dict[str, Any] = {
} "name": sheet.name,
"filename": filename,
}
if filename: if filename:
filepath = os.path.join(base_dir, filename) filepath = os.path.join(base_dir, filename)
count = _count_components(filepath) count = _count_components(filepath)
if count >= 0: if count >= 0:
info["component_count"] = count 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: else:
info["component_count"] = 0 info["component_count"] = 0
info["note"] = "file not found or failed to load"
else:
info["component_count"] = 0
children = node.get("children", []) # Count total sheets
if children: child_sheets = info.get("sheets", [])
info["sheets"] = [_walk_hierarchy(child, base_dir) for child in children] if child_sheets:
info["total_sheets"] = 1 + sum( info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in info["sheets"] c.get("total_sheets", 1) for c in child_sheets
) )
else: else:
info["total_sheets"] = 1 info["total_sheets"] = 1
return info result_sheets.append(info)
# Use SheetManager API — sch.sheets.get_sheet_hierarchy() returns a return result_sheets
# tree with children already identified from the S-expression data.
hierarchy_data = None
with contextlib.suppress(Exception):
hierarchy_data = sch.sheets.get_sheet_hierarchy()
if hierarchy_data and isinstance(hierarchy_data, dict) and "root" in hierarchy_data: root_info: dict[str, Any] = {
root_node = hierarchy_data["root"] "name": "root",
"filename": os.path.basename(schematic_path),
"component_count": len(sch.components),
}
root_info: dict[str, Any] = { if sch.sheets:
"name": "root", root_info["sheets"] = _walk_sheets(sch.sheets, schematic_dir)
"filename": os.path.basename(schematic_path), root_info["total_sheets"] = 1 + sum(
"component_count": len(list(sch.components)), c.get("total_sheets", 1) for c in root_info["sheets"]
} )
children = root_node.get("children", [])
if children:
root_info["sheets"] = [
_walk_hierarchy(child, schematic_dir) for child in children
]
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
else: else:
# Fallback: report just the root sheet root_info["total_sheets"] = 1
hierarchy = {
"name": "root", hierarchy = root_info
"filename": os.path.basename(schematic_path),
"component_count": len(list(sch.components)),
"total_sheets": 1,
"note": "Sheet hierarchy API unavailable — only root sheet reported",
}
logger.info("Got hierarchy for %s: %d total sheets", schematic_path, hierarchy["total_sheets"]) logger.info("Got hierarchy for %s: %d total sheets", schematic_path, hierarchy["total_sheets"])
return { return {

View File

@ -2,9 +2,8 @@
Schematic analysis and export tools for the mckicad MCP server. Schematic analysis and export tools for the mckicad MCP server.
Provides ERC checking, connectivity analysis, pin-level queries, netlist Provides ERC checking, connectivity analysis, pin-level queries, netlist
export, and PDF export. Uses kicad-sch-api for structural analysis and export, and PDF export. Uses SchDocument for structural analysis and
kicad-cli for rule checks and file exports, falling back gracefully when kicad-cli for rule checks and file exports.
either dependency is unavailable.
""" """
import contextlib import contextlib
@ -18,8 +17,9 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.kicad_cli import find_kicad_cli from mckicad.utils.kicad_cli import find_kicad_cli
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path
from mckicad.utils.sexp_parser import ( from mckicad.utils.sexp_parser import (
parse_global_labels,
parse_lib_symbol_pins, parse_lib_symbol_pins,
transform_pin_to_schematic, transform_pin_to_schematic,
) )
@ -29,55 +29,9 @@ from mckicad.utils.sexp_parser import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Engine detection
# ---------------------------------------------------------------------------
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed -- schematic analysis tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _require_sch_api() -> dict[str, Any] | None:
"""Return an error dict if kicad-sch-api is unavailable, else None."""
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
"engine": "none",
}
return None
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
if _HAS_SCH_API: return "sch_document"
return "kicad-sch-api"
return "none"
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
"""Validate a schematic file path. Returns an error dict on failure, else None."""
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if must_exist and not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def _expand(path: str) -> str:
"""Expand ~ and return an absolute path."""
return os.path.abspath(os.path.expanduser(path))
def _resolve_root_schematic(schematic_path: str) -> str | None: def _resolve_root_schematic(schematic_path: str) -> str | None:
@ -315,14 +269,17 @@ def _build_connectivity_state(
except Exception: except Exception:
pass pass
# Global labels from raw file — kicad-sch-api has no sch.global_labels # Global labels — SchDocument exposes these natively
# attribute, so (global_label ...) nodes are invisible to the API. try:
# Parse them directly from the S-expression when the file path is known. for gl in getattr(sch, "global_labels", []):
if schematic_path: text = getattr(gl, "text", None)
for gl in parse_global_labels(schematic_path): pos = getattr(gl, "position", None)
coord = _rc(gl["x"], gl["y"]) if text and pos:
_find(coord) coord = _rc(*_coord_from_point(pos))
label_at[coord] = gl["text"] _find(coord)
label_at[coord] = str(text)
except Exception:
pass
# Power symbols — their value acts as a net name (e.g. GND, VCC) # Power symbols — their value acts as a net name (e.g. GND, VCC)
for comp in getattr(sch, "components", []): for comp in getattr(sch, "components", []):
@ -470,40 +427,13 @@ def run_schematic_erc(
"error": f"Invalid severity filter: {severity}. Must be 'all', 'error', or 'warning'.", "error": f"Invalid severity filter: {severity}. Must be 'all', 'error', or 'warning'.",
} }
# --- Attempt 1: kicad-sch-api --- # ERC via kicad-cli
if _HAS_SCH_API:
try:
from kicad_sch_api import ElectricalRulesChecker # type: ignore[attr-defined]
sch = _ksa_load(schematic_path)
checker = ElectricalRulesChecker(sch)
violations = checker.run()
logger.info("ERC via kicad-sch-api found %d violation(s)", len(violations))
return _format_erc_result(schematic_path, violations, severity, "kicad-sch-api")
except (ImportError, AttributeError) as exc:
logger.info(
"kicad-sch-api ElectricalRulesChecker not available (%s), trying kicad-cli",
exc,
)
except Exception as exc:
logger.warning("kicad-sch-api ERC failed (%s), trying kicad-cli", exc)
# --- Attempt 2: kicad-cli ---
cli_path, cli_err = _require_kicad_cli() cli_path, cli_err = _require_kicad_cli()
if cli_err: if cli_err:
# Neither engine is available return {
api_err = _require_sch_api() "success": False,
if api_err: "error": "kicad-cli not found. Ensure kicad-cli is on your PATH.",
return { }
"success": False,
"error": (
"No ERC engine available. Install kicad-sch-api (uv add kicad-sch-api) "
"or ensure kicad-cli is on your PATH."
),
}
return cli_err
assert cli_path is not None assert cli_path is not None
try: try:
@ -630,18 +560,13 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]:
Dictionary with ``net_count``, ``connection_count``, Dictionary with ``net_count``, ``connection_count``,
``unconnected_pins``, and ``detail_file``. ``unconnected_pins``, and ``detail_file``.
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
net_graph, unconnected = _build_connectivity(sch, schematic_path) net_graph, unconnected = _build_connectivity(sch, schematic_path)
total_connections = sum(len(pins) for pins in net_graph.values()) total_connections = sum(len(pins) for pins in net_graph.values())
@ -693,18 +618,13 @@ def check_pin_connection(
Dictionary with ``net`` name and ``connected_to`` list of Dictionary with ``net`` name and ``connected_to`` list of
``{reference, pin}`` dicts for every other pin on the same net. ``{reference, pin}`` dicts for every other pin on the same net.
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
net_graph, _unconnected = _build_connectivity(sch, schematic_path) net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find which net this pin belongs to # Find which net this pin belongs to
@ -775,18 +695,13 @@ def verify_pins_connected(
Dictionary with ``connected`` boolean, plus ``from`` and ``to`` Dictionary with ``connected`` boolean, plus ``from`` and ``to``
endpoint descriptions. endpoint descriptions.
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
net_graph, _unconnected = _build_connectivity(sch, schematic_path) net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find the net for each pin # Find the net for each pin
@ -851,18 +766,13 @@ def get_component_pins(
Dictionary with ``reference``, ``pin_count``, and ``pins`` list Dictionary with ``reference``, ``pin_count``, and ``pins`` list
where each entry describes one pin. where each entry describes one pin.
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
# Locate the component # Locate the component
comp = None comp = None
@ -1239,10 +1149,6 @@ def audit_wiring(
(unconnected pins, high-fanout nets, auto-named nets), and optionally (unconnected pins, high-fanout nets, auto-named nets), and optionally
``detail_file`` when wire data is offloaded. ``detail_file`` when wire data is offloaded.
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
@ -1253,7 +1159,7 @@ def audit_wiring(
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
state = _build_connectivity_state(sch, schematic_path) state = _build_connectivity_state(sch, schematic_path)
find_fn = state["find"] find_fn = state["find"]
@ -1430,10 +1336,6 @@ def verify_connectivity(
Dictionary with ``verified`` count, ``failed`` count, and ``results`` Dictionary with ``verified`` count, ``failed`` count, and ``results``
list with per-net status (``match``, ``mismatch``, ``missing_net``). list with per-net status (``match``, ``mismatch``, ``missing_net``).
""" """
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
@ -1444,7 +1346,7 @@ def verify_connectivity(
schematic_path = _expand(schematic_path) schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
state = _build_connectivity_state(sch, schematic_path) state = _build_connectivity_state(sch, schematic_path)
net_graph = state["net_graph"] net_graph = state["net_graph"]
@ -1666,18 +1568,9 @@ def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
except Exception as exc: except Exception as exc:
logger.warning("Netlist-based connectivity failed: %s", exc) logger.warning("Netlist-based connectivity failed: %s", exc)
# Fallback: single-file kicad-sch-api analysis # Fallback: single-file SchDocument analysis
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-cli and kicad-sch-api both unavailable",
"net_count": 0,
"connection_count": 0,
"unconnected_pins": 0,
}
try: try:
sch = _ksa_load(expanded) sch = load_schematic(expanded)
net_graph, unconnected = _build_connectivity(sch, expanded) net_graph, unconnected = _build_connectivity(sch, expanded)
total_connections = sum(len(pins) for pins in net_graph.values()) total_connections = sum(len(pins) for pins in net_graph.values())
return { return {
@ -1685,7 +1578,7 @@ def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
"net_count": len(net_graph), "net_count": len(net_graph),
"connection_count": total_connections, "connection_count": total_connections,
"unconnected_pins": len(unconnected), "unconnected_pins": len(unconnected),
"engine": "kicad-sch-api", "engine": "sch_document",
} }
except Exception as exc: except Exception as exc:
return { return {

View File

@ -2,18 +2,19 @@
Schematic editing tools for modifying existing elements in KiCad schematics. Schematic editing tools for modifying existing elements in KiCad schematics.
Complements the creation-oriented tools in schematic.py with modification, Complements the creation-oriented tools in schematic.py with modification,
removal, and annotation capabilities. Uses the same kicad-sch-api engine removal, and annotation capabilities.
and follows the same swap-point pattern for future kipy IPC support.
Also includes s-expression-direct tools (e.g. ``remove_wires_by_criteria``) Also includes s-expression-direct tools (e.g. ``remove_wires_by_criteria``)
that bypass kicad-sch-api for operations it doesn't support well. that bypass the schematic engine for operations that work directly on the
s-expression tree.
""" """
import logging import logging
import os
from typing import Any from typing import Any
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path
from mckicad.utils.sexp_parser import ( from mckicad.utils.sexp_parser import (
parse_wire_segments, parse_wire_segments,
remove_sexp_blocks_by_uuid, remove_sexp_blocks_by_uuid,
@ -21,66 +22,9 @@ from mckicad.utils.sexp_parser import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Engine abstraction — mirrors schematic.py setup
# ---------------------------------------------------------------------------
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed — schematic edit tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
"""Return the name of the active schematic manipulation engine.""" return "sch_document"
if _HAS_SCH_API:
return "kicad-sch-api"
return "none"
def _require_sch_api() -> dict[str, Any] | None:
"""Return an error dict if kicad-sch-api is unavailable, else None."""
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
"engine": "none",
}
return None
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
"""Validate a schematic file path. Returns an error dict on failure, else None."""
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {
"success": False,
"error": f"Path must end with .kicad_sch, got: {path}",
}
if must_exist and not os.path.isfile(expanded):
return {
"success": False,
"error": f"Schematic file not found: {expanded}",
}
return None
def _expand(path: str) -> str:
"""Expand ~ and return an absolute path."""
return os.path.abspath(os.path.expanduser(path))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -123,14 +67,10 @@ def modify_component(
Returns: Returns:
Dictionary with ``success``, ``reference``, ``modified`` list, and ``engine``. Dictionary with ``success``, ``reference``, ``modified`` list, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not reference: if not reference:
return {"success": False, "error": "reference must be a non-empty string"} return {"success": False, "error": "reference must be a non-empty string"}
@ -143,7 +83,7 @@ def modify_component(
} }
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
comp = sch.components.get(reference) comp = sch.components.get(reference)
if comp is None: if comp is None:
return { return {
@ -162,11 +102,11 @@ def modify_component(
modified.append(f"rotation={rotation}") modified.append(f"rotation={rotation}")
if value is not None: if value is not None:
comp.value = value comp.set_value(value)
modified.append(f"value={value}") modified.append(f"value={value}")
if footprint is not None: if footprint is not None:
comp.footprint = footprint comp.set_footprint(footprint)
modified.append(f"footprint={footprint}") modified.append(f"footprint={footprint}")
if properties: if properties:
@ -175,11 +115,11 @@ def modify_component(
modified.append(f"property[{key}]={val}") modified.append(f"property[{key}]={val}")
if in_bom is not None: if in_bom is not None:
comp.in_bom = in_bom comp.set_in_bom(in_bom)
modified.append(f"in_bom={in_bom}") modified.append(f"in_bom={in_bom}")
if on_board is not None: if on_board is not None:
comp.on_board = on_board comp.set_on_board(on_board)
modified.append(f"on_board={on_board}") modified.append(f"on_board={on_board}")
if not modified: if not modified:
@ -227,20 +167,16 @@ def remove_component(schematic_path: str, reference: str) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success``, removed ``reference``, and ``engine``. Dictionary with ``success``, removed ``reference``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not reference: if not reference:
return {"success": False, "error": "reference must be a non-empty string"} return {"success": False, "error": "reference must be a non-empty string"}
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
removed = sch.components.remove(reference) removed = sch.components.remove(reference)
if removed is False: if removed is False:
return { return {
@ -283,20 +219,16 @@ def remove_wire(schematic_path: str, wire_id: str) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success``, removed ``wire_id``, and ``engine``. Dictionary with ``success``, removed ``wire_id``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not wire_id: if not wire_id:
return {"success": False, "error": "wire_id must be a non-empty string"} return {"success": False, "error": "wire_id must be a non-empty string"}
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sch.remove_wire(wire_id) sch.remove_wire(wire_id)
sch.save(schematic_path) sch.save(schematic_path)
@ -332,20 +264,16 @@ def remove_label(schematic_path: str, label_id: str) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success``, removed ``label_id``, and ``engine``. Dictionary with ``success``, removed ``label_id``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not label_id: if not label_id:
return {"success": False, "error": "label_id must be a non-empty string"} return {"success": False, "error": "label_id must be a non-empty string"}
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sch.remove_label(label_id) sch.remove_label(label_id)
sch.save(schematic_path) sch.save(schematic_path)
@ -392,14 +320,10 @@ def set_title_block(
Returns: Returns:
Dictionary with ``success``, ``fields_set`` list, and ``engine``. Dictionary with ``success``, ``fields_set`` list, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
# Build kwargs for kicad-sch-api's set_title_block(). # Build kwargs for kicad-sch-api's set_title_block().
# API signature: set_title_block(title, date, rev, company, comments) # API signature: set_title_block(title, date, rev, company, comments)
@ -432,7 +356,7 @@ def set_title_block(
} }
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sch.set_title_block(**kwargs) sch.set_title_block(**kwargs)
sch.save(schematic_path) sch.save(schematic_path)
@ -470,21 +394,17 @@ def add_text_annotation(
Returns: Returns:
Dictionary with ``success``, ``text_id``, ``position``, and ``engine``. Dictionary with ``success``, ``text_id``, ``position``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
if not text: if not text:
return {"success": False, "error": "text must be a non-empty string"} return {"success": False, "error": "text must be a non-empty string"}
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
text_id = sch.add_text(text=text, position=(x, y)) text_id = sch.add_text(text, x, y)
sch.save(schematic_path) sch.save(schematic_path)
logger.info("Added text annotation at (%.1f, %.1f) in %s", x, y, schematic_path) logger.info("Added text annotation at (%.1f, %.1f) in %s", x, y, schematic_path)
@ -521,18 +441,14 @@ def add_no_connect(
Returns: Returns:
Dictionary with ``success``, ``position``, and ``engine``. Dictionary with ``success``, ``position``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
sch.no_connects.add(position=(x, y)) sch.add_no_connect(x, y)
sch.save(schematic_path) sch.save(schematic_path)
logger.info("Added no-connect at (%.1f, %.1f) in %s", x, y, schematic_path) logger.info("Added no-connect at (%.1f, %.1f) in %s", x, y, schematic_path)
@ -562,17 +478,13 @@ def backup_schematic(schematic_path: str) -> dict[str, Any]:
Returns: Returns:
Dictionary with ``success``, ``backup_path``, and ``engine``. Dictionary with ``success``, ``backup_path``, and ``engine``.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
try: try:
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
backup_path = sch.backup() backup_path = sch.backup()
logger.info("Backed up %s to %s", schematic_path, backup_path) logger.info("Backed up %s to %s", schematic_path, backup_path)

View File

@ -8,53 +8,17 @@ for use in standalone scripts without MCP.
""" """
import logging import logging
import os
from typing import Any from typing import Any
from mckicad.server import mcp from mckicad.server import mcp
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic, validate_schematic_path as _validate_schematic_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HAS_SCH_API = False
try:
from kicad_sch_api import load_schematic as _ksa_load
_HAS_SCH_API = True
except ImportError:
logger.warning(
"kicad-sch-api not installed — pattern tools will return helpful errors. "
"Install with: uv add kicad-sch-api"
)
def _require_sch_api() -> dict[str, Any] | None:
if not _HAS_SCH_API:
return {
"success": False,
"error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api",
"engine": "none",
}
return None
def _get_schematic_engine() -> str: def _get_schematic_engine() -> str:
return "kicad-sch-api" if _HAS_SCH_API else "none" return "sch_document"
def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None:
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if must_exist and not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def _expand(path: str) -> str:
return os.path.abspath(os.path.expanduser(path))
@mcp.tool() @mcp.tool()
@ -93,14 +57,10 @@ def place_decoupling_bank_pattern(
Dictionary with placed component references, power symbol details, Dictionary with placed component references, power symbol details,
and grid bounds. and grid bounds.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
# Parse comma-separated values into cap specs # Parse comma-separated values into cap specs
values = [v.strip() for v in cap_values.split(",") if v.strip()] values = [v.strip() for v in cap_values.split(",") if v.strip()]
@ -112,7 +72,7 @@ def place_decoupling_bank_pattern(
try: try:
from mckicad.patterns.decoupling_bank import place_decoupling_bank from mckicad.patterns.decoupling_bank import place_decoupling_bank
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
result = place_decoupling_bank( result = place_decoupling_bank(
sch=sch, sch=sch,
caps=caps, caps=caps,
@ -173,19 +133,15 @@ def place_pull_resistor_pattern(
Returns: Returns:
Dictionary with resistor reference, wire ID, and power symbol details. Dictionary with resistor reference, wire ID, and power symbol details.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
try: try:
from mckicad.patterns.pull_resistor import place_pull_resistor from mckicad.patterns.pull_resistor import place_pull_resistor
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
result = place_pull_resistor( result = place_pull_resistor(
sch=sch, sch=sch,
signal_ref=signal_ref, signal_ref=signal_ref,
@ -253,19 +209,15 @@ def place_crystal_pattern(
Dictionary with crystal and capacitor references, wire IDs, Dictionary with crystal and capacitor references, wire IDs,
and ground symbol details. and ground symbol details.
""" """
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path) verr = _validate_schematic_path(schematic_path)
if verr: if verr:
return verr return verr
schematic_path = _expand(schematic_path)
try: try:
from mckicad.patterns.crystal_oscillator import place_crystal_with_caps from mckicad.patterns.crystal_oscillator import place_crystal_with_caps
sch = _ksa_load(schematic_path) sch = load_schematic(schematic_path)
result = place_crystal_with_caps( result = place_crystal_with_caps(
sch=sch, sch=sch,
xtal_value=xtal_value, xtal_value=xtal_value,

View File

@ -0,0 +1,541 @@
"""Unified symbol library resolution for KiCad.
Given a lib_id like ``"Device:R"``, finds the ``.kicad_sym`` file and
extracts the full symbol sexp tree. Reads both global and project
``sym-lib-table`` files, resolving KiCad path variables.
This module is the eventual replacement for the ad-hoc library search
in sexp_parser.py, providing proper sym-lib-table parsing with variable
substitution and mtime-based cache invalidation.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from mckicad.config import get_kicad_user_dir
from mckicad.utils.sexp_tree import (
SexpNode,
find,
find_named,
find_one,
find_recursive,
get_values,
parse,
parse_file,
text_at,
)
logger = logging.getLogger(__name__)
# Cache: path -> (mtime, parsed table dict)
_lib_table_cache: dict[str, tuple[float, dict[str, str]]] = {}
def resolve_symbol(
lib_id: str, *, project_dir: str | None = None,
) -> SexpNode | None:
"""Resolve a lib_id to its parsed symbol node.
Args:
lib_id: Full library identifier (e.g. ``"Device:R"``).
project_dir: Project directory for ``${KIPRJMOD}`` substitution.
Returns:
The parsed SexpNode for the symbol, or None if not found.
"""
if ":" not in lib_id:
return None
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return None
lib_path = resolve_library_path(library_name, project_dir=project_dir)
if lib_path is None:
return None
try:
tree = parse_file(lib_path)
except (OSError, UnicodeDecodeError):
logger.debug("Failed to parse library file: %s", lib_path)
return None
return find_named(tree, "symbol", symbol_name)
def resolve_symbol_with_tree(
lib_id: str, *, project_dir: str | None = None,
) -> tuple[SexpNode | None, SexpNode | None]:
"""Resolve a lib_id, returning both the symbol node and its library tree.
The library tree is needed for ``extract_symbol_pins`` and
``extract_pin_unit_map`` when the symbol uses ``(extends ...)``
to inherit pin geometry from a parent symbol.
Returns:
``(symbol_node, lib_tree)`` tuple. Both are None if resolution fails.
"""
if ":" not in lib_id:
return None, None
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return None, None
lib_path = resolve_library_path(library_name, project_dir=project_dir)
if lib_path is None:
return None, None
try:
tree = parse_file(lib_path)
except (OSError, UnicodeDecodeError):
logger.debug("Failed to parse library file: %s", lib_path)
return None, None
sym = find_named(tree, "symbol", symbol_name)
return sym, tree
def resolve_library_path(
library_name: str, *, project_dir: str | None = None,
) -> str | None:
"""Resolve a library name to its ``.kicad_sym`` file path.
Search order:
1. Project sym-lib-table (if project_dir given)
2. Global sym-lib-table (KiCad 9, then 8)
3. Direct file search (project dir, libs/, ../libs/)
"""
# 1. Project sym-lib-table
if project_dir:
table_path = os.path.join(project_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
if library_name in table:
uri = _substitute_variables(table[library_name], project_dir)
if os.path.isfile(uri):
return uri
# 2. Global sym-lib-tables (KiCad 9 first, then 8)
for version_dir in ("9.0", "8.0"):
config_dir = os.path.expanduser(f"~/.config/kicad/{version_dir}")
table_path = os.path.join(config_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
if library_name in table:
uri = _substitute_variables(table[library_name], project_dir)
if os.path.isfile(uri):
return uri
# 3. Direct file search (fallback for projects without sym-lib-table)
if project_dir:
filename = f"{library_name}.kicad_sym"
candidates = [
os.path.join(project_dir, filename),
os.path.join(project_dir, "libs", filename),
os.path.join(project_dir, os.pardir, "libs", filename),
]
for candidate in candidates:
resolved = os.path.normpath(candidate)
if os.path.isfile(resolved):
return resolved
return None
def extract_symbol_pins(
symbol_node: SexpNode,
*,
lib_tree: SexpNode | None = None,
) -> list[dict[str, Any]]:
"""Extract pin dicts from a parsed symbol node.
Output format matches ``sexp_parser.parse_lib_symbol_pins()``:
``{number, name, type, shape, x, y, rotation, length}``
Searches through sub-symbols (children with tag "symbol") to find
all pin definitions. If the symbol uses ``(extends "Parent")``,
follows the chain to find pin definitions in the parent symbol
(requires ``lib_tree`` the parsed library file root).
"""
# Check for extends chain
effective = _resolve_extends(symbol_node, lib_tree)
pins: list[dict[str, Any]] = []
all_pin_nodes = find_recursive(effective, "pin")
for pin_node in all_pin_nodes:
pin_dict = _parse_pin_node(pin_node)
if pin_dict is not None:
pins.append(pin_dict)
return pins
def extract_pin_unit_map(
symbol_node: SexpNode,
*,
lib_tree: SexpNode | None = None,
) -> dict[str, int]:
"""Build pin_number -> unit_number map from sub-symbols.
KiCad sub-symbols are named ``<base>_<unit>_<style>`` (e.g.
``TL072_2_1`` for unit 2). If the symbol uses ``(extends "Parent")``,
the parent's sub-symbols are used for pin resolution.
"""
effective = _resolve_extends(symbol_node, lib_tree)
base_name = effective.values[0] if effective.values else ""
pin_unit_map: dict[str, int] = {}
for sub_sym in find(effective, "symbol"):
if not sub_sym.values:
continue
sub_name = sub_sym.values[0]
# Parse unit number from <base>_<unit>_<style> pattern
if not sub_name.startswith(base_name + "_"):
continue
suffix = sub_name[len(base_name) + 1 :]
parts = suffix.split("_", 1)
if not parts or not parts[0].isdigit():
continue
unit_num = int(parts[0])
# Extract pins in this sub-symbol
for pin_node in find_recursive(sub_sym, "pin"):
pin_dict = _parse_pin_node(pin_node)
if pin_dict is not None:
pin_unit_map[pin_dict["number"]] = unit_num
return pin_unit_map
def extract_symbol_sexp_text(
lib_id: str, *, project_dir: str | None = None,
) -> str | None:
"""Return the raw sexp text for a symbol from its library file.
Used to embed correct lib_symbols in schematics (avoiding
kicad-sch-api's stale cached versions).
"""
if ":" not in lib_id:
return None
library_name, symbol_name = lib_id.split(":", 1)
if not library_name or not symbol_name:
return None
lib_path = resolve_library_path(library_name, project_dir=project_dir)
if lib_path is None:
return None
try:
with open(lib_path, encoding="utf-8") as f:
source = f.read()
except (OSError, UnicodeDecodeError):
return None
tree = parse(source)
sym = find_named(tree, "symbol", symbol_name)
if sym is None:
return None
return text_at(source, sym.span)
def get_kicad_symbol_dir() -> str | None:
"""Detect the KiCad system symbol directory.
Checks ``KICAD9_SYMBOL_DIR`` and ``KICAD8_SYMBOL_DIR`` env vars,
then falls back to the standard Linux install path.
"""
for env_var in ("KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR"):
val = os.environ.get(env_var)
if val and os.path.isdir(val):
return val
# Standard paths
for path in ("/usr/share/kicad/symbols",):
if os.path.isdir(path):
return path
return None
def load_sym_lib_table(table_path: str) -> dict[str, str]:
"""Parse a sym-lib-table into ``{name: uri}`` dict.
Uses mtime-based caching to avoid re-parsing unchanged files.
URIs are returned with variables un-substituted (caller applies
substitution based on context).
"""
try:
mtime = os.path.getmtime(table_path)
except OSError:
return {}
cached = _lib_table_cache.get(table_path)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
with open(table_path, encoding="utf-8") as f:
content = f.read()
except (OSError, UnicodeDecodeError):
return {}
tree = parse(content)
result: dict[str, str] = {}
for lib_node in find(tree, "lib"):
name_vals = get_values(lib_node, "name")
uri_vals = get_values(lib_node, "uri")
if name_vals and uri_vals:
result[name_vals[0]] = uri_vals[0]
_lib_table_cache[table_path] = (mtime, result)
return result
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _resolve_extends(
symbol_node: SexpNode, lib_tree: SexpNode | None,
) -> SexpNode:
"""Follow ``(extends "ParentName")`` to find the symbol with actual pins.
KiCad allows symbols to inherit pin geometry from a parent symbol
in the same library file (e.g. TL072 extends LM2904). This
function walks the chain up to 5 levels to find the root definition.
"""
current = symbol_node
for _ in range(5): # guard against cycles
extends_node = find_one(current, "extends")
if extends_node is None or not extends_node.values:
break
if lib_tree is None:
break
parent_name = extends_node.values[0]
parent = find_named(lib_tree, "symbol", parent_name)
if parent is None:
break
current = parent
return current
def _parse_pin_node(pin_node: SexpNode) -> dict[str, Any] | None:
"""Convert a parsed ``(pin type shape ...)`` node into a pin dict."""
# pin_node.values = [type, shape, ...]
if len(pin_node.values) < 2:
return None
pin_type = pin_node.values[0]
pin_shape = pin_node.values[1]
# (at X Y [rotation])
at_vals = get_values(pin_node, "at")
if not at_vals or len(at_vals) < 2:
return None
x = float(at_vals[0])
y = float(at_vals[1])
rotation = float(at_vals[2]) if len(at_vals) > 2 else 0.0
# (length L)
length_vals = get_values(pin_node, "length")
length = float(length_vals[0]) if length_vals else 0.0
# (name "NAME" ...)
name_node = find_one(pin_node, "name")
name = name_node.values[0] if name_node and name_node.values else ""
# (number "NUM" ...)
number_node = find_one(pin_node, "number")
number = number_node.values[0] if number_node and number_node.values else ""
return {
"number": number,
"name": name,
"type": pin_type,
"shape": pin_shape,
"x": x,
"y": y,
"rotation": rotation,
"length": length,
}
def search_symbols(
query: str, *, project_dir: str | None = None,
) -> list[dict[str, Any]]:
"""Search all available symbol libraries for symbols matching a query.
Iterates libraries from sym-lib-table (global + project-local) and
fuzzy-matches the query against symbol name, keywords, and description.
Returns list of dicts: {lib_id, name, description, keywords, pin_count}.
"""
query_lower = query.lower()
results: list[dict[str, Any]] = []
seen_lib_ids: set[str] = set()
for lib_name, lib_path in _iter_all_libraries(project_dir=project_dir):
try:
tree = parse_file(lib_path)
except (OSError, UnicodeDecodeError):
continue
for sym_node in find(tree, "symbol"):
if not sym_node.values:
continue
sym_name = sym_node.values[0]
# Skip sub-symbols (contain _ followed by digit pattern)
if "_" in sym_name:
parts = sym_name.rsplit("_", 2)
if len(parts) >= 3 and parts[-1].isdigit() and parts[-2].isdigit():
continue
lib_id = f"{lib_name}:{sym_name}"
if lib_id in seen_lib_ids:
continue
# Extract searchable fields
description = ""
keywords = ""
for prop in find(sym_node, "property"):
vals = prop.values
if len(vals) >= 2:
pname = vals[0] if vals[0] != "private" else (vals[1] if len(vals) >= 3 else "")
pval = vals[1] if vals[0] != "private" else (vals[2] if len(vals) >= 3 else "")
if pname == "ki_keywords":
keywords = pval
elif pname == "ki_description":
description = pval
# Fuzzy match
searchable = f"{sym_name} {description} {keywords}".lower()
if query_lower not in searchable:
continue
seen_lib_ids.add(lib_id)
pin_count = len(extract_symbol_pins(sym_node, lib_tree=tree))
results.append({
"lib_id": lib_id,
"name": sym_name,
"description": description,
"keywords": keywords,
"pin_count": pin_count,
})
return results
def get_symbol_info(
lib_id: str, *, project_dir: str | None = None,
) -> dict[str, Any] | None:
"""Get detailed info about a specific symbol by lib_id.
Returns dict with name, description, keywords, pin_count,
footprint_filters, and units count. Returns None if not found.
"""
sym_node, lib_tree = resolve_symbol_with_tree(lib_id, project_dir=project_dir)
if sym_node is None:
return None
name = sym_node.values[0] if sym_node.values else ""
description = ""
keywords = ""
fp_filters = ""
for prop in find(sym_node, "property"):
vals = prop.values
if len(vals) >= 2:
pname = vals[0] if vals[0] != "private" else (vals[1] if len(vals) >= 3 else "")
pval = vals[1] if vals[0] != "private" else (vals[2] if len(vals) >= 3 else "")
if pname == "ki_keywords":
keywords = pval
elif pname == "ki_description":
description = pval
elif pname == "ki_fp_filters":
fp_filters = pval
pins = extract_symbol_pins(sym_node, lib_tree=lib_tree)
# Count units from sub-symbol enumeration
units: set[int] = set()
base_name = name
for sub in find(sym_node, "symbol"):
if sub.values and sub.values[0].startswith(base_name + "_"):
suffix = sub.values[0][len(base_name) + 1:]
parts = suffix.split("_", 1)
if parts and parts[0].isdigit():
units.add(int(parts[0]))
return {
"lib_id": lib_id,
"name": name,
"description": description,
"keywords": keywords,
"pin_count": len(pins),
"pins": pins,
"footprint_filters": fp_filters,
"units": max(len(units), 1),
}
def _iter_all_libraries(
*, project_dir: str | None = None,
) -> list[tuple[str, str]]:
"""Yield (library_name, file_path) for all available symbol libraries."""
result: list[tuple[str, str]] = []
seen: set[str] = set()
# Project sym-lib-table
if project_dir:
table_path = os.path.join(project_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
for name, uri in table.items():
if name not in seen:
resolved = _substitute_variables(uri, project_dir)
if os.path.isfile(resolved):
result.append((name, resolved))
seen.add(name)
# Global sym-lib-tables
for version_dir in ("9.0", "8.0"):
config_dir = os.path.expanduser(f"~/.config/kicad/{version_dir}")
table_path = os.path.join(config_dir, "sym-lib-table")
if os.path.isfile(table_path):
table = load_sym_lib_table(table_path)
for name, uri in table.items():
if name not in seen:
resolved = _substitute_variables(uri, project_dir)
if os.path.isfile(resolved):
result.append((name, resolved))
seen.add(name)
return result
def _substitute_variables(uri: str, project_dir: str | None = None) -> str:
"""Replace KiCad path variables in a URI."""
if "${KIPRJMOD}" in uri and project_dir:
uri = uri.replace("${KIPRJMOD}", project_dir)
symbol_dir = get_kicad_symbol_dir()
if symbol_dir:
uri = uri.replace("${KICAD9_SYMBOL_DIR}", symbol_dir)
uri = uri.replace("${KICAD8_SYMBOL_DIR}", symbol_dir)
kicad_user_dir = get_kicad_user_dir()
if kicad_user_dir:
uri = uri.replace("${KICAD_USER_DIR}", kicad_user_dir)
return os.path.normpath(uri)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
"""Shared helpers for schematic tool modules.
Consolidates the duplicated ``_validate_schematic_path()``,
``_expand()``, and ``load_schematic()`` functions that previously
existed in every tool file alongside ``_HAS_SCH_API`` guards.
"""
import os
from typing import Any
from mckicad.utils.sch_document import SchDocument
def load_schematic(
filepath: str, *, project_dir: str | None = None,
) -> SchDocument:
"""Load a schematic file, returning a SchDocument."""
expanded = expand(filepath)
if project_dir is None:
project_dir = os.path.dirname(expanded)
return SchDocument(expanded, project_dir=project_dir)
def validate_schematic_path(
path: str, must_exist: bool = True,
) -> dict[str, Any] | None:
"""Validate a schematic file path. Returns an error dict on failure, else None."""
if not path:
return {"success": False, "error": "Schematic path must be a non-empty string"}
expanded = os.path.expanduser(path)
if not expanded.endswith(".kicad_sch"):
return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"}
if must_exist and not os.path.isfile(expanded):
return {"success": False, "error": f"Schematic file not found: {expanded}"}
return None
def expand(path: str) -> str:
"""Expand ~ and return an absolute path."""
return os.path.abspath(os.path.expanduser(path))

View File

@ -30,7 +30,6 @@ import os
import re import re
import tempfile import tempfile
from typing import Any from typing import Any
import uuid as _uuid_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -252,8 +251,7 @@ def _find_component_for_pin(
# Match by unit number # Match by unit number
for comp in all_units: for comp in all_units:
data = getattr(comp, "_data", None) comp_unit = getattr(comp, "unit", 1)
comp_unit = data.unit if data else 1
if comp_unit == target_unit: if comp_unit == target_unit:
return comp return comp
@ -548,225 +546,11 @@ def _extract_named_section(content: str, keyword: str, name: str) -> str | None:
return None return None
# ---------------------------------------------------------------------------
# S-expression generation — bypasses kicad-sch-api serializer bugs
# ---------------------------------------------------------------------------
def _rc(value: float) -> str:
"""Round a coordinate to 2 decimal places for s-expression output."""
return f"{value:.2f}".rstrip("0").rstrip(".")
def _escape_sexp_string(text: str) -> str:
"""Escape a string for safe embedding in an s-expression double-quoted value."""
return text.replace("\\", "\\\\").replace('"', '\\"')
def generate_label_sexp(
text: str,
x: float,
y: float,
rotation: float = 0,
uuid_str: str | None = None,
) -> str:
"""Generate a KiCad ``(label ...)`` s-expression block.
Args:
text: Label text (net name).
x: Horizontal position in schematic units.
y: Vertical position in schematic units.
rotation: Label rotation in degrees (0, 90, 180, 270).
uuid_str: Explicit UUID string. Auto-generated if None.
Returns:
Complete ``(label ...)`` s-expression string ready for file insertion.
"""
if uuid_str is None:
uuid_str = str(_uuid_mod.uuid4())
safe_text = _escape_sexp_string(text)
rot = _rc(rotation)
return (
f'\t(label "{safe_text}"\n'
f"\t\t(at {_rc(x)} {_rc(y)} {rot})\n"
f"\t\t(effects\n"
f"\t\t\t(font\n"
f"\t\t\t\t(size 1.27 1.27)\n"
f"\t\t\t)\n"
f"\t\t\t(justify left bottom)\n"
f"\t\t)\n"
f'\t\t(uuid "{uuid_str}")\n'
f"\t)\n"
)
def generate_global_label_sexp(
text: str,
x: float,
y: float,
rotation: float = 0,
shape: str = "bidirectional",
uuid_str: str | None = None,
) -> str:
"""Generate a KiCad ``(global_label ...)`` s-expression block.
Includes the ``Intersheetrefs`` property required by KiCad 9+.
Args:
text: Label text (net name).
x: Horizontal position in schematic units.
y: Vertical position in schematic units.
rotation: Label rotation in degrees (0, 90, 180, 270).
shape: Label shape ``bidirectional``, ``input``, ``output``,
``tri_state``, or ``passive``.
uuid_str: Explicit UUID string. Auto-generated if None.
Returns:
Complete ``(global_label ...)`` s-expression string.
"""
if uuid_str is None:
uuid_str = str(_uuid_mod.uuid4())
safe_text = _escape_sexp_string(text)
rot = _rc(rotation)
return (
f'\t(global_label "{safe_text}"\n'
f"\t\t(shape {shape})\n"
f"\t\t(at {_rc(x)} {_rc(y)} {rot})\n"
f"\t\t(effects\n"
f"\t\t\t(font\n"
f"\t\t\t\t(size 1.27 1.27)\n"
f"\t\t\t)\n"
f"\t\t\t(justify left)\n"
f"\t\t)\n"
f'\t\t(uuid "{uuid_str}")\n'
f'\t\t(property "Intersheetrefs" "${{INTERSHEET_REFS}}"\n'
f"\t\t\t(at 0 0 0)\n"
f"\t\t\t(effects\n"
f"\t\t\t\t(font\n"
f"\t\t\t\t\t(size 1.27 1.27)\n"
f"\t\t\t\t)\n"
f"\t\t\t\t(hide yes)\n"
f"\t\t\t)\n"
f"\t\t)\n"
f"\t)\n"
)
def insert_sexp_before_close(filepath: str, sexp_block: str) -> None:
"""Insert an s-expression block into a ``.kicad_sch`` file before its final ``)``.
Performs an atomic write (temp file + ``os.replace()``) to avoid
corrupting the schematic if interrupted.
Args:
filepath: Path to an existing ``.kicad_sch`` file.
sexp_block: The s-expression text to insert.
Raises:
ValueError: If the file doesn't start with ``(kicad_sch``.
FileNotFoundError: If the file doesn't exist.
"""
with open(filepath, encoding="utf-8") as f:
content = f.read()
stripped = content.lstrip()
if not stripped.startswith("(kicad_sch"):
raise ValueError(f"Not a KiCad schematic file: {filepath}")
# Find the last closing paren
last_close = content.rfind(")")
if last_close == -1:
raise ValueError(f"Malformed schematic file (no closing paren): {filepath}")
new_content = content[:last_close] + sexp_block + content[last_close:]
# Atomic write: temp file in same directory, then rename
fd, tmp_path = tempfile.mkstemp(
dir=os.path.dirname(filepath) or ".",
suffix=".kicad_sch.tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp_f:
tmp_f.write(new_content)
os.replace(tmp_path, filepath)
except BaseException:
# Clean up temp file on any failure
with _suppress_os_error():
os.unlink(tmp_path)
raise
def _suppress_os_error(): def _suppress_os_error():
"""Context manager that suppresses OSError (for cleanup paths).""" """Context manager that suppresses OSError (for cleanup paths)."""
return contextlib.suppress(OSError) return contextlib.suppress(OSError)
# ---------------------------------------------------------------------------
# Post-save fixup: kicad-sch-api property serialization bug
# ---------------------------------------------------------------------------
# kicad-sch-api's formatter unconditionally quotes lst[1] in (property ...)
# nodes. For KiCad 9's ``(property private "name" "value" ...)`` syntax,
# the bare keyword ``private`` is mis-serialized as the quoted string
# ``"private"`` and the real value is left unquoted.
#
# Match: (property "private" "NAME" BARE_VALUE...
# where BARE_VALUE is everything up to a newline or open-paren.
_PROPERTY_PRIVATE_FIX_RE = re.compile(
r'\(property\s+"private"\s+"([^"]*)"\s+([^\n(]+)',
)
def fix_property_private_keywords(filepath: str) -> int:
"""Repair mis-serialized ``(property private ...)`` keywords in a schematic.
kicad-sch-api's formatter quotes the ``private`` keyword as a string,
producing ``(property "private" "name" value)`` instead of KiCad 9's
correct ``(property private "name" "value")``. This corrupts
kicad-cli's parser and silently drops affected sheets from netlist
export.
Performs an atomic write (temp file + ``os.replace()``).
Args:
filepath: Path to a ``.kicad_sch`` file.
Returns:
Number of properties repaired.
"""
try:
with open(filepath, encoding="utf-8") as f:
content = f.read()
except Exception:
return 0
def _fix_match(m: re.Match[str]) -> str:
name = m.group(1)
value = m.group(2).rstrip()
return f'(property private "{name}" "{value}"'
new_content, count = _PROPERTY_PRIVATE_FIX_RE.subn(_fix_match, content)
if count == 0:
return 0
# Atomic write
fd, tmp_path = tempfile.mkstemp(
dir=os.path.dirname(filepath) or ".",
suffix=".kicad_sch.tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp_f:
tmp_f.write(new_content)
os.replace(tmp_path, filepath)
except BaseException:
with _suppress_os_error():
os.unlink(tmp_path)
raise
logger.info("Fixed %d malformed (property private ...) in %s", count, filepath)
return count
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Wire segment extraction and removal # Wire segment extraction and removal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -964,9 +748,11 @@ def resolve_pin_position(
if not sexp_pins: if not sexp_pins:
return None return None
# Find the matching pin
# Take the LAST matching pin across sub-symbols. KiCad lib_symbols # Take the LAST matching pin across sub-symbols. KiCad lib_symbols
# may define the same pin in both _1_0 and _1_1 sub-symbols; the # may define the same pin in both _1_0 and _1_1 sub-symbols; the
# later definition takes precedence (dict-overwrite semantics). # later definition takes precedence (same as dict-overwrite semantics
# used by external tools like fix_pin_positions.py).
target_pin = None target_pin = None
for pin in sexp_pins: for pin in sexp_pins:
if pin["number"] == pin_number: if pin["number"] == pin_number:
@ -1483,41 +1269,3 @@ def resolve_wire_collision(
((stub_start_x, stub_start_y), (new_lx, new_ly), net_name), ((stub_start_x, stub_start_y), (new_lx, new_ly), net_name),
) )
return (stub_start_x, stub_start_y, new_lx, new_ly) return (stub_start_x, stub_start_y, new_lx, new_ly)
def generate_wire_sexp(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
uuid_str: str | None = None,
) -> str:
"""Generate a KiCad ``(wire ...)`` s-expression block.
Creates a wire segment with default stroke style, suitable for
insertion via :func:`insert_sexp_before_close`.
Args:
start_x: Wire start X coordinate.
start_y: Wire start Y coordinate.
end_x: Wire end X coordinate.
end_y: Wire end Y coordinate.
uuid_str: Explicit UUID string. Auto-generated if None.
Returns:
Complete ``(wire ...)`` s-expression string.
"""
if uuid_str is None:
uuid_str = str(_uuid_mod.uuid4())
return (
f"\t(wire\n"
f"\t\t(pts\n"
f"\t\t\t(xy {_rc(start_x)} {_rc(start_y)}) (xy {_rc(end_x)} {_rc(end_y)})\n"
f"\t\t)\n"
f"\t\t(stroke\n"
f"\t\t\t(width 0)\n"
f"\t\t\t(type default)\n"
f"\t\t)\n"
f'\t\t(uuid "{uuid_str}")\n'
f"\t)\n"
)

View File

@ -0,0 +1,410 @@
"""KiCad s-expression tokenizer and tree builder.
Parses .kicad_sch and .kicad_sym files into a navigable tree with
byte-offset tracking for targeted writes. Replaces ad-hoc regex +
bracket-counting with a proper parse tree.
"""
from __future__ import annotations
import contextlib
import copy
from dataclasses import dataclass, field
from enum import Enum, auto
import os
import re
import tempfile
class TokenType(Enum):
LPAREN = auto()
RPAREN = auto()
ATOM = auto()
STRING = auto()
@dataclass(slots=True)
class Token:
type: TokenType
value: str
offset: int
@dataclass
class SexpNode:
"""A node in a KiCad s-expression tree.
KiCad sexp nodes follow the pattern ``(tag values... children...)``
where ``tag`` is the first atom, ``values`` are subsequent atoms/strings
before any child nodes, and ``children`` are nested ``(keyword ...)``
sub-expressions.
``_quoted[i]`` tracks whether ``values[i]`` came from a STRING token
(True) or an ATOM token (False). Used by ``serialize()`` to reproduce
the original quoting style.
"""
tag: str
values: list[str] = field(default_factory=list)
children: list[SexpNode] = field(default_factory=list)
span: tuple[int, int] = (0, 0)
_quoted: list[bool] = field(default_factory=list)
def tokenize(text: str) -> list[Token]:
"""Split sexp text into tokens with byte offsets.
Token types:
- LPAREN / RPAREN: structural delimiters
- STRING: ``"..."`` with ``\\"`` and ``\\\\`` escaping
- ATOM: bare words, numbers, negative numbers any non-whitespace,
non-paren sequence
"""
tokens: list[Token] = []
i = 0
n = len(text)
while i < n:
c = text[i]
if c in (" ", "\t", "\n", "\r"):
i += 1
continue
if c == "(":
tokens.append(Token(TokenType.LPAREN, "(", i))
i += 1
continue
if c == ")":
tokens.append(Token(TokenType.RPAREN, ")", i))
i += 1
continue
if c == '"':
# Quoted string — scan for unescaped closing quote
start = i
i += 1
parts: list[str] = []
while i < n:
ch = text[i]
if ch == "\\":
if i + 1 < n:
next_ch = text[i + 1]
if next_ch == '"':
parts.append('"')
elif next_ch == "\\":
parts.append("\\")
elif next_ch == "n":
parts.append("\n")
else:
parts.append(next_ch)
i += 2
continue
elif ch == '"':
break
parts.append(ch)
i += 1
i += 1 # skip closing quote
tokens.append(Token(TokenType.STRING, "".join(parts), start))
continue
# Bare atom — everything until whitespace, paren, or quote
start = i
while i < n and text[i] not in (" ", "\t", "\n", "\r", "(", ")", '"'):
i += 1
tokens.append(Token(TokenType.ATOM, text[start:i], start))
return tokens
def _parse_tokens(tokens: list[Token], pos: int) -> tuple[SexpNode, int]:
"""Parse a single node from token stream starting at ``pos``.
Expects ``tokens[pos]`` to be an LPAREN. Returns the built node
and the index after the closing RPAREN.
"""
assert tokens[pos].type == TokenType.LPAREN
open_offset = tokens[pos].offset
pos += 1
# First token after '(' is the tag
if pos >= len(tokens) or tokens[pos].type in (TokenType.LPAREN, TokenType.RPAREN):
# Empty or anonymous node — use empty tag
tag = ""
else:
tag = tokens[pos].value
pos += 1
values: list[str] = []
quoted: list[bool] = []
children: list[SexpNode] = []
while pos < len(tokens):
tok = tokens[pos]
if tok.type == TokenType.RPAREN:
close_offset = tok.offset + 1
node = SexpNode(
tag=tag,
values=values,
children=children,
span=(open_offset, close_offset),
_quoted=quoted,
)
return node, pos + 1
if tok.type == TokenType.LPAREN:
child, pos = _parse_tokens(tokens, pos)
children.append(child)
continue
# ATOM or STRING — goes into values
values.append(tok.value)
quoted.append(tok.type == TokenType.STRING)
pos += 1
# Unterminated — return what we have
node = SexpNode(
tag=tag,
values=values,
children=children,
span=(open_offset, len(tokens[-1].value) + tokens[-1].offset if tokens else 0),
_quoted=quoted,
)
return node, pos
def parse(text: str) -> SexpNode:
"""Parse sexp text into a tree. Returns the root node."""
tokens = tokenize(text)
if not tokens:
return SexpNode(tag="", span=(0, 0))
node, _ = _parse_tokens(tokens, 0)
return node
def parse_file(filepath: str) -> SexpNode:
"""Read a file and parse it into a sexp tree."""
with open(filepath, encoding="utf-8") as f:
text = f.read()
return parse(text)
# ---------------------------------------------------------------------------
# Query functions
# ---------------------------------------------------------------------------
def find(node: SexpNode, tag: str) -> list[SexpNode]:
"""Find all direct children with a matching tag."""
return [c for c in node.children if c.tag == tag]
def find_recursive(node: SexpNode, tag: str) -> list[SexpNode]:
"""Find all descendants with a matching tag (depth-first)."""
results: list[SexpNode] = []
_find_recursive_impl(node, tag, results)
return results
def _find_recursive_impl(
node: SexpNode, tag: str, results: list[SexpNode],
) -> None:
for child in node.children:
if child.tag == tag:
results.append(child)
_find_recursive_impl(child, tag, results)
def find_one(node: SexpNode, tag: str) -> SexpNode | None:
"""Find the first direct child with a matching tag."""
for c in node.children:
if c.tag == tag:
return c
return None
def find_named(node: SexpNode, tag: str, name: str) -> SexpNode | None:
"""Find ``(tag "name" ...)`` among direct children. Exact match only."""
for c in node.children:
if c.tag == tag and c.values and c.values[0] == name:
return c
return None
def get_values(node: SexpNode, tag: str) -> list[str] | None:
"""Get the values list from a direct child with the given tag.
Returns None if no child with that tag exists.
"""
child = find_one(node, tag)
if child is None:
return None
return child.values
def text_at(source: str, span: tuple[int, int]) -> str:
"""Extract the source text covered by a node's span."""
return source[span[0] : span[1]]
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
# Regex for values that should remain unquoted (atoms)
_ATOM_RE = re.compile(r"^-?\d+\.?\d*$")
# Bare keywords that KiCad writes unquoted
_BARE_KEYWORDS = frozenset({
"yes", "no", "none",
"line", "passive", "input", "output", "bidirectional",
"tri_state", "power_in", "power_out", "open_collector",
"open_emitter", "unconnected", "free", "unspecified",
"default", "solid", "dash", "dot", "dash_dot", "dash_dot_dot",
"left", "right", "top", "bottom", "mirror",
"hide", "inverted", "clock", "inverted_clock",
"input_low", "clock_low", "output_low", "edge_clock_high",
"non_logic",
"background", "foreground",
"x", "y",
})
def _quote_value(value: str) -> str:
"""Wrap a string in double quotes, escaping internal quotes/backslashes."""
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _format_value(value: str, is_quoted: bool) -> str:
"""Format a single value for serialization."""
if is_quoted:
return _quote_value(value)
return value
def _is_inline(node: SexpNode) -> bool:
"""Determine if a node should be serialized on a single line.
Inline when: no children, or a single-child chain (each node has at most
one child) that terminates in a leaf. This matches KiCad's style of
writing ``(effects (font (size 1.27 1.27)))`` on one line.
"""
if not node.children:
return True
if len(node.children) == 1:
return _is_inline(node.children[0])
return False
def serialize(node: SexpNode, indent: int = 0) -> str:
"""Serialize a SexpNode tree back to KiCad s-expression text.
Reproduces the original quoting for parsed nodes (via ``_quoted``).
Uses KiCad-style 2-space indentation for multi-child nodes.
"""
parts: list[str] = []
parts.append(f"({node.tag}")
# Values
for i, val in enumerate(node.values):
is_q = node._quoted[i] if i < len(node._quoted) else True
parts.append(f" {_format_value(val, is_q)}")
if not node.children:
parts.append(")")
return "".join(parts)
if _is_inline(node):
# Single-line: (tag vals (child vals))
for child in node.children:
parts.append(f" {serialize(child, 0)}")
parts.append(")")
return "".join(parts)
# Multi-line
parts.append("\n")
child_indent = indent + 2
prefix = " " * child_indent
for child in node.children:
parts.append(f"{prefix}{serialize(child, child_indent)}\n")
parts.append(f"{' ' * indent})")
return "".join(parts)
def serialize_file(node: SexpNode, filepath: str) -> None:
"""Serialize a tree to a file using atomic write (tempfile + rename)."""
text = serialize(node, indent=0) + "\n"
dirpath = os.path.dirname(os.path.abspath(filepath))
fd, tmp_path = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, filepath)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
# ---------------------------------------------------------------------------
# Node builders
# ---------------------------------------------------------------------------
def _should_quote(value: str) -> bool:
"""Auto-quoting heuristic: returns True if value should be quoted."""
if _ATOM_RE.match(value):
return False
return value not in _BARE_KEYWORDS
def make_node(
tag: str,
values: list[str] | None = None,
children: list[SexpNode] | None = None,
) -> SexpNode:
"""Build a SexpNode with auto-quoting heuristic for values.
Values that look numeric or are bare KiCad keywords get ``_quoted=False``.
Everything else gets ``_quoted=True``.
"""
vals = values or []
quoted = [_should_quote(v) for v in vals]
return SexpNode(
tag=tag,
values=vals,
children=children or [],
_quoted=quoted,
)
def make_atom(tag: str, *values: str) -> SexpNode:
"""Build a node where all values are unquoted atoms.
Convenience for ``(tag val1 val2)`` style nodes like ``(at 1.27 2.54)``.
"""
return SexpNode(
tag=tag,
values=list(values),
_quoted=[False] * len(values),
)
def make_string(tag: str, *values: str) -> SexpNode:
"""Build a node where all values are quoted strings.
Convenience for ``(tag "val1" "val2")`` style nodes.
"""
return SexpNode(
tag=tag,
values=list(values),
_quoted=[True] * len(values),
)
def deep_copy(node: SexpNode) -> SexpNode:
"""Deep copy a SexpNode tree, preserving ``_quoted`` metadata."""
return copy.deepcopy(node)

View File

@ -6,7 +6,10 @@ import tempfile
import pytest import pytest
from mckicad.utils.sch_document import SchDocument
# Detect whether kicad-sch-api is available for conditional tests # Detect whether kicad-sch-api is available for conditional tests
# (only needed for legacy tests that directly call kicad-sch-api functions)
_HAS_SCH_API = False _HAS_SCH_API = False
try: try:
from kicad_sch_api import create_schematic, load_schematic # noqa: F401 from kicad_sch_api import create_schematic, load_schematic # noqa: F401
@ -57,14 +60,10 @@ def tmp_output_dir():
def populated_schematic(tmp_output_dir): def populated_schematic(tmp_output_dir):
"""Create a schematic with components for testing edit/analysis tools. """Create a schematic with components for testing edit/analysis tools.
Returns the path to the .kicad_sch file, or None if kicad-sch-api Returns the path to the .kicad_sch file.
is not installed.
""" """
if not _HAS_SCH_API:
pytest.skip("kicad-sch-api not installed")
path = os.path.join(tmp_output_dir, "populated.kicad_sch") path = os.path.join(tmp_output_dir, "populated.kicad_sch")
sch = create_schematic("test_populated") sch = SchDocument.create(path)
# Add several components # Add several components
sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100))
@ -87,11 +86,8 @@ def populated_schematic_with_ic(tmp_output_dir):
2-pin passives have predictable pin layouts suitable for testing 2-pin passives have predictable pin layouts suitable for testing
power symbol attachment and pattern placement. power symbol attachment and pattern placement.
""" """
if not _HAS_SCH_API:
pytest.skip("kicad-sch-api not installed")
path = os.path.join(tmp_output_dir, "ic_test.kicad_sch") path = os.path.join(tmp_output_dir, "ic_test.kicad_sch")
sch = create_schematic("ic_test") sch = SchDocument.create(path)
sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100))
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(200, 100)) sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(200, 100))

View File

@ -5,7 +5,6 @@ import os
import pytest import pytest
from mckicad.autowire.planner import generate_batch_plan
from mckicad.autowire.strategy import ( from mckicad.autowire.strategy import (
NetPlan, NetPlan,
PinInfo, PinInfo,
@ -17,7 +16,10 @@ from mckicad.autowire.strategy import (
estimate_crossings, estimate_crossings,
pin_distance, pin_distance,
) )
from tests.conftest import requires_sch_api from mckicad.autowire.planner import generate_batch_plan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Strategy unit tests # Strategy unit tests
@ -344,7 +346,6 @@ class TestBatchPlanGeneration:
class TestAutowireTool: class TestAutowireTool:
@requires_sch_api
def test_dry_run_returns_plan(self, populated_schematic): def test_dry_run_returns_plan(self, populated_schematic):
from mckicad.tools.autowire import autowire_schematic from mckicad.tools.autowire import autowire_schematic
@ -361,7 +362,6 @@ class TestAutowireTool:
batch_data = json.load(f) batch_data = json.load(f)
assert isinstance(batch_data, dict) assert isinstance(batch_data, dict)
@requires_sch_api
def test_dry_run_does_not_modify(self, populated_schematic): def test_dry_run_does_not_modify(self, populated_schematic):
"""dry_run=True should not change the schematic file.""" """dry_run=True should not change the schematic file."""
with open(populated_schematic) as f: with open(populated_schematic) as f:
@ -376,14 +376,12 @@ class TestAutowireTool:
assert original_content == after_content assert original_content == after_content
@requires_sch_api
def test_invalid_schematic_path(self): def test_invalid_schematic_path(self):
from mckicad.tools.autowire import autowire_schematic from mckicad.tools.autowire import autowire_schematic
result = autowire_schematic("/nonexistent/path.kicad_sch") result = autowire_schematic("/nonexistent/path.kicad_sch")
assert result["success"] is False assert result["success"] is False
@requires_sch_api
def test_invalid_extension(self): def test_invalid_extension(self):
from mckicad.tools.autowire import autowire_schematic from mckicad.tools.autowire import autowire_schematic

View File

@ -6,11 +6,9 @@ import re
import pytest import pytest
from tests.conftest import requires_sch_api
class TestBatchValidation: class TestBatchValidation:
"""Tests for batch JSON validation (no kicad-sch-api needed for some).""" """Tests for batch JSON validation."""
def test_bad_json_file(self, tmp_output_dir): def test_bad_json_file(self, tmp_output_dir):
from mckicad.tools.batch import apply_batch from mckicad.tools.batch import apply_batch
@ -59,7 +57,6 @@ class TestBatchValidation:
assert result["success"] is False assert result["success"] is False
@requires_sch_api
class TestBatchDryRun: class TestBatchDryRun:
"""Tests for batch dry_run mode.""" """Tests for batch dry_run mode."""
@ -115,14 +112,12 @@ class TestBatchDryRun:
assert result["success"] is False assert result["success"] is False
@requires_sch_api
class TestBatchApply: class TestBatchApply:
"""Integration tests for applying batch operations.""" """Integration tests for applying batch operations."""
def test_apply_components_and_wires(self, populated_schematic_with_ic, batch_json_file): def test_apply_components_and_wires(self, populated_schematic_with_ic, batch_json_file):
from kicad_sch_api import load_schematic
from mckicad.tools.batch import apply_batch from mckicad.tools.batch import apply_batch
from mckicad.utils.sch_helpers import load_schematic
result = apply_batch( result = apply_batch(
schematic_path=populated_schematic_with_ic, schematic_path=populated_schematic_with_ic,
@ -181,7 +176,7 @@ class TestBatchApply:
with open(sidecar_path, "w") as f: with open(sidecar_path, "w") as f:
json.dump(data, f) json.dump(data, f)
# Use relative path should find it in .mckicad/ # Use relative path -- should find it in .mckicad/
result = apply_batch( result = apply_batch(
schematic_path=populated_schematic_with_ic, schematic_path=populated_schematic_with_ic,
batch_file="sidecar_batch.json", batch_file="sidecar_batch.json",
@ -219,7 +214,6 @@ class TestBatchApply:
assert '(global_label "BATCH_GLOBAL"' in content assert '(global_label "BATCH_GLOBAL"' in content
@requires_sch_api
class TestBatchPinRefLabels: class TestBatchPinRefLabels:
"""Tests for pin-referenced label placement in batch operations.""" """Tests for pin-referenced label placement in batch operations."""
@ -324,7 +318,6 @@ class TestBatchPinRefLabels:
assert "(wire\n" in content assert "(wire\n" in content
@requires_sch_api
class TestBatchLabelConnections: class TestBatchLabelConnections:
"""Tests for label_connections batch operations.""" """Tests for label_connections batch operations."""
@ -422,8 +415,6 @@ class TestBatchLabelConnections:
content = f.read() content = f.read()
# Two labels with same text # Two labels with same text
import re
matches = re.findall(r'\(global_label "MULTI_PIN_NET"', content) matches = re.findall(r'\(global_label "MULTI_PIN_NET"', content)
assert len(matches) == 2 assert len(matches) == 2
@ -432,7 +423,6 @@ class TestBatchLabelConnections:
assert len(wire_matches) >= 2 assert len(wire_matches) >= 2
@requires_sch_api
class TestBatchLabelOffset: class TestBatchLabelOffset:
"""Tests for label auto-offset and direction override.""" """Tests for label auto-offset and direction override."""
@ -464,20 +454,23 @@ class TestBatchLabelOffset:
content = f.read() content = f.read()
# Wire stub should be 7.62mm long (3 grid units), not 2.54mm # Wire stub should be 7.62mm long (3 grid units), not 2.54mm
# Check that xy coordinates in the wire span ~7.62mm # sexp_tree serializer puts each (xy ...) on its own line,
wire_matches = re.findall( # so we extract pairs of consecutive xy nodes within (pts ...)
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content, xy_matches = re.findall(
r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
) )
assert len(wire_matches) >= 1 # Process consecutive xy pairs (each wire has 2 xy nodes)
for x1, y1, x2, y2 in wire_matches: found_stub = False
dx = abs(float(x2) - float(x1)) for i in range(0, len(xy_matches) - 1, 2):
dy = abs(float(y2) - float(y1)) x1, y1 = float(xy_matches[i][0]), float(xy_matches[i][1])
x2, y2 = float(xy_matches[i + 1][0]), float(xy_matches[i + 1][1])
dx = abs(x2 - x1)
dy = abs(y2 - y1)
length = (dx**2 + dy**2) ** 0.5 length = (dx**2 + dy**2) ** 0.5
# At least one stub should be ~7.62mm
if abs(length - 7.62) < 0.5: if abs(length - 7.62) < 0.5:
found_stub = True
break break
else: assert found_stub, "No wire stub found with ~7.62mm length"
pytest.fail("No wire stub found with ~7.62mm length")
def test_custom_stub_length_per_connection( def test_custom_stub_length_per_connection(
self, populated_schematic_with_ic, tmp_output_dir, self, populated_schematic_with_ic, tmp_output_dir,
@ -508,18 +501,20 @@ class TestBatchLabelOffset:
with open(populated_schematic_with_ic) as f: with open(populated_schematic_with_ic) as f:
content = f.read() content = f.read()
wire_matches = re.findall( xy_matches = re.findall(
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content, r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
) )
assert len(wire_matches) >= 1 found_stub = False
for x1, y1, x2, y2 in wire_matches: for i in range(0, len(xy_matches) - 1, 2):
dx = abs(float(x2) - float(x1)) x1, y1 = float(xy_matches[i][0]), float(xy_matches[i][1])
dy = abs(float(y2) - float(y1)) x2, y2 = float(xy_matches[i + 1][0]), float(xy_matches[i + 1][1])
dx = abs(x2 - x1)
dy = abs(y2 - y1)
length = (dx**2 + dy**2) ** 0.5 length = (dx**2 + dy**2) ** 0.5
if abs(length - 12.7) < 0.5: if abs(length - 12.7) < 0.5:
found_stub = True
break break
else: assert found_stub, "No wire stub found with ~12.7mm length"
pytest.fail("No wire stub found with ~12.7mm length")
def test_direction_override( def test_direction_override(
self, populated_schematic_with_ic, tmp_output_dir, self, populated_schematic_with_ic, tmp_output_dir,
@ -550,7 +545,6 @@ class TestBatchLabelOffset:
assert result["labels_placed"] >= 1 assert result["labels_placed"] >= 1
@requires_sch_api
class TestBatchPinRefNoConnects: class TestBatchPinRefNoConnects:
"""Tests for pin-referenced no_connect placement in batch operations.""" """Tests for pin-referenced no_connect placement in batch operations."""
@ -626,7 +620,6 @@ class TestBatchPinRefNoConnects:
assert any("pin-reference" in e or "coordinate" in e for e in result["validation_errors"]) assert any("pin-reference" in e or "coordinate" in e for e in result["validation_errors"])
@requires_sch_api
class TestBatchHierarchyContext: class TestBatchHierarchyContext:
"""Tests for hierarchy context in batch operations.""" """Tests for hierarchy context in batch operations."""
@ -634,8 +627,6 @@ class TestBatchHierarchyContext:
self, populated_schematic_with_ic, tmp_output_dir, self, populated_schematic_with_ic, tmp_output_dir,
): ):
"""Passing parent_uuid and sheet_uuid sets hierarchy context on the schematic.""" """Passing parent_uuid and sheet_uuid sets hierarchy context on the schematic."""
from unittest.mock import patch
from mckicad.tools.batch import apply_batch from mckicad.tools.batch import apply_batch
data = { data = {
@ -647,23 +638,21 @@ class TestBatchHierarchyContext:
with open(batch_path, "w") as f: with open(batch_path, "w") as f:
json.dump(data, f) json.dump(data, f)
with patch("mckicad.tools.batch._ksa_load") as mock_load: result = apply_batch(
# Let the real load happen but spy on set_hierarchy_context schematic_path=populated_schematic_with_ic,
from kicad_sch_api import load_schematic batch_file=batch_path,
parent_uuid="aaaa-bbbb-cccc",
sheet_uuid="dddd-eeee-ffff",
)
real_sch = load_schematic(populated_schematic_with_ic) assert result["success"] is True
mock_load.return_value = real_sch
result = apply_batch( # Verify the hierarchy path was written to the file
schematic_path=populated_schematic_with_ic, with open(populated_schematic_with_ic) as f:
batch_file=batch_path, content = f.read()
parent_uuid="aaaa-bbbb-cccc",
sheet_uuid="dddd-eeee-ffff",
)
assert result["success"] is True assert "sheet_instances" in content
# Verify the hierarchy path was set assert "/aaaa-bbbb-cccc/dddd-eeee-ffff" in content
assert real_sch._hierarchy_path == "/aaaa-bbbb-cccc/dddd-eeee-ffff"
def test_no_hierarchy_context_without_params( def test_no_hierarchy_context_without_params(
self, populated_schematic_with_ic, tmp_output_dir, self, populated_schematic_with_ic, tmp_output_dir,
@ -688,93 +677,6 @@ class TestBatchHierarchyContext:
assert result["success"] is True assert result["success"] is True
@pytest.mark.unit
class TestRegisterProjectLibraries:
"""Tests for project-local library registration in apply_batch."""
def test_registers_unknown_library_from_sym_lib_table(self, tmp_path):
"""Libraries listed in sym-lib-table are registered with the cache."""
from mckicad.tools.batch import _register_project_libraries
# Create a minimal .kicad_sym file
lib_dir = tmp_path / "libs"
lib_dir.mkdir()
kicad_sym = lib_dir / "CustomLib.kicad_sym"
kicad_sym.write_text(
'(kicad_symbol_lib (version 20211014) (generator test)\n'
' (symbol "MyPart" (in_bom yes) (on_board yes)\n'
' (property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))\n'
' (property "Value" "MyPart" (at 0 0 0) (effects (font (size 1.27 1.27))))\n'
' )\n'
')\n'
)
# Create a sym-lib-table that references it
sym_lib_table = tmp_path / "sym-lib-table"
sym_lib_table.write_text(
'(sym_lib_table\n'
' (version 7)\n'
' (lib (name "CustomLib")(type "KiCad")(uri "${KIPRJMOD}/libs/CustomLib.kicad_sym")(options "")(descr "Test lib"))\n'
')\n'
)
# Create a minimal .kicad_pro so _find_project_root works
kicad_pro = tmp_path / "test.kicad_pro"
kicad_pro.write_text("{}")
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "CustomLib:MyPart", "reference": "U1", "value": "MyPart", "x": 100, "y": 100},
],
}
registered = _register_project_libraries(data, sch_path)
assert "CustomLib" in registered
def test_skips_already_known_library(self, tmp_path):
"""Standard libraries (Device, etc.) are not re-registered."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "Device:R", "reference": "R1", "value": "10k", "x": 100, "y": 100},
],
}
# Device is already in the cache from /usr/share/kicad/symbols/
registered = _register_project_libraries(data, sch_path)
assert "Device" not in registered
def test_no_components_returns_empty(self, tmp_path):
"""Batch with no components produces no registrations."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {"labels": [{"text": "NET1", "x": 0, "y": 0}]}
registered = _register_project_libraries(data, sch_path)
assert registered == []
def test_missing_library_file_not_registered(self, tmp_path):
"""Non-existent library file returns empty (no crash)."""
from mckicad.tools.batch import _register_project_libraries
sch_path = str(tmp_path / "test.kicad_sch")
data = {
"components": [
{"lib_id": "NonExistentLib:FakePart", "reference": "U1", "value": "X", "x": 0, "y": 0},
],
}
registered = _register_project_libraries(data, sch_path)
assert registered == []
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestMultiUnitComponents: class TestMultiUnitComponents:
"""Tests for multi-unit component placement in apply_batch.""" """Tests for multi-unit component placement in apply_batch."""

458
tests/test_lib_resolver.py Normal file
View File

@ -0,0 +1,458 @@
"""Tests for unified symbol library resolution."""
import os
import time
import pytest
from mckicad.utils.lib_resolver import (
_lib_table_cache,
extract_pin_unit_map,
extract_symbol_pins,
extract_symbol_sexp_text,
get_kicad_symbol_dir,
load_sym_lib_table,
resolve_library_path,
resolve_symbol,
)
from mckicad.utils.sexp_tree import find_recursive, parse
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
DEVICE_LIB = "/usr/share/kicad/symbols/Device.kicad_sym"
GLOBAL_SYM_LIB_TABLE_9 = os.path.expanduser("~/.config/kicad/9.0/sym-lib-table")
GLOBAL_SYM_LIB_TABLE_8 = os.path.expanduser("~/.config/kicad/8.0/sym-lib-table")
has_kicad = os.path.isfile(DEVICE_LIB)
has_global_table_9 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_9)
has_global_table_8 = os.path.isfile(GLOBAL_SYM_LIB_TABLE_8)
has_global_table = has_global_table_9 or has_global_table_8
requires_kicad = pytest.mark.skipif(not has_kicad, reason="KiCad not installed")
requires_global_table = pytest.mark.skipif(
not has_global_table, reason="No global sym-lib-table",
)
@pytest.fixture
def project_with_lib(tmp_path):
"""Create a project directory with a custom symbol library."""
# Create a minimal .kicad_sym file
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "MyResistor"
(symbol "MyResistor_1_1"
(pin passive line
(at 0 2.54 270)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -2.54 90)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "MyLib.kicad_sym"
lib_file.write_text(lib_content)
# Create sym-lib-table pointing to it
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
f'(uri "${{KIPRJMOD}}/MyLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_file = tmp_path / "sym-lib-table"
table_file.write_text(table_content)
# Create .kicad_pro so it looks like a project
pro_file = tmp_path / "test.kicad_pro"
pro_file.write_text('{"meta": {}}')
return tmp_path
@pytest.fixture
def multi_unit_lib(tmp_path):
"""Create a project with a multi-unit symbol (like TL072)."""
lib_content = """\
(kicad_symbol_lib
(version 20241209)
(symbol "TL072"
(symbol "TL072_1_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "3" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "TL072_2_1"
(pin output line
(at 5.08 0 180)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "7" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "5" (effects (font (size 1.27 1.27))))
)
(pin input line
(at -5.08 -2.54 0)
(length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "6" (effects (font (size 1.27 1.27))))
)
)
)
)"""
lib_file = tmp_path / "OpAmps.kicad_sym"
lib_file.write_text(lib_content)
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "OpAmps")(type "KiCad")'
f'(uri "${{KIPRJMOD}}/OpAmps.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
(tmp_path / "test.kicad_pro").write_text('{"meta": {}}')
return tmp_path
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear the lib table cache between tests."""
_lib_table_cache.clear()
yield
_lib_table_cache.clear()
# ---------------------------------------------------------------------------
# resolve_library_path
# ---------------------------------------------------------------------------
@requires_global_table
class TestResolveLibraryPathGlobalTable:
def test_finds_device_lib(self):
path = resolve_library_path("Device")
assert path is not None
assert path.endswith("Device.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathProjectTable:
def test_finds_project_lib(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
assert path.endswith("MyLib.kicad_sym")
assert os.path.isfile(path)
class TestResolveLibraryPathPrecedence:
@requires_global_table
def test_project_overrides_global(self, tmp_path):
"""If both project and global define a library, project wins."""
# Create a project-level Device.kicad_sym
project_lib = tmp_path / "Device.kicad_sym"
project_lib.write_text("(kicad_symbol_lib (version 20241209))")
table_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "Device")(type "KiCad")'
'(uri "${KIPRJMOD}/Device.kicad_sym")(options "")(descr ""))\n'
")\n"
)
(tmp_path / "sym-lib-table").write_text(table_content)
path = resolve_library_path("Device", project_dir=str(tmp_path))
assert path is not None
# Should resolve to the project-local one, not /usr/share/kicad/...
assert str(tmp_path) in path
class TestResolveLibraryPathDirectFallback:
def test_finds_lib_in_same_dir(self, tmp_path):
"""Falls back to directory search when no sym-lib-table."""
lib_file = tmp_path / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
assert path.endswith("CustomLib.kicad_sym")
def test_finds_lib_in_libs_subdir(self, tmp_path):
libs_dir = tmp_path / "libs"
libs_dir.mkdir()
lib_file = libs_dir / "CustomLib.kicad_sym"
lib_file.write_text("(kicad_symbol_lib)")
path = resolve_library_path("CustomLib", project_dir=str(tmp_path))
assert path is not None
def test_nonexistent_library(self, tmp_path):
path = resolve_library_path("NoSuchLib", project_dir=str(tmp_path))
assert path is None
# ---------------------------------------------------------------------------
# resolve_symbol
# ---------------------------------------------------------------------------
@requires_kicad
@requires_global_table
class TestResolveSymbolDeviceR:
def test_resolves_resistor(self):
sym = resolve_symbol("Device:R")
assert sym is not None
assert sym.tag == "symbol"
assert sym.values[0] == "R"
pins = find_recursive(sym, "pin")
assert len(pins) == 2
class TestResolveSymbolProjectLib:
def test_resolves_from_project(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
assert sym.values[0] == "MyResistor"
class TestResolveSymbolEdgeCases:
def test_nonexistent_library(self):
assert resolve_symbol("NoSuchLib:NoSuchSymbol") is None
def test_nonexistent_symbol_in_library(self, project_with_lib):
assert resolve_symbol(
"MyLib:NoSuchSymbol", project_dir=str(project_with_lib),
) is None
def test_invalid_lib_id_no_colon(self):
assert resolve_symbol("JustAName") is None
def test_invalid_lib_id_empty_parts(self):
assert resolve_symbol(":Symbol") is None
assert resolve_symbol("Lib:") is None
# ---------------------------------------------------------------------------
# extract_symbol_pins
# ---------------------------------------------------------------------------
class TestExtractSymbolPinsFormat:
def test_pin_dict_format(self, project_with_lib):
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
pins = extract_symbol_pins(sym)
assert len(pins) == 2
for pin in pins:
assert "number" in pin
assert "name" in pin
assert "type" in pin
assert "shape" in pin
assert "x" in pin
assert "y" in pin
assert "rotation" in pin
assert "length" in pin
# Check specific values
pin_numbers = {p["number"] for p in pins}
assert pin_numbers == {"1", "2"}
pin1 = next(p for p in pins if p["number"] == "1")
assert pin1["type"] == "passive"
assert pin1["shape"] == "line"
assert pin1["x"] == 0.0
assert pin1["y"] == 2.54
assert pin1["rotation"] == 270.0
assert pin1["length"] == 1.27
# ---------------------------------------------------------------------------
# extract_pin_unit_map
# ---------------------------------------------------------------------------
class TestExtractPinUnitMap:
def test_multi_unit_mapping(self, multi_unit_lib):
sym = resolve_symbol("OpAmps:TL072", project_dir=str(multi_unit_lib))
assert sym is not None
pin_map = extract_pin_unit_map(sym)
# Unit 1 pins: 1, 2, 3
assert pin_map["1"] == 1
assert pin_map["2"] == 1
assert pin_map["3"] == 1
# Unit 2 pins: 5, 6, 7
assert pin_map["5"] == 2
assert pin_map["6"] == 2
assert pin_map["7"] == 2
def test_single_unit_empty_map(self, project_with_lib):
"""Single-unit symbols don't have meaningful sub-symbol structure."""
sym = resolve_symbol("MyLib:MyResistor", project_dir=str(project_with_lib))
assert sym is not None
# MyResistor only has _1_1 sub-symbol, so all pins map to unit 1
pin_map = extract_pin_unit_map(sym)
# For a single-unit symbol, having a map is fine (unit 1)
if pin_map:
assert all(v == 1 for v in pin_map.values())
# ---------------------------------------------------------------------------
# extract_symbol_sexp_text
# ---------------------------------------------------------------------------
class TestExtractSymbolSexpText:
def test_returns_raw_text(self, project_with_lib):
text = extract_symbol_sexp_text(
"MyLib:MyResistor", project_dir=str(project_with_lib),
)
assert text is not None
assert text.startswith('(symbol "MyResistor"')
assert text.endswith(")")
# Should be parseable
node = parse(text)
assert node.tag == "symbol"
def test_nonexistent_returns_none(self):
assert extract_symbol_sexp_text("NoLib:NoSym") is None
# ---------------------------------------------------------------------------
# get_kicad_symbol_dir
# ---------------------------------------------------------------------------
class TestGetKicadSymbolDir:
@requires_kicad
def test_detected(self):
sym_dir = get_kicad_symbol_dir()
assert sym_dir is not None
assert os.path.isdir(sym_dir)
assert os.path.isfile(os.path.join(sym_dir, "Device.kicad_sym"))
def test_env_override(self, tmp_path, monkeypatch):
monkeypatch.setenv("KICAD9_SYMBOL_DIR", str(tmp_path))
sym_dir = get_kicad_symbol_dir()
assert sym_dir == str(tmp_path)
# ---------------------------------------------------------------------------
# load_sym_lib_table
# ---------------------------------------------------------------------------
class TestLoadSymLibTable:
def test_parses_project_table(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result = load_sym_lib_table(table_path)
assert "MyLib" in result
assert "${KIPRJMOD}/MyLib.kicad_sym" in result["MyLib"]
@requires_global_table
def test_parses_global_table(self):
table_path = GLOBAL_SYM_LIB_TABLE_9 if has_global_table_9 else GLOBAL_SYM_LIB_TABLE_8
result = load_sym_lib_table(table_path)
assert "Device" in result
def test_nonexistent_file(self):
result = load_sym_lib_table("/no/such/file")
assert result == {}
# ---------------------------------------------------------------------------
# Variable substitution
# ---------------------------------------------------------------------------
class TestVariableSubstitution:
def test_kiprjmod(self, project_with_lib):
path = resolve_library_path("MyLib", project_dir=str(project_with_lib))
assert path is not None
# Should NOT contain ${KIPRJMOD} — it should be resolved
assert "${KIPRJMOD}" not in path
assert str(project_with_lib) in path
@requires_global_table
def test_kicad9_symbol_dir(self):
"""Global table uses ${KICAD9_SYMBOL_DIR} which should be resolved."""
path = resolve_library_path("Device")
assert path is not None
assert "${KICAD9_SYMBOL_DIR}" not in path
assert "${KICAD8_SYMBOL_DIR}" not in path
# ---------------------------------------------------------------------------
# Cache invalidation
# ---------------------------------------------------------------------------
class TestCacheInvalidation:
def test_cache_hit(self, project_with_lib):
table_path = str(project_with_lib / "sym-lib-table")
result1 = load_sym_lib_table(table_path)
result2 = load_sym_lib_table(table_path)
assert result1 == result2
# Should be the same dict object (cache hit)
assert result1 is result2
def test_cache_invalidation_on_mtime(self, project_with_lib):
table_path = project_with_lib / "sym-lib-table"
str_path = str(table_path)
result1 = load_sym_lib_table(str_path)
assert "MyLib" in result1
# Modify the file (add another library)
time.sleep(0.05) # ensure mtime changes
new_content = (
"(sym_lib_table\n"
" (version 7)\n"
' (lib (name "MyLib")(type "KiCad")'
'(uri "${KIPRJMOD}/MyLib.kicad_sym")(options "")(descr ""))\n'
' (lib (name "NewLib")(type "KiCad")'
'(uri "${KIPRJMOD}/NewLib.kicad_sym")(options "")(descr ""))\n'
")\n"
)
table_path.write_text(new_content)
result2 = load_sym_lib_table(str_path)
assert "NewLib" in result2
# Should NOT be the same object (cache was invalidated)
assert result1 is not result2

View File

@ -188,7 +188,7 @@ class TestDecouplingBankMCPTool:
assert result["success"] is True assert result["success"] is True
assert result["cap_count"] == 3 assert result["cap_count"] == 3
assert result["engine"] == "kicad-sch-api" assert result["engine"] == "sch_document"
def test_empty_values_rejected(self, populated_schematic_with_ic): def test_empty_values_rejected(self, populated_schematic_with_ic):
from mckicad.tools.schematic_patterns import place_decoupling_bank_pattern from mckicad.tools.schematic_patterns import place_decoupling_bank_pattern

View File

@ -154,7 +154,7 @@ class TestAddPowerSymbolTool:
assert result["target_component"] == "R1" assert result["target_component"] == "R1"
assert result["target_pin"] == "2" assert result["target_pin"] == "2"
assert result["reference"].startswith("#PWR") assert result["reference"].startswith("#PWR")
assert result["engine"] == "kicad-sch-api" assert result["engine"] == "sch_document"
def test_add_vcc_to_cap_pin(self, populated_schematic_with_ic): def test_add_vcc_to_cap_pin(self, populated_schematic_with_ic):
from mckicad.tools.power_symbols import add_power_symbol from mckicad.tools.power_symbols import add_power_symbol

1582
tests/test_sch_document.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,6 @@ import os
import pytest import pytest
from tests.conftest import requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_create_schematic(tmp_output_dir): def test_create_schematic(tmp_output_dir):
"""create_schematic should produce a .kicad_sch file.""" """create_schematic should produce a .kicad_sch file."""
@ -50,7 +47,6 @@ def test_list_components_empty_schematic(tmp_output_dir):
assert result.get("count", 0) == 0 assert result.get("count", 0) == 0
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_list_components_single_lookup(populated_schematic): def test_list_components_single_lookup(populated_schematic):
"""list_components with reference param should return one component.""" """list_components with reference param should return one component."""
@ -62,7 +58,6 @@ def test_list_components_single_lookup(populated_schematic):
assert result["components"][0]["reference"] == "R1" assert result["components"][0]["reference"] == "R1"
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_list_components_single_lookup_not_found(populated_schematic): def test_list_components_single_lookup_not_found(populated_schematic):
"""list_components with nonexistent reference should fail.""" """list_components with nonexistent reference should fail."""
@ -72,7 +67,6 @@ def test_list_components_single_lookup_not_found(populated_schematic):
assert result["success"] is False assert result["success"] is False
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_get_schematic_info_compact(populated_schematic): def test_get_schematic_info_compact(populated_schematic):
"""get_schematic_info should return compact output.""" """get_schematic_info should return compact output."""
@ -85,7 +79,6 @@ def test_get_schematic_info_compact(populated_schematic):
assert "unique_symbol_count" in result assert "unique_symbol_count" in result
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_get_component_detail(populated_schematic): def test_get_component_detail(populated_schematic):
"""get_component_detail should return full info for one component.""" """get_component_detail should return full info for one component."""
@ -97,7 +90,6 @@ def test_get_component_detail(populated_schematic):
assert result["component"]["lib_id"] == "Device:R" assert result["component"]["lib_id"] == "Device:R"
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_get_component_detail_not_found(populated_schematic): def test_get_component_detail_not_found(populated_schematic):
"""get_component_detail for missing component should fail.""" """get_component_detail for missing component should fail."""
@ -107,7 +99,6 @@ def test_get_component_detail_not_found(populated_schematic):
assert result["success"] is False assert result["success"] is False
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_get_schematic_hierarchy(populated_schematic): def test_get_schematic_hierarchy(populated_schematic):
"""get_schematic_hierarchy should return at least the root sheet.""" """get_schematic_hierarchy should return at least the root sheet."""
@ -119,7 +110,6 @@ def test_get_schematic_hierarchy(populated_schematic):
assert result["hierarchy"]["total_sheets"] >= 1 assert result["hierarchy"]["total_sheets"] >= 1
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
def test_add_hierarchical_sheet_returns_uuids(tmp_output_dir): def test_add_hierarchical_sheet_returns_uuids(tmp_output_dir):
"""add_hierarchical_sheet should return sheet_uuid and parent_uuid.""" """add_hierarchical_sheet should return sheet_uuid and parent_uuid."""
@ -213,12 +203,14 @@ class TestAddLabelPersistence:
result = add_label(schematic_path=path, text="SPI_CLK", x=100.0, y=200.0) result = add_label(schematic_path=path, text="SPI_CLK", x=100.0, y=200.0)
assert result["success"] is True assert result["success"] is True
assert result["label_type"] == "local" assert result["label_type"] == "local"
assert result["engine"] == "sexp-direct" assert result["engine"] == "sch_document"
with open(path) as f: with open(path) as f:
content = f.read() content = f.read()
assert '(label "SPI_CLK"' in content assert '(label "SPI_CLK"' in content
assert "(at 100 200 0)" in content # SchDocument serializes floats (100.0), old engine used ints (100)
assert "(at 100" in content
assert "200" in content
@pytest.mark.unit @pytest.mark.unit
def test_global_label_persists(self, tmp_output_dir): def test_global_label_persists(self, tmp_output_dir):

View File

@ -182,22 +182,14 @@ class TestErcJsonParsing:
from mckicad.tools.schematic_analysis import run_schematic_erc from mckicad.tools.schematic_analysis import run_schematic_erc
# Anchor the project in its own subdirectory. The autouse # Create a project structure with root + sub-sheet
# _set_test_search_paths fixture drops a second "test_project.kicad_pro" pro_file = tmp_path / "myproject.kicad_pro"
# into tmp_path; keeping our project one level down means the root walk
# finds *this* project's .kicad_pro and stops before it reaches (and is
# confused by) the sibling. Without this, resolution depends on
# os.listdir order, which differs between ext4 and APFS.
proj_dir = tmp_path / "myproject"
proj_dir.mkdir()
pro_file = proj_dir / "myproject.kicad_pro"
pro_file.write_text("{}") pro_file.write_text("{}")
root_sch = proj_dir / "myproject.kicad_sch" root_sch = tmp_path / "myproject.kicad_sch"
root_sch.write_text('(kicad_sch (version 20231120) (uuid "root"))\n') root_sch.write_text('(kicad_sch (version 20231120) (uuid "root"))\n')
sub_dir = proj_dir / "sub" sub_dir = tmp_path / "sub"
sub_dir.mkdir() sub_dir.mkdir()
sub_sch = sub_dir / "power.kicad_sch" sub_sch = sub_dir / "power.kicad_sch"
sub_sch.write_text('(kicad_sch (version 20231120) (uuid "sub"))\n') sub_sch.write_text('(kicad_sch (version 20231120) (uuid "sub"))\n')

View File

@ -5,10 +5,6 @@ import tempfile
import pytest import pytest
from tests.conftest import requires_sch_api
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestModifyComponent: class TestModifyComponent:
"""Tests for the modify_component tool.""" """Tests for the modify_component tool."""
@ -46,7 +42,6 @@ class TestModifyComponent:
assert result.get("modified") == [] assert result.get("modified") == []
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestRemoveComponent: class TestRemoveComponent:
"""Tests for the remove_component tool.""" """Tests for the remove_component tool."""
@ -71,7 +66,6 @@ class TestRemoveComponent:
assert result["success"] is False assert result["success"] is False
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestBackupSchematic: class TestBackupSchematic:
"""Tests for the backup_schematic tool.""" """Tests for the backup_schematic tool."""
@ -116,7 +110,6 @@ class TestValidation:
assert result["success"] is False assert result["success"] is False
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestSetTitleBlock: class TestSetTitleBlock:
"""Tests for the set_title_block tool.""" """Tests for the set_title_block tool."""
@ -150,7 +143,6 @@ class TestSetTitleBlock:
assert result.get("fields_set") == [] assert result.get("fields_set") == []
@requires_sch_api
@pytest.mark.unit @pytest.mark.unit
class TestAnnotationTools: class TestAnnotationTools:
"""Tests for add_text_annotation and add_no_connect.""" """Tests for add_text_annotation and add_no_connect."""

View File

@ -11,11 +11,6 @@ import pytest
from mckicad.utils.sexp_parser import ( from mckicad.utils.sexp_parser import (
clamp_stub_length, clamp_stub_length,
compute_label_placement, compute_label_placement,
fix_property_private_keywords,
generate_global_label_sexp,
generate_label_sexp,
generate_wire_sexp,
insert_sexp_before_close,
parse_global_labels, parse_global_labels,
parse_lib_file_symbol_pins, parse_lib_file_symbol_pins,
parse_lib_symbol_pin_units, parse_lib_symbol_pin_units,
@ -357,221 +352,6 @@ class TestResolveLabelCollision:
assert y == pytest.approx(50.0) assert y == pytest.approx(50.0)
class TestGenerateLabelSexp:
def test_basic_local_label(self):
sexp = generate_label_sexp("NET_A", 100.5, 200.25)
assert '(label "NET_A"' in sexp
assert "\t\t(at 100.5 200.25 0)" in sexp
assert "\t\t(uuid" in sexp
assert "\t\t(effects\n" in sexp
assert "\t\t\t(font\n" in sexp
assert "\t\t\t\t(size 1.27 1.27)" in sexp
assert "\t\t\t(justify left bottom)" in sexp
def test_custom_uuid(self):
sexp = generate_label_sexp("X", 0, 0, uuid_str="test-uuid-123")
assert '(uuid "test-uuid-123")' in sexp
def test_rotation(self):
sexp = generate_label_sexp("CLK", 50, 50, rotation=90)
assert "\t\t(at 50 50 90)" in sexp
def test_auto_uuid_is_unique(self):
sexp1 = generate_label_sexp("A", 0, 0)
sexp2 = generate_label_sexp("A", 0, 0)
# Extract UUIDs
import re
uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2)
assert len(uuids) == 2
assert uuids[0] != uuids[1]
def test_quote_escaping(self):
sexp = generate_label_sexp('NET"SPECIAL', 0, 0)
assert r'(label "NET\"SPECIAL"' in sexp
def test_backslash_escaping(self):
sexp = generate_label_sexp("NET\\PATH", 0, 0)
assert r'(label "NET\\PATH"' in sexp
class TestGenerateGlobalLabelSexp:
def test_basic_global_label(self):
sexp = generate_global_label_sexp("VBUS_OUT", 187.96, 114.3)
assert '(global_label "VBUS_OUT"' in sexp
assert "\t\t(shape bidirectional)" in sexp
assert "\t\t(at 187.96 114.3 0)" in sexp
assert "Intersheetrefs" in sexp
assert "${INTERSHEET_REFS}" in sexp
assert "\t\t\t(at 0 0 0)" in sexp
def test_custom_shape(self):
sexp = generate_global_label_sexp("CLK", 0, 0, shape="output")
assert "\t\t(shape output)" in sexp
def test_rotation(self):
sexp = generate_global_label_sexp("SIG", 10, 20, rotation=180)
assert "\t\t(at 10 20 180)" in sexp
def test_round_trip_parse(self, tmp_path):
"""Generated global label should be parseable by parse_global_labels."""
sexp = generate_global_label_sexp("TEST_NET", 123.45, 67.89)
# Wrap in a minimal schematic
content = f"(kicad_sch\n (version 20231120)\n{sexp})\n"
filepath = str(tmp_path / "test.kicad_sch")
with open(filepath, "w") as f:
f.write(content)
labels = parse_global_labels(filepath)
assert len(labels) == 1
assert labels[0]["text"] == "TEST_NET"
assert labels[0]["x"] == pytest.approx(123.45)
assert labels[0]["y"] == pytest.approx(67.89)
class TestInsertSexpBeforeClose:
def test_insert_into_minimal_schematic(self, tmp_path):
filepath = str(tmp_path / "test.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
insert_sexp_before_close(filepath, ' (label "X"\n (at 0 0 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(label "X"' in content
assert content.strip().endswith(")")
assert content.startswith("(kicad_sch")
def test_preserves_existing_content(self, tmp_path):
filepath = str(tmp_path / "test.kicad_sch")
original = '(kicad_sch\n (version 20231120)\n (uuid "abc")\n)\n'
with open(filepath, "w") as f:
f.write(original)
insert_sexp_before_close(filepath, ' (label "Y"\n (at 1 2 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(uuid "abc")' in content
assert '(label "Y"' in content
def test_rejects_non_kicad_file(self, tmp_path):
filepath = str(tmp_path / "bad.kicad_sch")
with open(filepath, "w") as f:
f.write("not a kicad file")
with pytest.raises(ValueError, match="Not a KiCad schematic"):
insert_sexp_before_close(filepath, "(label)")
def test_multiple_insertions(self, tmp_path):
filepath = str(tmp_path / "multi.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
insert_sexp_before_close(filepath, ' (label "A"\n (at 0 0 0)\n )\n')
insert_sexp_before_close(filepath, ' (label "B"\n (at 10 10 0)\n )\n')
with open(filepath) as f:
content = f.read()
assert '(label "A"' in content
assert '(label "B"' in content
def test_file_not_found(self, tmp_path):
with pytest.raises(FileNotFoundError):
insert_sexp_before_close(str(tmp_path / "missing.kicad_sch"), "(label)")
class TestFixPropertyPrivateKeywords:
"""Tests for repairing mis-serialized (property private ...) keywords."""
MALFORMED_CONTENT = """\
(kicad_sch
(version 20231120)
(lib_symbols
(symbol "Device:Crystal_GND24"
(property "private" "KLC_S3.3" The rectangle is not a symbol body but a graphical element
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(property "private" "KLC_S4.6" Pin placement follows symbol drawing
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
)
)
"""
CORRECT_CONTENT = """\
(kicad_sch
(version 20231120)
(lib_symbols
(symbol "Device:Crystal_GND24"
(property private "KLC_S3.3" "The rectangle is not a symbol body but a graphical element"
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(property private "KLC_S4.6" "Pin placement follows symbol drawing"
(at 0 0 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
)
)
)
"""
def test_fixes_malformed_properties(self, tmp_path):
filepath = str(tmp_path / "malformed.kicad_sch")
with open(filepath, "w") as f:
f.write(self.MALFORMED_CONTENT)
count = fix_property_private_keywords(filepath)
assert count == 2
with open(filepath) as f:
content = f.read()
assert '(property private "KLC_S3.3" "The rectangle' in content
assert '(property private "KLC_S4.6" "Pin placement' in content
assert '(property "private"' not in content
def test_no_changes_when_correct(self, tmp_path):
filepath = str(tmp_path / "correct.kicad_sch")
with open(filepath, "w") as f:
f.write(self.CORRECT_CONTENT)
count = fix_property_private_keywords(filepath)
assert count == 0
def test_no_changes_when_no_private(self, tmp_path):
filepath = str(tmp_path / "noprivate.kicad_sch")
with open(filepath, "w") as f:
f.write("(kicad_sch\n (version 20231120)\n)\n")
count = fix_property_private_keywords(filepath)
assert count == 0
def test_nonexistent_file_returns_zero(self):
count = fix_property_private_keywords("/nonexistent/path.kicad_sch")
assert count == 0
def test_preserves_surrounding_content(self, tmp_path):
filepath = str(tmp_path / "preserve.kicad_sch")
with open(filepath, "w") as f:
f.write(self.MALFORMED_CONTENT)
fix_property_private_keywords(filepath)
with open(filepath) as f:
content = f.read()
assert '(symbol "Device:Crystal_GND24"' in content
assert "(version 20231120)" in content
assert content.strip().endswith(")")
class TestResolvePinPosition: class TestResolvePinPosition:
"""Tests for resolve_pin_position (requires mocking sch object).""" """Tests for resolve_pin_position (requires mocking sch object)."""
@ -1184,50 +964,6 @@ class TestClampStubLength:
assert result == pytest.approx(3.73) # 5.0 - 1.27 assert result == pytest.approx(3.73) # 5.0 - 1.27
# ---------------------------------------------------------------------------
# generate_wire_sexp tests
# ---------------------------------------------------------------------------
class TestGenerateWireSexp:
def test_basic_wire_sexp(self):
sexp = generate_wire_sexp(100.0, 200.0, 110.5, 200.0)
assert "\t(wire\n" in sexp
assert "\t\t(pts\n" in sexp
assert "(xy 100 200) (xy 110.5 200)" in sexp
assert "\t\t(stroke\n" in sexp
assert "\t\t\t(width 0)" in sexp
assert "\t\t\t(type default)" in sexp
assert "\t\t(uuid" in sexp
def test_custom_uuid(self):
sexp = generate_wire_sexp(0, 0, 10, 10, uuid_str="test-wire-uuid")
assert '(uuid "test-wire-uuid")' in sexp
def test_auto_uuid_is_unique(self):
import re
sexp1 = generate_wire_sexp(0, 0, 10, 10)
sexp2 = generate_wire_sexp(0, 0, 10, 10)
uuids = re.findall(r'\(uuid "([^"]+)"\)', sexp1 + sexp2)
assert len(uuids) == 2
assert uuids[0] != uuids[1]
def test_round_trip_parse(self, tmp_path):
"""Generated wire should be parseable by parse_wire_segments."""
sexp = generate_wire_sexp(148.59, 194.31, 156.21, 194.31, uuid_str="rt-uuid")
content = f"(kicad_sch\n (version 20231120)\n{sexp})\n"
filepath = str(tmp_path / "wire_rt.kicad_sch")
with open(filepath, "w") as f:
f.write(content)
wires = parse_wire_segments(filepath)
assert len(wires) == 1
assert wires[0]["start"]["x"] == pytest.approx(148.59)
assert wires[0]["end"]["x"] == pytest.approx(156.21)
assert wires[0]["uuid"] == "rt-uuid"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# resolve_pin_position_and_orientation tests # resolve_pin_position_and_orientation tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1545,140 +1281,132 @@ class TestParseLibSymbolPinUnits:
class TestFindComponentForPin: class TestFindComponentForPin:
"""Tests for _find_component_for_pin (multi-unit unit selection).""" """Tests for _find_component_for_pin (multi-unit unit selection)."""
def test_finds_correct_unit_for_pin(self): def test_finds_correct_unit_for_pin(self, tmp_path):
"""Pin 8 (V+) belongs to unit 3 — returns the unit 3 instance.""" """Pin 8 (V+) belongs to unit 3 — returns the unit 3 instance."""
from kicad_sch_api import load_schematic from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import _find_component_for_pin from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile( path = str(tmp_path / "multi.kicad_sch")
suffix=".kicad_sch", mode="w", delete=False, with open(path, "w") as f:
) as f:
f.write(MULTI_UNIT_SCHEMATIC) f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try: sch = SchDocument(path)
sch = load_schematic(path) sch.components.add(
sch.components.add( lib_id="Amplifier_Operational:TL072",
lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072",
reference="U2", value="TL072", x=300, y=102, unit=1,
position=(300, 102), unit=1, )
) sch.components.add(
sch.components.add( lib_id="Amplifier_Operational:TL072",
lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072",
reference="U2", value="TL072", x=300, y=145, unit=2,
position=(300, 145), unit=2, )
) sch.components.add(
sch.components.add( lib_id="Amplifier_Operational:TL072",
lib_id="Amplifier_Operational:TL072", reference="U2", value="TL072",
reference="U2", value="TL072", x=300, y=175, unit=3,
position=(300, 175), unit=3, )
) sch.save(path)
# Pin 8 → unit 3 # Reload to ensure sexp is parsed
comp = _find_component_for_pin( sch = SchDocument(path)
sch, "U2", "8", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 3
# Pin 1 → unit 1 # Pin 8 → unit 3
comp = _find_component_for_pin( comp = _find_component_for_pin(
sch, "U2", "1", path, "Amplifier_Operational:TL072", sch, "U2", "8", path, "Amplifier_Operational:TL072",
) )
assert comp is not None assert comp is not None
assert comp._data.unit == 1 assert comp.unit == 3
# Pin 5 → unit 2 # Pin 1 → unit 1
comp = _find_component_for_pin( comp = _find_component_for_pin(
sch, "U2", "5", path, "Amplifier_Operational:TL072", sch, "U2", "1", path, "Amplifier_Operational:TL072",
) )
assert comp is not None assert comp is not None
assert comp._data.unit == 2 assert comp.unit == 1
finally:
os.unlink(path)
def test_single_unit_returns_get(self): # Pin 5 → unit 2
comp = _find_component_for_pin(
sch, "U2", "5", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp.unit == 2
def test_single_unit_returns_get(self, tmp_path):
"""Single-unit symbol falls back to sch.components.get().""" """Single-unit symbol falls back to sch.components.get()."""
from kicad_sch_api import load_schematic from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import _find_component_for_pin from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile( path = str(tmp_path / "single.kicad_sch")
suffix=".kicad_sch", mode="w", delete=False, with open(path, "w") as f:
) as f:
f.write(SAMPLE_SCHEMATIC) f.write(SAMPLE_SCHEMATIC)
path = f.name
try: sch = SchDocument(path)
sch = load_schematic(path) sch.components.add(
sch.components.add( lib_id="Device:R", reference="R1",
lib_id="Device:R", reference="R1", value="10k", x=100, y=100,
value="10k", position=(100, 100), )
) sch.save(path)
comp = _find_component_for_pin( sch = SchDocument(path)
sch, "R1", "1", path, "Device:R", comp = _find_component_for_pin(
) sch, "R1", "1", path, "Device:R",
assert comp is not None )
assert comp.reference == "R1" assert comp is not None
finally: assert comp.reference == "R1"
os.unlink(path)
class TestMultiUnitPinResolution: class TestMultiUnitPinResolution:
"""Integration: resolve_pin_position returns correct coords per unit.""" """Integration: resolve_pin_position returns correct coords per unit."""
def test_pin_resolves_to_correct_unit_position(self): def test_pin_resolves_to_correct_unit_position(self, tmp_path):
"""Pin 8 (unit 3 at y=175) resolves near y=175, not y=102.""" """Pin 8 (unit 3 at y=175) resolves near y=175, not y=102."""
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import resolve_pin_position from mckicad.utils.sexp_parser import resolve_pin_position
with tempfile.NamedTemporaryFile( path = str(tmp_path / "multi_resolve.kicad_sch")
suffix=".kicad_sch", mode="w", delete=False, with open(path, "w") as f:
) as f:
f.write(MULTI_UNIT_SCHEMATIC) f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try: sch = SchDocument(path)
from kicad_sch_api import load_schematic sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
x=300, y=102, unit=1,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
x=300, y=145, unit=2,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
x=300, y=175, unit=3,
)
sch.save(path)
sch = load_schematic(path) # Reload for clean state
sch.components.add( sch = SchDocument(path)
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 102), unit=1,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 145), unit=2,
)
sch.components.add(
lib_id="Amplifier_Operational:TL072",
reference="U2", value="TL072",
position=(300, 175), unit=3,
)
# Pin 8 (V+) → unit 3 at y=175 # Pin 8 (V+) → unit 3 at y=175
pos_8 = resolve_pin_position(sch, path, "U2", "8") pos_8 = resolve_pin_position(sch, path, "U2", "8")
assert pos_8 is not None assert pos_8 is not None
# Y should be near 175 (unit 3), not near 102 (unit 1) # Y should be near 175 (unit 3), not near 102 (unit 1)
assert abs(pos_8[1] - 175) < 10, ( assert abs(pos_8[1] - 175) < 10, (
f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)" f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)"
) )
# Pin 1 (output) → unit 1 at y=102 # Pin 1 (output) → unit 1 at y=102
pos_1 = resolve_pin_position(sch, path, "U2", "1") pos_1 = resolve_pin_position(sch, path, "U2", "1")
assert pos_1 is not None assert pos_1 is not None
assert abs(pos_1[1] - 102) < 10, ( assert abs(pos_1[1] - 102) < 10, (
f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)" f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)"
) )
# Pin 5 (+In) → unit 2 at y=145 # Pin 5 (+In) → unit 2 at y=145
pos_5 = resolve_pin_position(sch, path, "U2", "5") pos_5 = resolve_pin_position(sch, path, "U2", "5")
assert pos_5 is not None assert pos_5 is not None
assert abs(pos_5[1] - 145) < 10, ( assert abs(pos_5[1] - 145) < 10, (
f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)" f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)"
) )
finally:
os.unlink(path)

668
tests/test_sexp_tree.py Normal file
View File

@ -0,0 +1,668 @@
"""Tests for the KiCad s-expression tokenizer and tree builder."""
import os
import pytest
from mckicad.utils.sexp_tree import (
SexpNode,
TokenType,
deep_copy,
find,
find_named,
find_one,
find_recursive,
get_values,
make_atom,
make_node,
make_string,
parse,
parse_file,
serialize,
serialize_file,
text_at,
tokenize,
)
# ---------------------------------------------------------------------------
# Tokenizer
# ---------------------------------------------------------------------------
class TestTokenizeAtoms:
def test_bare_words(self):
tokens = tokenize("symbol pin at")
assert len(tokens) == 3
assert all(t.type == TokenType.ATOM for t in tokens)
assert [t.value for t in tokens] == ["symbol", "pin", "at"]
def test_numbers(self):
tokens = tokenize("1.27 2.54 0")
assert [t.value for t in tokens] == ["1.27", "2.54", "0"]
def test_negative_numbers(self):
tokens = tokenize("-3.81 -0.5e-3")
assert [t.value for t in tokens] == ["-3.81", "-0.5e-3"]
def test_offsets_sequential(self):
tokens = tokenize("abc def")
assert tokens[0].offset == 0
assert tokens[1].offset == 4
class TestTokenizeStrings:
def test_quoted_string(self):
tokens = tokenize('"hello world"')
assert len(tokens) == 1
assert tokens[0].type == TokenType.STRING
assert tokens[0].value == "hello world"
def test_escaped_quotes(self):
tokens = tokenize(r'"say \"hello\""')
assert tokens[0].value == 'say "hello"'
def test_escaped_backslash(self):
tokens = tokenize(r'"path\\to\\file"')
assert tokens[0].value == "path\\to\\file"
def test_empty_string(self):
tokens = tokenize('""')
assert len(tokens) == 1
assert tokens[0].type == TokenType.STRING
assert tokens[0].value == ""
class TestTokenizeNested:
def test_nested_parens(self):
tokens = tokenize("(at 1.27 2.54)")
types = [t.type for t in tokens]
assert types == [
TokenType.LPAREN,
TokenType.ATOM,
TokenType.ATOM,
TokenType.ATOM,
TokenType.RPAREN,
]
def test_deeply_nested(self):
tokens = tokenize('(pin passive line (at 0 0) (name "A"))')
lparen_count = sum(1 for t in tokens if t.type == TokenType.LPAREN)
rparen_count = sum(1 for t in tokens if t.type == TokenType.RPAREN)
assert lparen_count == 3
assert rparen_count == 3
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
class TestParseSimple:
def test_simple_node(self):
node = parse("(at 1.27 2.54)")
assert node.tag == "at"
assert node.values == ["1.27", "2.54"]
assert node.children == []
def test_string_values(self):
node = parse('(name "VCC")')
assert node.tag == "name"
assert node.values == ["VCC"]
def test_empty_input(self):
node = parse("")
assert node.tag == ""
class TestParseNested:
def test_pin_with_children(self):
sexp = '(pin passive line (at 0 0) (name "A"))'
node = parse(sexp)
assert node.tag == "pin"
assert node.values == ["passive", "line"]
assert len(node.children) == 2
assert node.children[0].tag == "at"
assert node.children[0].values == ["0", "0"]
assert node.children[1].tag == "name"
assert node.children[1].values == ["A"]
def test_mixed_values_and_children(self):
sexp = '(symbol "R" (pin_names (offset 1.016)))'
node = parse(sexp)
assert node.tag == "symbol"
assert node.values == ["R"]
assert len(node.children) == 1
assert node.children[0].tag == "pin_names"
class TestParseKicadLabel:
LABEL_SEXP = (
'(label "VCC"\n'
"\t(at 100.0 50.0 0)\n"
"\t(effects\n"
"\t\t(font\n"
"\t\t\t(size 1.27 1.27)\n"
"\t\t)\n"
"\t\t(justify left bottom)\n"
"\t)\n"
'\t(uuid "abc-123")\n'
")"
)
def test_label_structure(self):
node = parse(self.LABEL_SEXP)
assert node.tag == "label"
assert node.values == ["VCC"]
assert len(node.children) == 3 # at, effects, uuid
at_node = find_one(node, "at")
assert at_node is not None
assert at_node.values == ["100.0", "50.0", "0"]
uuid_node = find_one(node, "uuid")
assert uuid_node is not None
assert uuid_node.values == ["abc-123"]
class TestParseKicadSymbol:
SYMBOL_SEXP = """\
(symbol "R"
(pin_names (offset 0))
(symbol "R_0_1"
(polyline (pts (xy 0 -1.27) (xy 0 1.27)))
)
(symbol "R_1_1"
(pin passive line
(at 0 2.54 270)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line
(at 0 -2.54 90)
(length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)"""
def test_symbol_structure(self):
node = parse(self.SYMBOL_SEXP)
assert node.tag == "symbol"
assert node.values == ["R"]
# Direct children with tag "symbol" are sub-symbols
sub_syms = find(node, "symbol")
assert len(sub_syms) == 2
assert sub_syms[0].values == ["R_0_1"]
assert sub_syms[1].values == ["R_1_1"]
def test_pins_in_subsymbols(self):
node = parse(self.SYMBOL_SEXP)
pins = find_recursive(node, "pin")
assert len(pins) == 2
# First pin
assert pins[0].values[0] == "passive" # type
at_vals = get_values(pins[0], "at")
assert at_vals == ["0", "2.54", "270"]
# ---------------------------------------------------------------------------
# Query functions
# ---------------------------------------------------------------------------
class TestFindDirectChildren:
def test_find_symbols(self):
sexp = '(lib (symbol "A" (pin)) (symbol "B" (pin)) (version 1))'
node = parse(sexp)
syms = find(node, "symbol")
assert len(syms) == 2
assert syms[0].values == ["A"]
assert syms[1].values == ["B"]
def test_find_no_match(self):
node = parse("(root (child))")
assert find(node, "nonexistent") == []
class TestFindRecursive:
def test_find_pins_in_subsymbols(self):
sexp = "(root (symbol (sub (pin 1)) (sub (pin 2))))"
node = parse(sexp)
pins = find_recursive(node, "pin")
assert len(pins) == 2
assert pins[0].values == ["1"]
assert pins[1].values == ["2"]
class TestFindNamed:
def test_exact_match(self):
sexp = '(lib (symbol "Device:R") (symbol "Device:R_0_1"))'
node = parse(sexp)
result = find_named(node, "symbol", "Device:R")
assert result is not None
assert result.values == ["Device:R"]
def test_no_partial_match(self):
sexp = '(lib (symbol "Device:R_0_1") (symbol "Device:R_1_1"))'
node = parse(sexp)
result = find_named(node, "symbol", "Device:R")
assert result is None
def test_no_match(self):
node = parse("(lib (symbol))")
assert find_named(node, "symbol", "anything") is None
class TestGetValues:
def test_get_at_values(self):
sexp = "(pin passive line (at 5.08 0 180) (length 2.54))"
node = parse(sexp)
at_vals = get_values(node, "at")
assert at_vals == ["5.08", "0", "180"]
def test_get_missing_tag(self):
node = parse("(pin passive)")
assert get_values(node, "at") is None
# ---------------------------------------------------------------------------
# Span tracking
# ---------------------------------------------------------------------------
class TestSpanTracking:
def test_simple_span(self):
source = "(at 1.27 2.54)"
node = parse(source)
assert node.span == (0, len(source))
def test_nested_span(self):
source = '(pin passive (at 0 0) (name "A"))'
node = parse(source)
# Root span covers everything
assert node.span == (0, len(source))
# Children have their own spans
at_node = node.children[0]
assert source[at_node.span[0] : at_node.span[1]] == "(at 0 0)"
def test_span_with_whitespace(self):
source = " (at 1 2) "
node = parse(source)
# Span should start at the opening paren, not at whitespace
assert node.span[0] == 2
assert source[node.span[0] : node.span[1]] == "(at 1 2)"
class TestTextAt:
def test_extract_source(self):
source = '(root (child "value") (other))'
node = parse(source)
child = node.children[0]
extracted = text_at(source, child.span)
assert extracted == '(child "value")'
def test_extract_nested(self):
source = "(root (parent (child 1) (child 2)))"
node = parse(source)
parent = node.children[0]
extracted = text_at(source, parent.span)
assert extracted == "(parent (child 1) (child 2))"
# ---------------------------------------------------------------------------
# Real file parsing (requires KiCad installation)
# ---------------------------------------------------------------------------
DEVICE_LIB = "/usr/share/kicad/symbols/Device.kicad_sym"
@pytest.mark.skipif(
not os.path.isfile(DEVICE_LIB),
reason="KiCad symbol library not installed",
)
class TestParseRealFile:
def test_parse_device_lib(self):
tree = parse_file(DEVICE_LIB)
assert tree.tag == "kicad_symbol_lib"
# Should contain many symbols
symbols = find(tree, "symbol")
assert len(symbols) > 50
def test_find_resistor(self):
tree = parse_file(DEVICE_LIB)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
assert resistor.values[0] == "R"
# R has two pins in its sub-symbols
pins = find_recursive(resistor, "pin")
assert len(pins) == 2
def test_span_matches_source(self):
with open(DEVICE_LIB, encoding="utf-8") as f:
source = f.read()
tree = parse(source)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
extracted = text_at(source, resistor.span)
assert extracted.startswith('(symbol "R"')
assert extracted.endswith(")")
def test_pin_details(self):
tree = parse_file(DEVICE_LIB)
resistor = find_named(tree, "symbol", "R")
assert resistor is not None
pins = find_recursive(resistor, "pin")
for pin in pins:
# Each pin should have at, length, name, number children
assert find_one(pin, "at") is not None
assert find_one(pin, "length") is not None
assert find_one(pin, "name") is not None
assert find_one(pin, "number") is not None
# ---------------------------------------------------------------------------
# Quoting tracking (_quoted field)
# ---------------------------------------------------------------------------
class TestQuotedTracking:
def test_atom_not_quoted(self):
node = parse("(at 1.27 2.54)")
assert node._quoted == [False, False]
def test_string_quoted(self):
node = parse('(name "VCC")')
assert node._quoted == [True]
def test_mixed_values(self):
node = parse('(pin passive line)')
assert node._quoted == [False, False]
def test_nested_quoted(self):
sexp = '(property "Reference" "R1" (at 0 0 0))'
node = parse(sexp)
assert node._quoted == [True, True]
at_node = find_one(node, "at")
assert at_node is not None
assert at_node._quoted == [False, False, False]
def test_empty_string_quoted(self):
node = parse('(value "")')
assert node._quoted == [True]
assert node.values == [""]
# ---------------------------------------------------------------------------
# Serializer
# ---------------------------------------------------------------------------
class TestSerializeSimple:
def test_values_only(self):
node = make_atom("at", "1.27", "2.54")
assert serialize(node) == "(at 1.27 2.54)"
def test_string_values(self):
node = make_string("name", "VCC")
assert serialize(node) == '(name "VCC")'
def test_no_values_no_children(self):
node = make_node("empty")
assert serialize(node) == "(empty)"
def test_escape_quotes_in_values(self):
node = make_string("desc", 'say "hello"')
result = serialize(node)
assert result == '(desc "say \\"hello\\"")'
def test_escape_backslash(self):
node = make_string("path", "C:\\Users\\file")
result = serialize(node)
assert result == '(path "C:\\\\Users\\\\file")'
class TestSerializeNested:
def test_single_child_inline(self):
child = make_atom("offset", "0")
node = make_node("pin_names", children=[child])
assert serialize(node) == "(pin_names (offset 0))"
def test_multiple_children_multiline(self):
c1 = make_atom("at", "0", "0")
c2 = make_string("name", "A")
node = make_node("pin", ["passive", "line"], [c1, c2])
result = serialize(node)
assert "(pin passive line\n" in result
assert " (at 0 0)\n" in result
assert ' (name "A")\n' in result
assert result.endswith(")")
def test_indentation_nesting(self):
inner = make_atom("size", "1.27", "1.27")
font = make_node("font", children=[inner])
effects = make_node("effects", children=[font])
# Single-child chain: effects -> font -> size → all inline
result = serialize(effects)
assert result == "(effects (font (size 1.27 1.27)))"
class TestSerializeRoundTrip:
def test_simple_roundtrip(self):
original = "(at 1.27 2.54)"
tree = parse(original)
assert serialize(tree) == original
def test_string_roundtrip(self):
original = '(name "VCC")'
tree = parse(original)
assert serialize(tree) == original
def test_nested_roundtrip(self):
original = "(pin_names (offset 0))"
tree = parse(original)
assert serialize(tree) == original
def test_property_roundtrip(self):
original = '(property "Reference" "R1" (at 0 0 0))'
tree = parse(original)
result = serialize(tree)
assert result == original
def test_kicad_label_roundtrip(self):
sexp = (
'(label "VCC"\n'
' (at 100.0 50.0 0)\n'
' (effects\n'
' (font (size 1.27 1.27))\n'
' (justify left bottom)\n'
' )\n'
' (uuid "abc-123")\n'
')'
)
tree = parse(sexp)
result = serialize(tree)
# Re-parse both to verify structural equivalence
re_parsed = parse(result)
assert re_parsed.tag == "label"
assert re_parsed.values == ["VCC"]
at_node = find_one(re_parsed, "at")
assert at_node is not None
assert at_node.values == ["100.0", "50.0", "0"]
def test_multi_child_structure_preserved(self):
sexp = '(symbol "R" (pin_names (offset 0)) (in_bom yes) (on_board yes))'
tree = parse(sexp)
result = serialize(tree)
re_tree = parse(result)
assert re_tree.tag == "symbol"
assert re_tree.values == ["R"]
assert len(re_tree.children) == 3
class TestSerializeFile:
def test_writes_valid_file(self, tmp_path):
node = make_node("kicad_sch", children=[
make_atom("version", "20231120"),
make_string("generator", "test"),
make_string("uuid", "abc-123"),
])
filepath = str(tmp_path / "test.kicad_sch")
serialize_file(node, filepath)
assert os.path.isfile(filepath)
with open(filepath, encoding="utf-8") as f:
content = f.read()
assert content.startswith("(kicad_sch")
assert content.endswith("\n")
def test_roundtrip_through_file(self, tmp_path):
node = make_node("kicad_sch", children=[
make_atom("version", "20231120"),
make_string("uuid", "file-test"),
])
filepath = str(tmp_path / "rt.kicad_sch")
serialize_file(node, filepath)
reloaded = parse_file(filepath)
assert reloaded.tag == "kicad_sch"
assert find_one(reloaded, "version") is not None
uuid_node = find_one(reloaded, "uuid")
assert uuid_node is not None
assert uuid_node.values == ["file-test"]
@pytest.mark.skipif(
not os.path.isfile(DEVICE_LIB),
reason="KiCad symbol library not installed",
)
class TestSerializeRealFile:
def test_device_lib_roundtrip_structural(self):
"""Parse real Device.kicad_sym, serialize, re-parse — same structure."""
tree = parse_file(DEVICE_LIB)
text = serialize(tree)
re_tree = parse(text)
# Same number of top-level symbols
orig_syms = find(tree, "symbol")
re_syms = find(re_tree, "symbol")
assert len(re_syms) == len(orig_syms)
# Spot-check: resistor still has 2 pins
r_orig = find_named(tree, "symbol", "R")
r_new = find_named(re_tree, "symbol", "R")
assert r_orig is not None
assert r_new is not None
assert len(find_recursive(r_orig, "pin")) == len(find_recursive(r_new, "pin"))
# ---------------------------------------------------------------------------
# Node builders
# ---------------------------------------------------------------------------
class TestMakeNode:
def test_auto_quote_numeric(self):
node = make_node("at", ["1.27", "2.54"])
assert node._quoted == [False, False]
assert serialize(node) == "(at 1.27 2.54)"
def test_auto_quote_negative(self):
node = make_node("at", ["-3.81", "0"])
assert node._quoted == [False, False]
def test_auto_quote_keyword(self):
node = make_node("in_bom", ["yes"])
assert node._quoted == [False]
assert serialize(node) == "(in_bom yes)"
def test_auto_quote_string(self):
node = make_node("lib_id", ["Device:R"])
assert node._quoted == [True]
assert serialize(node) == '(lib_id "Device:R")'
def test_auto_quote_mixed(self):
node = make_node("pin", ["passive", "line"])
assert node._quoted == [False, False]
def test_with_children(self):
child = make_atom("offset", "0")
node = make_node("pin_names", children=[child])
assert serialize(node) == "(pin_names (offset 0))"
def test_empty(self):
node = make_node("empty")
assert node.values == []
assert node.children == []
class TestMakeAtom:
def test_basic(self):
node = make_atom("at", "1.27", "2.54", "90")
assert node.values == ["1.27", "2.54", "90"]
assert all(q is False for q in node._quoted)
assert serialize(node) == "(at 1.27 2.54 90)"
def test_no_values(self):
node = make_atom("empty")
assert node.values == []
class TestMakeString:
def test_basic(self):
node = make_string("property", "Reference", "R1")
assert node.values == ["Reference", "R1"]
assert all(q is True for q in node._quoted)
assert serialize(node) == '(property "Reference" "R1")'
def test_single(self):
node = make_string("uuid", "abc-123")
assert serialize(node) == '(uuid "abc-123")'
# ---------------------------------------------------------------------------
# Deep copy
# ---------------------------------------------------------------------------
class TestDeepCopy:
def test_independent_copy(self):
original = make_node("symbol", ["Device:R"], [
make_atom("at", "1.27", "2.54"),
make_string("uuid", "orig-uuid"),
])
copied = deep_copy(original)
# Structural equality
assert copied.tag == original.tag
assert copied.values == original.values
assert copied._quoted == original._quoted
assert len(copied.children) == len(original.children)
# Independence: modifying copy doesn't affect original
copied.values[0] = "Modified:R"
assert original.values[0] == "Device:R"
copied.children[0].values[0] = "99.99"
assert original.children[0].values[0] == "1.27"
def test_preserves_quoted(self):
original = parse('(property "Reference" "R1")')
copied = deep_copy(original)
assert copied._quoted == [True, True]
assert serialize(copied) == '(property "Reference" "R1")'
def test_deep_nesting(self):
sexp = '(effects (font (size 1.27 1.27)) (justify left))'
original = parse(sexp)
copied = deep_copy(original)
result = serialize(copied)
re_parsed = parse(result)
assert re_parsed.tag == "effects"
font = find_one(re_parsed, "font")
assert font is not None

83
uv.lock generated
View File

@ -92,10 +92,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
] ]
@ -542,18 +548,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
] ]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]] [[package]]
name = "jsonref" name = "jsonref"
version = "1.1.0" version = "1.1.0"
@ -636,22 +630,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ab/b9edb6088ba8263a7f2446f1f3ae088c50bde149d7dc149abca32c4d21d2/kicad_python-0.5.0-py3-none-any.whl", hash = "sha256:fe7adeff9f1c23a76f2ace9468faadcbeef915ce026b187ca35db1cf4faa4451", size = 130154, upload-time = "2025-10-13T15:10:02.695Z" }, { url = "https://files.pythonhosted.org/packages/ad/ab/b9edb6088ba8263a7f2446f1f3ae088c50bde149d7dc149abca32c4d21d2/kicad_python-0.5.0-py3-none-any.whl", hash = "sha256:fe7adeff9f1c23a76f2ace9468faadcbeef915ce026b187ca35db1cf4faa4451", size = 130154, upload-time = "2025-10-13T15:10:02.695Z" },
] ]
[[package]]
name = "kicad-sch-api"
version = "0.5.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
{ name = "jinja2" },
{ name = "mcp" },
{ name = "pydantic" },
{ name = "sexpdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/38/cc778c53ad8b15da1e5b49146c640c0e0eb6a86b0d8270459d59cf042d23/kicad_sch_api-0.5.6.tar.gz", hash = "sha256:f001faffdf1745d7a52e90636be2bc4ae3463909491fff9f9d40fe1f7842d36e", size = 391722, upload-time = "2025-11-19T07:00:43.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/f5/f042a3f61aa919c6ad2ecdb0e7ec6d9c3b184388363a5a1c46f127513ca3/kicad_sch_api-0.5.6-py3-none-any.whl", hash = "sha256:70935184dca35eed17bffa8958e93f7fe02512fd0c44d6be9f22ddc54e39d164", size = 329065, upload-time = "2025-11-19T07:00:41.748Z" },
]
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.8.0" version = "0.8.0"
@ -724,44 +702,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
] ]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]] [[package]]
name = "mckicad" name = "mckicad"
version = "2026.3.3" version = "2026.3.3"
@ -770,7 +710,6 @@ dependencies = [
{ name = "defusedxml" }, { name = "defusedxml" },
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "kicad-python" }, { name = "kicad-python" },
{ name = "kicad-sch-api" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "requests" }, { name = "requests" },
] ]
@ -790,7 +729,6 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" }, { name = "defusedxml", specifier = ">=0.7.1" },
{ name = "fastmcp", specifier = ">=3.1.0" }, { name = "fastmcp", specifier = ">=3.1.0" },
{ name = "kicad-python", specifier = ">=0.5.0" }, { name = "kicad-python", specifier = ">=0.5.0" },
{ name = "kicad-sch-api", specifier = ">=0.5.6" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
] ]
@ -1449,15 +1387,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
] ]
[[package]]
name = "sexpdata"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/7f/369a478863a39351be75e0a12602bc29196b31f87bf3432bed2be6379f8e/sexpdata-1.0.2.tar.gz", hash = "sha256:92b67b0361f6766f8f9e44b9519cf3fbcfafa755db85bbf893c3e1cf4ddac109", size = 8906, upload-time = "2024-01-09T07:09:59.096Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/f3/ec9f8cc20dc1f34c926f0ec3f43b73fa2da59cf08e432fb8ae5b666b2027/sexpdata-1.0.2-py3-none-any.whl", hash = "sha256:b39c918f055a85c5c35c1d4f7930aabb176bd29016e5ba5692e7e849914b2a1a", size = 10337, upload-time = "2024-01-09T07:09:57.185Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"