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",
"defusedxml>=0.7.1",
"kicad-python>=0.5.0",
"kicad-sch-api>=0.5.6",
"requests>=2.32.5",
]
@ -80,7 +80,7 @@ warn_unused_ignores = true
show_error_codes = true
[[tool.mypy.overrides]]
module = ["kipy.*", "kicad_sch_api.*", "requests.*"]
module = ["kipy.*", "requests.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]

View File

@ -11,22 +11,11 @@ import os
from typing import Any
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__)
_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")
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,
and position.
"""
if not _HAS_SCH_API:
return json.dumps({"error": "kicad-sch-api not installed"})
expanded = _expand(path)
if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"})
try:
sch = _ksa_load(expanded)
sch = load_schematic(expanded)
components: list[dict[str, Any]] = []
for comp in sch.components:
entry: dict[str, Any] = {
"reference": getattr(comp, "reference", None),
"lib_id": getattr(comp, "lib_id", None),
"value": getattr(comp, "value", None),
"reference": comp.reference,
"lib_id": comp.lib_id,
"value": comp.value,
}
pos = getattr(comp, "position", None)
if pos is not None and isinstance(pos, (list, tuple)) and len(pos) >= 2:
entry["position"] = {"x": pos[0], "y": pos[1]}
pos = comp.position
if pos is not None:
entry["position"] = {"x": pos.x, "y": pos.y}
components.append(entry)
return json.dumps(components, indent=2)
except Exception as e:
@ -66,36 +52,19 @@ def schematic_nets_resource(path: str) -> str:
"""Browsable net list for a KiCad schematic.
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)
if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"})
try:
sch = _ksa_load(expanded)
nets_attr = getattr(sch, "nets", None)
sch = load_schematic(expanded)
if nets_attr is None:
return json.dumps({"error": "Schematic has no nets attribute"})
from mckicad.tools.schematic_analysis import _build_connectivity
net_data: dict[str, list[dict[str, str]]] = {}
if isinstance(nets_attr, dict):
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)
net_graph, _unconnected = _build_connectivity(sch, expanded)
return json.dumps(net_graph, indent=2)
except Exception as e:
logger.error("Failed to load schematic nets for resource: %s", 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
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)
if not os.path.isfile(expanded):
return json.dumps({"error": f"File not found: {expanded}"})
try:
sch = _ksa_load(expanded)
sch = load_schematic(expanded)
def _build(sheet_sch: Any, name: str, filename: str) -> dict[str, Any]:
info: dict[str, Any] = {
"name": name,
"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]] = []
if hasattr(sheet_sch, "sheets"):
for child in sheet_sch.sheets:
child_name = getattr(child, "name", "unknown")
child_file = getattr(child, "filename", "unknown")
child_path = os.path.join(os.path.dirname(expanded), child_file)
if os.path.isfile(child_path):
try:
child_sch = _ksa_load(child_path)
children.append(_build(child_sch, child_name, child_file))
except Exception:
children.append({"name": child_name, "filename": child_file, "error": "load failed"})
else:
children.append({"name": child_name, "filename": child_file, "note": "file not found"})
for child in sheet_sch.sheets:
child_name = child.name
child_file = child.filename
child_path = os.path.join(os.path.dirname(expanded), child_file)
if os.path.isfile(child_path):
try:
child_sch = load_schematic(child_path)
children.append(_build(child_sch, child_name, child_file))
except Exception:
children.append({"name": child_name, "filename": child_file, "error": "load failed"})
else:
children.append({"name": child_name, "filename": child_file, "note": "file not found"})
if children:
info["sheets"] = children
return info

View File

@ -23,41 +23,11 @@ from mckicad.autowire.strategy import (
from mckicad.config import TIMEOUT_CONSTANTS
from mckicad.server import mcp
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__)
_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]:
"""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
plan, batch JSON, and apply results (when dry_run=False).
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
@ -178,7 +144,8 @@ def autowire_schematic(
try:
# 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
@ -285,32 +252,15 @@ def autowire_schematic(
# 12. Apply if not dry_run
if not dry_run and batch_data:
from mckicad.tools.batch import (
_apply_batch_operations,
_register_project_libraries,
)
from mckicad.utils.sexp_parser import (
fix_property_private_keywords,
insert_sexp_before_close,
)
from mckicad.tools.batch import _apply_batch_operations
# Re-load schematic fresh for application
sch = _ksa_load(schematic_path)
_register_project_libraries(batch_data, schematic_path)
sch = _load_sch(schematic_path)
summary = _apply_batch_operations(sch, batch_data, 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"] = {
"components_placed": summary.get("components_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.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__)
# Map user-facing direction names to the pin schematic rotation that
# produces that label placement direction (label goes opposite to body).
_DIRECTION_TO_ROTATION: dict[str, float] = {
"left": 0, # body right label left
"right": 180, # body left label right
"up": 90, # body down label up
"down": 270, # body up label down
"left": 0, # body right -> label left
"right": 180, # body left -> label right
"up": 90, # body down -> label up
"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:
return "kicad-sch-api" if _HAS_SCH_API else "none"
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))
return "sch_document"
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
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(
sch: Any, data: dict[str, Any], schematic_path: str,
) -> dict[str, Any]:
"""Apply all batch operations to a loaded schematic. Returns summary.
Labels are returned as pending sexp strings (``_pending_label_sexps``)
that must be inserted into the file *after* ``sch.save()``, because
kicad-sch-api's serializer drops global labels and raises TypeError
on local labels.
Labels are inserted directly into the sexp tree via
``sch.add_label()`` / ``sch.add_global_label()`` and wire stubs via
``sch.add_wire()``, so no post-save fixup is needed.
Args:
sch: A kicad-sch-api SchematicDocument instance.
sch: A SchDocument instance.
data: Parsed batch JSON data.
schematic_path: Path to the .kicad_sch file (needed for sexp
pin fallback and label insertion).
schematic_path: Path to the .kicad_sch file (needed for pin
position resolution).
"""
from mckicad.patterns._geometry import add_power_symbol_to_pin
from mckicad.utils.sexp_parser import (
WireSegment,
clamp_stub_length,
compute_label_placement,
generate_global_label_sexp,
generate_label_sexp,
generate_wire_sexp,
resolve_label_collision,
resolve_pin_position,
resolve_pin_position_and_orientation,
@ -357,7 +276,6 @@ def _apply_batch_operations(
placed_wires: list[str] = []
placed_labels: list[str] = []
placed_no_connects = 0
pending_label_sexps: list[str] = []
occupied_positions: dict[tuple[float, float], str] = {}
placed_wire_segments: list[WireSegment] = []
collisions_resolved = 0
@ -371,7 +289,7 @@ def _apply_batch_operations(
except Exception:
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", []):
ref = comp.get("reference")
unit = comp.get("unit", 1)
@ -426,10 +344,10 @@ def _apply_batch_operations(
for wire in data.get("wires", []):
if "from_ref" in wire:
wire_id = sch.add_wire_between_pins(
component1_ref=wire["from_ref"],
pin1_number=wire["from_pin"],
component2_ref=wire["to_ref"],
pin2_number=wire["to_pin"],
from_ref=wire["from_ref"],
from_pin=wire["from_pin"],
to_ref=wire["to_ref"],
to_pin=wire["to_pin"],
)
else:
wire_id = sch.add_wire(
@ -481,7 +399,7 @@ def _apply_batch_operations(
if _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", []):
is_global = label.get("global", False)
rotation = label.get("rotation", 0)
@ -535,7 +453,7 @@ def _apply_batch_operations(
lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"]
# Resolve collisions before generating sexp
# Resolve collisions before placing
new_x, new_y = resolve_label_collision(
lx, ly, rotation, label["text"], occupied_positions,
)
@ -544,17 +462,16 @@ def _apply_batch_operations(
lx, ly = new_x, new_y
if is_global:
sexp = generate_global_label_sexp(
sch.add_global_label(
text=label["text"], x=lx, y=ly, rotation=rotation,
shape=label.get("shape", "bidirectional"),
)
else:
sexp = generate_label_sexp(
sch.add_label(
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
w_sx, w_sy, w_lx, w_ly = resolve_wire_collision(
placement["stub_start_x"], placement["stub_start_y"],
@ -566,12 +483,11 @@ def _apply_batch_operations(
):
wire_collisions_resolved += 1
lx, ly = w_lx, w_ly
wire_sexp = generate_wire_sexp(w_sx, w_sy, w_lx, w_ly)
pending_label_sexps.append(wire_sexp)
sch.add_wire(w_sx, w_sy, w_lx, w_ly)
else:
# Coordinate-based label (original path)
if is_global:
sexp = generate_global_label_sexp(
sch.add_global_label(
text=label["text"],
x=label["x"],
y=label["y"],
@ -579,17 +495,16 @@ def _apply_batch_operations(
shape=label.get("shape", "bidirectional"),
)
else:
sexp = generate_label_sexp(
sch.add_label(
text=label["text"],
x=label["x"],
y=label["y"],
rotation=rotation,
)
pending_label_sexps.append(sexp)
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", []):
net = lc["net"]
is_global = lc.get("global", False)
@ -643,7 +558,7 @@ def _apply_batch_operations(
lx, ly = placement["label_x"], placement["label_y"]
rotation = placement["label_rotation"]
# Resolve collisions before generating sexp
# Resolve collisions before placing
new_x, new_y = resolve_label_collision(
lx, ly, rotation, net, occupied_positions,
)
@ -652,16 +567,15 @@ def _apply_batch_operations(
lx, ly = new_x, new_y
if is_global:
sexp = generate_global_label_sexp(
sch.add_global_label(
text=net, x=lx, y=ly, rotation=rotation, shape=shape,
)
else:
sexp = generate_label_sexp(
sch.add_label(
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
w_sx, w_sy, w_lx, w_ly = resolve_wire_collision(
placement["stub_start_x"], placement["stub_start_y"],
@ -673,8 +587,7 @@ def _apply_batch_operations(
):
wire_collisions_resolved += 1
lx, ly = w_lx, w_ly
wire_sexp = generate_wire_sexp(w_sx, w_sy, w_lx, w_ly)
pending_label_sexps.append(wire_sexp)
sch.add_wire(w_sx, w_sy, w_lx, w_ly)
placed_labels.append(net)
# 5. No-connects (coordinate or pin-referenced)
@ -689,9 +602,9 @@ def _apply_batch_operations(
nc["pin_ref"], nc["pin_number"],
)
continue
sch.no_connects.add(position=pin_pos)
sch.add_no_connect(position=pin_pos)
else:
sch.no_connects.add(position=(nc["x"], nc["y"]))
sch.add_no_connect(x=nc["x"], y=nc["y"])
placed_no_connects += 1
return {
@ -713,7 +626,6 @@ def _apply_batch_operations(
+ len(placed_labels)
+ 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,
and wire/label IDs for the created elements.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
@ -846,11 +754,7 @@ def apply_batch(
}
try:
sch = _ksa_load(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)
sch = load_schematic(schematic_path)
# Set hierarchy context for sub-sheets so kicad-cli resolves
# power symbol nets correctly during netlist export.
@ -898,27 +802,10 @@ def apply_batch(
# Apply all operations
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)
# 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(
"Batch applied: %d operations from %s to %s",
summary["total_operations"],

View File

@ -7,53 +7,17 @@ Uses the shared geometry helpers from the patterns library.
"""
import logging
import os
from typing import Any
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__)
_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:
return "kicad-sch-api" if _HAS_SCH_API else "none"
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))
return "sch_document"
@mcp.tool()
@ -97,14 +61,10 @@ def add_power_symbol(
Dictionary with ``success``, placed ``reference``, ``lib_id``,
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not net:
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.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)
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.
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
exposes a schematic API over its IPC transport.
"""
from collections import Counter
import contextlib
import logging
import os
import re
from collections import Counter
from typing import Any
from mckicad.config import INLINE_RESULT_THRESHOLD
from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file
from mckicad.utils.lib_resolver import get_symbol_info as _lr_get_info
from mckicad.utils.lib_resolver import search_symbols as _lr_search
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sch_helpers import expand as _expand
from mckicad.utils.sch_helpers import load_schematic
from mckicad.utils.sch_helpers import validate_schematic_path as _validate_schematic_path
from mckicad.utils.sexp_parser import (
compute_label_placement,
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,
transform_pin_to_schematic,
)
logger = logging.getLogger(__name__)
@ -34,69 +33,14 @@ logger = logging.getLogger(__name__)
# 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:
"""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.
"""
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))
return "sch_document"
# ---------------------------------------------------------------------------
@ -129,10 +73,6 @@ def create_schematic(
Returns:
Dictionary with ``success``, ``path``, ``paper_size``, and ``engine`` keys.
"""
err = _require_sch_api()
if err:
return err
output_path = _expand(output_path)
if not output_path.endswith(".kicad_sch"):
@ -155,8 +95,8 @@ def create_schematic(
if parent:
os.makedirs(parent, exist_ok=True)
sch = _ksa_create(name)
sch.set_paper_size(paper_size)
sch = SchDocument.create(output_path, paper=paper_size)
sch.set_title_block(title=name)
sch.save(output_path)
logger.info("Created schematic '%s' (%s) at %s", name, paper_size, output_path)
@ -198,17 +138,13 @@ def add_component(
Returns:
Dictionary with ``success``, component ``reference``, and ``lib_id``.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
sch.components.add(
lib_id=lib_id,
reference=reference,
@ -217,10 +153,6 @@ def add_component(
)
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)
return {
"success": True,
@ -257,12 +189,8 @@ def search_components(query: str, library: str | None = None) -> dict[str, Any]:
Returns:
Dictionary with ``success`` and a ``results`` list of matching symbols.
"""
err = _require_sch_api()
if err:
return err
try:
raw_results = _ksa_search(query)
raw_results = _lr_search(query)
# Filter by library when requested
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))
# Large result sidecar file with top results inline
# Large result -> sidecar file with top results inline
if len(results) > INLINE_RESULT_THRESHOLD:
safe_query = re.sub(r"[^\w\-]", "_", query)[:30]
detail_path = write_detail_file(None, f"search_{safe_query}.json", results)
@ -340,18 +268,14 @@ def add_wire(
Returns:
Dictionary with ``success`` and the wire ``id``.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
wire_id = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y))
sch = load_schematic(schematic_path)
wire = sch.add_wire(start=(start_x, start_y), end=(end_x, end_y))
sch.save(schematic_path)
logger.info(
@ -364,7 +288,7 @@ def add_wire(
)
return {
"success": True,
"wire_id": wire_id,
"wire_id": str(wire.uuid),
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"schematic_path": schematic_path,
@ -399,23 +323,14 @@ def connect_pins(
Returns:
Dictionary with ``success`` and the created wire ``id``.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
wire_id = sch.add_wire_between_pins(
component1_ref=from_ref,
pin1_number=from_pin,
component2_ref=to_ref,
pin2_number=to_pin,
)
sch = load_schematic(schematic_path)
wire = sch.add_wire_between_pins(from_ref, from_pin, to_ref, to_pin)
sch.save(schematic_path)
logger.info(
@ -428,7 +343,7 @@ def connect_pins(
)
return {
"success": True,
"wire_id": wire_id,
"wire_id": str(wire.uuid),
"from": {"reference": from_ref, "pin": from_pin},
"to": {"reference": to_ref, "pin": to_pin},
"schematic_path": schematic_path,
@ -477,10 +392,6 @@ def add_label(
automatically resolve position and rotation from a component pin.
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:
schematic_path: Path to an existing .kicad_sch file.
text: Label text (becomes the net name, e.g. ``GND``, ``SPI_CLK``).
@ -518,11 +429,10 @@ def add_label(
}
try:
sch = load_schematic(schematic_path)
if has_pin_ref:
# 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(
sch, schematic_path, pin_ref, pin_number, # type: ignore[arg-type]
)
@ -541,42 +451,38 @@ def add_label(
label_rotation = placement["label_rotation"]
if global_label:
sexp = generate_global_label_sexp(
text, lx, ly, rotation=label_rotation, shape=shape,
gl = sch.add_global_label(
text, lx, ly, shape=shape, rotation=label_rotation,
)
label_id = gl.uuid
label_type = "global"
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"
wire_sexp = generate_wire_sexp(
# Add wire stub
sch.add_wire(
placement["stub_start_x"], placement["stub_start_y"],
placement["stub_end_x"], placement["stub_end_y"],
)
combined = sexp + wire_sexp
insert_sexp_before_close(schematic_path, combined)
x, y = lx, ly
else:
# Coordinate placement (original path)
# Coordinate placement
if global_label:
sexp = generate_global_label_sexp(
text, x, y, rotation=rotation, shape=shape, # type: ignore[arg-type]
gl = sch.add_global_label(
text, x, y, shape=shape, rotation=rotation, # type: ignore[arg-type]
)
label_id = gl.uuid
label_type = "global"
else:
sexp = generate_label_sexp(
lbl = sch.add_label(
text, x, y, rotation=rotation, # type: ignore[arg-type]
)
label_id = lbl.uuid
label_type = "local"
insert_sexp_before_close(schematic_path, sexp)
# 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"
sch.save(schematic_path)
logger.info(
"Added %s label '%s' at (%.1f, %.1f) in %s",
@ -589,7 +495,7 @@ def add_label(
"label_type": label_type,
"position": {"x": x, "y": y},
"schematic_path": schematic_path,
"engine": "sexp-direct",
"engine": _get_schematic_engine(),
}
except Exception as e:
logger.error("Failed to add label '%s' in %s: %s", text, schematic_path, e)
@ -625,10 +531,6 @@ def add_hierarchical_sheet(
Returns:
Dictionary with ``success`` and sheet metadata.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
@ -641,12 +543,14 @@ def add_hierarchical_sheet(
}
try:
sch = _ksa_load(schematic_path)
sheet_uuid = sch.add_sheet(
sch = load_schematic(schematic_path)
sheet_result = sch.add_sheet(
name=name,
filename=filename,
position=(x, y),
size=(width, height),
x=x,
y=y,
width=width,
height=height,
)
parent_uuid = sch.uuid
sch.save(schematic_path)
@ -665,7 +569,7 @@ def add_hierarchical_sheet(
"success": True,
"sheet_name": name,
"sheet_filename": filename,
"sheet_uuid": sheet_uuid,
"sheet_uuid": sheet_result["uuid"],
"parent_uuid": parent_uuid,
"position": {"x": x, "y": y},
"size": {"width": width, "height": height},
@ -687,7 +591,7 @@ def list_components(
When called without ``reference``, returns all components. For large
schematics (>{threshold} components), a compact summary is returned
inline and the full list is written to ``.mckicad/components.json``
read that file for complete data.
-- read that file for complete data.
When ``reference`` is provided, returns only that component's details
(always inline, always compact).
@ -701,9 +605,6 @@ def list_components(
Dictionary with ``success``, ``count``, and either inline
``components`` list or a ``detail_file`` path.
""".replace("{threshold}", str(INLINE_RESULT_THRESHOLD))
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
@ -711,23 +612,16 @@ def list_components(
return verr
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
components: list[dict[str, Any]] = []
for comp in sch.components:
entry: dict[str, Any] = {
"reference": getattr(comp, "reference", None),
"lib_id": getattr(comp, "lib_id", None),
"value": getattr(comp, "value", None),
"reference": comp.reference,
"lib_id": comp.lib_id,
"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)
# Single-component lookup
@ -749,7 +643,7 @@ def list_components(
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:
prefix_counts = Counter(
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
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.
Args:
@ -795,83 +689,43 @@ def get_schematic_info(schematic_path: str) -> dict[str, Any]:
Dictionary with ``success``, ``statistics``, ``validation`` summary,
``unique_symbol_count``, and optionally a ``detail_file`` path.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
# Gather statistics
stats = sch.get_statistics()
# Supplement label statistics — sch.get_statistics() often reports 0
# for labels because the raw data parser doesn't count them.
# Use sch.labels.get_statistics() for accurate counts.
with contextlib.suppress(Exception):
label_stats = sch.labels.get_statistics()
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)
)
# Supplement text_elements for backwards compatibility
stats["text_elements"] = {
"label": stats.get("labels", 0),
"global_label": stats.get("global_labels", 0),
"total_text_elements": stats.get("labels", 0) + stats.get("global_labels", 0),
}
# Run validation
issues = sch.validate()
# Collect symbol details (for sidecar file)
# Collect symbol details
lib_ids_seen: set[str] = set()
symbol_details: list[dict[str, Any]] = []
for comp in sch.components:
lid = getattr(comp, "lib_id", None)
lid = comp.lib_id
if lid and lid not in lib_ids_seen:
lib_ids_seen.add(lid)
try:
info = _ksa_get_symbol_info(lid)
if isinstance(info, dict):
info = _lr_get_info(lid)
if info is not None:
symbol_details.append(info)
else:
symbol_details.append(
{
"lib_id": lid,
"name": getattr(info, "name", str(info)),
"description": getattr(info, "description", None),
"pin_count": getattr(info, "pin_count", None),
}
)
symbol_details.append({"lib_id": lid, "lookup_failed": True})
except Exception:
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
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
properties, pin list, footprint, and validation results.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
comp = sch.components.get(reference)
if comp is None:
return {
@ -949,119 +799,43 @@ def get_component_detail(schematic_path: str, reference: str) -> dict[str, Any]:
detail: dict[str, Any] = {
"reference": reference,
"lib_id": getattr(comp, "lib_id", None),
"value": getattr(comp, "value", None),
"footprint": getattr(comp, "footprint", None),
"lib_id": comp.lib_id,
"value": comp.value,
"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
try:
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
detail["properties"] = comp.properties
# Pins — use get_symbol_definition().pins for metadata and
# sch.list_component_pins() for schematic-transformed positions.
# comp.pins is empty on loaded schematics (kicad-sch-api gap).
# Pins via SchDocument.list_component_pins()
pins: list[dict[str, Any]] = []
pin_data = sch.list_component_pins(reference)
for pin_num, pos in pin_data:
pins.append({
"number": pin_num,
"position": {"x": pos.x, "y": pos.y},
})
pin_positions: dict[str, Any] = {}
with contextlib.suppress(Exception):
for pin_num, pos in sch.list_component_pins(reference):
pin_positions[str(pin_num)] = pos
raw_pins: list[Any] | None = None
with contextlib.suppress(Exception):
sym_def = comp.get_symbol_definition()
if sym_def is not None:
raw_pins = list(sym_def.pins)
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)
# Enrich pins with name/type from lib_resolver if available
lib_info = _lr_get_info(comp.lib_id)
if lib_info and "pins" in lib_info:
lib_pins_by_num = {
str(p["number"]): p for p in lib_info["pins"]
}
for pin in pins:
lib_pin = lib_pins_by_num.get(str(pin["number"]))
if lib_pin:
pin["name"] = lib_pin.get("name")
pin["type"] = lib_pin.get("type")
detail["pins"] = pins
detail["pin_count"] = len(pins)
# BOM/board flags
detail["in_bom"] = getattr(comp, "in_bom", None)
detail["on_board"] = getattr(comp, "on_board", None)
# Validation
try:
if hasattr(comp, "validate"):
detail["validation"] = comp.validate()
except Exception:
detail["validation"] = None
detail["in_bom"] = comp.in_bom
detail["on_board"] = comp.on_board
logger.info("Got detail for %s in %s", reference, schematic_path)
return {
@ -1080,7 +854,7 @@ def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
"""Get the hierarchical sheet tree of a KiCad schematic.
Returns the sheet structure with filenames and per-sheet component
counts. Essential for multi-sheet designs use this to understand
counts. Essential for multi-sheet designs -- use this to understand
the design's organization before diving into specific sheets.
Args:
@ -1089,17 +863,13 @@ def get_schematic_hierarchy(schematic_path: str) -> dict[str, Any]:
Returns:
Dictionary with ``success`` and a ``hierarchy`` tree of sheets.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
schematic_dir = os.path.dirname(schematic_path)
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):
return -1
try:
sheet_sch = _ksa_load(filepath)
return len(list(sheet_sch.components))
sheet_sch = load_schematic(filepath)
return len(sheet_sch.components)
except Exception:
return -1
def _walk_hierarchy(node: dict[str, Any], base_dir: str) -> dict[str, Any]:
"""Convert a SheetManager hierarchy node into our output format."""
filename = node.get("filename", "")
info: dict[str, Any] = {
"name": node.get("name", "unknown"),
"filename": filename,
}
def _walk_sheets(sheets: list, base_dir: str) -> list[dict[str, Any]]:
"""Recursively walk sheets to build the hierarchy tree."""
result_sheets: list[dict[str, Any]] = []
for sheet in sheets:
filename = sheet.filename
info: dict[str, Any] = {
"name": sheet.name,
"filename": filename,
}
if filename:
filepath = os.path.join(base_dir, filename)
count = _count_components(filepath)
if count >= 0:
info["component_count"] = count
if filename:
filepath = os.path.join(base_dir, filename)
count = _count_components(filepath)
if count >= 0:
info["component_count"] = count
else:
info["component_count"] = 0
info["note"] = "file not found or failed to load"
# Recurse into child sheets
if os.path.isfile(filepath):
with contextlib.suppress(Exception):
child_sch = load_schematic(filepath)
if child_sch.sheets:
child_dir = os.path.dirname(filepath)
info["sheets"] = _walk_sheets(child_sch.sheets, child_dir)
else:
info["component_count"] = 0
info["note"] = "file not found or failed to load"
else:
info["component_count"] = 0
children = node.get("children", [])
if children:
info["sheets"] = [_walk_hierarchy(child, base_dir) for child in children]
info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in info["sheets"]
)
else:
info["total_sheets"] = 1
# Count total sheets
child_sheets = info.get("sheets", [])
if child_sheets:
info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in child_sheets
)
else:
info["total_sheets"] = 1
return info
result_sheets.append(info)
# Use SheetManager API — sch.sheets.get_sheet_hierarchy() returns a
# tree with children already identified from the S-expression data.
hierarchy_data = None
with contextlib.suppress(Exception):
hierarchy_data = sch.sheets.get_sheet_hierarchy()
return result_sheets
if hierarchy_data and isinstance(hierarchy_data, dict) and "root" in hierarchy_data:
root_node = hierarchy_data["root"]
root_info: dict[str, Any] = {
"name": "root",
"filename": os.path.basename(schematic_path),
"component_count": len(sch.components),
}
root_info: dict[str, Any] = {
"name": "root",
"filename": os.path.basename(schematic_path),
"component_count": len(list(sch.components)),
}
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
if sch.sheets:
root_info["sheets"] = _walk_sheets(sch.sheets, schematic_dir)
root_info["total_sheets"] = 1 + sum(
c.get("total_sheets", 1) for c in root_info["sheets"]
)
else:
# Fallback: report just the root sheet
hierarchy = {
"name": "root",
"filename": os.path.basename(schematic_path),
"component_count": len(list(sch.components)),
"total_sheets": 1,
"note": "Sheet hierarchy API unavailable — only root sheet reported",
}
root_info["total_sheets"] = 1
hierarchy = root_info
logger.info("Got hierarchy for %s: %d total sheets", schematic_path, hierarchy["total_sheets"])
return {

View File

@ -2,9 +2,8 @@
Schematic analysis and export tools for the mckicad MCP server.
Provides ERC checking, connectivity analysis, pin-level queries, netlist
export, and PDF export. Uses kicad-sch-api for structural analysis and
kicad-cli for rule checks and file exports, falling back gracefully when
either dependency is unavailable.
export, and PDF export. Uses SchDocument for structural analysis and
kicad-cli for rule checks and file exports.
"""
import contextlib
@ -18,8 +17,9 @@ from mckicad.config import INLINE_RESULT_THRESHOLD, TIMEOUT_CONSTANTS
from mckicad.server import mcp
from mckicad.utils.file_utils import write_detail_file
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 (
parse_global_labels,
parse_lib_symbol_pins,
transform_pin_to_schematic,
)
@ -29,55 +29,9 @@ from mckicad.utils.sexp_parser import (
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:
if _HAS_SCH_API:
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))
return "sch_document"
def _resolve_root_schematic(schematic_path: str) -> str | None:
@ -315,14 +269,17 @@ def _build_connectivity_state(
except Exception:
pass
# Global labels from raw file — kicad-sch-api has no sch.global_labels
# attribute, so (global_label ...) nodes are invisible to the API.
# Parse them directly from the S-expression when the file path is known.
if schematic_path:
for gl in parse_global_labels(schematic_path):
coord = _rc(gl["x"], gl["y"])
_find(coord)
label_at[coord] = gl["text"]
# Global labels — SchDocument exposes these natively
try:
for gl in getattr(sch, "global_labels", []):
text = getattr(gl, "text", None)
pos = getattr(gl, "position", None)
if text and pos:
coord = _rc(*_coord_from_point(pos))
_find(coord)
label_at[coord] = str(text)
except Exception:
pass
# Power symbols — their value acts as a net name (e.g. GND, VCC)
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'.",
}
# --- Attempt 1: kicad-sch-api ---
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 ---
# ERC via kicad-cli
cli_path, cli_err = _require_kicad_cli()
if cli_err:
# Neither engine is available
api_err = _require_sch_api()
if api_err:
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
return {
"success": False,
"error": "kicad-cli not found. Ensure kicad-cli is on your PATH.",
}
assert cli_path is not None
try:
@ -630,18 +560,13 @@ def analyze_connectivity(schematic_path: str) -> dict[str, Any]:
Dictionary with ``net_count``, ``connection_count``,
``unconnected_pins``, and ``detail_file``.
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
net_graph, unconnected = _build_connectivity(sch, schematic_path)
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
``{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)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find which net this pin belongs to
@ -775,18 +695,13 @@ def verify_pins_connected(
Dictionary with ``connected`` boolean, plus ``from`` and ``to``
endpoint descriptions.
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
net_graph, _unconnected = _build_connectivity(sch, schematic_path)
# Find the net for each pin
@ -851,18 +766,13 @@ def get_component_pins(
Dictionary with ``reference``, ``pin_count``, and ``pins`` list
where each entry describes one pin.
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
# Locate the component
comp = None
@ -1239,10 +1149,6 @@ def audit_wiring(
(unconnected pins, high-fanout nets, auto-named nets), and optionally
``detail_file`` when wire data is offloaded.
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
@ -1253,7 +1159,7 @@ def audit_wiring(
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
state = _build_connectivity_state(sch, schematic_path)
find_fn = state["find"]
@ -1430,10 +1336,6 @@ def verify_connectivity(
Dictionary with ``verified`` count, ``failed`` count, and ``results``
list with per-net status (``match``, ``mismatch``, ``missing_net``).
"""
err = _require_sch_api()
if err:
return err
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
@ -1444,7 +1346,7 @@ def verify_connectivity(
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
state = _build_connectivity_state(sch, schematic_path)
net_graph = state["net_graph"]
@ -1666,18 +1568,9 @@ def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
except Exception as exc:
logger.warning("Netlist-based connectivity failed: %s", exc)
# Fallback: single-file kicad-sch-api 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,
}
# Fallback: single-file SchDocument analysis
try:
sch = _ksa_load(expanded)
sch = load_schematic(expanded)
net_graph, unconnected = _build_connectivity(sch, expanded)
total_connections = sum(len(pins) for pins in net_graph.values())
return {
@ -1685,7 +1578,7 @@ def _run_connectivity_raw(schematic_path: str) -> dict[str, Any]:
"net_count": len(net_graph),
"connection_count": total_connections,
"unconnected_pins": len(unconnected),
"engine": "kicad-sch-api",
"engine": "sch_document",
}
except Exception as exc:
return {

View File

@ -2,18 +2,19 @@
Schematic editing tools for modifying existing elements in KiCad schematics.
Complements the creation-oriented tools in schematic.py with modification,
removal, and annotation capabilities. Uses the same kicad-sch-api engine
and follows the same swap-point pattern for future kipy IPC support.
removal, and annotation capabilities.
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 os
from typing import Any
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 (
parse_wire_segments,
remove_sexp_blocks_by_uuid,
@ -21,66 +22,9 @@ from mckicad.utils.sexp_parser import (
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:
"""Return the name of the active schematic manipulation engine."""
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))
return "sch_document"
# ---------------------------------------------------------------------------
@ -123,14 +67,10 @@ def modify_component(
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not reference:
return {"success": False, "error": "reference must be a non-empty string"}
@ -143,7 +83,7 @@ def modify_component(
}
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
comp = sch.components.get(reference)
if comp is None:
return {
@ -162,11 +102,11 @@ def modify_component(
modified.append(f"rotation={rotation}")
if value is not None:
comp.value = value
comp.set_value(value)
modified.append(f"value={value}")
if footprint is not None:
comp.footprint = footprint
comp.set_footprint(footprint)
modified.append(f"footprint={footprint}")
if properties:
@ -175,11 +115,11 @@ def modify_component(
modified.append(f"property[{key}]={val}")
if in_bom is not None:
comp.in_bom = in_bom
comp.set_in_bom(in_bom)
modified.append(f"in_bom={in_bom}")
if on_board is not None:
comp.on_board = on_board
comp.set_on_board(on_board)
modified.append(f"on_board={on_board}")
if not modified:
@ -227,20 +167,16 @@ def remove_component(schematic_path: str, reference: str) -> dict[str, Any]:
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not reference:
return {"success": False, "error": "reference must be a non-empty string"}
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
removed = sch.components.remove(reference)
if removed is False:
return {
@ -283,20 +219,16 @@ def remove_wire(schematic_path: str, wire_id: str) -> dict[str, Any]:
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not wire_id:
return {"success": False, "error": "wire_id must be a non-empty string"}
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
sch.remove_wire(wire_id)
sch.save(schematic_path)
@ -332,20 +264,16 @@ def remove_label(schematic_path: str, label_id: str) -> dict[str, Any]:
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not label_id:
return {"success": False, "error": "label_id must be a non-empty string"}
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
sch.remove_label(label_id)
sch.save(schematic_path)
@ -392,14 +320,10 @@ def set_title_block(
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
# Build kwargs for kicad-sch-api's set_title_block().
# API signature: set_title_block(title, date, rev, company, comments)
@ -432,7 +356,7 @@ def set_title_block(
}
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
sch.set_title_block(**kwargs)
sch.save(schematic_path)
@ -470,21 +394,17 @@ def add_text_annotation(
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
if not text:
return {"success": False, "error": "text must be a non-empty string"}
try:
sch = _ksa_load(schematic_path)
text_id = sch.add_text(text=text, position=(x, y))
sch = load_schematic(schematic_path)
text_id = sch.add_text(text, x, y)
sch.save(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:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch.no_connects.add(position=(x, y))
sch = load_schematic(schematic_path)
sch.add_no_connect(x, y)
sch.save(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:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
backup_path = sch.backup()
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 os
from typing import Any
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__)
_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:
return "kicad-sch-api" if _HAS_SCH_API else "none"
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))
return "sch_document"
@mcp.tool()
@ -93,14 +57,10 @@ def place_decoupling_bank_pattern(
Dictionary with placed component references, power symbol details,
and grid bounds.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
# Parse comma-separated values into cap specs
values = [v.strip() for v in cap_values.split(",") if v.strip()]
@ -112,7 +72,7 @@ def place_decoupling_bank_pattern(
try:
from mckicad.patterns.decoupling_bank import place_decoupling_bank
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
result = place_decoupling_bank(
sch=sch,
caps=caps,
@ -173,19 +133,15 @@ def place_pull_resistor_pattern(
Returns:
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)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
from mckicad.patterns.pull_resistor import place_pull_resistor
sch = _ksa_load(schematic_path)
sch = load_schematic(schematic_path)
result = place_pull_resistor(
sch=sch,
signal_ref=signal_ref,
@ -253,19 +209,15 @@ def place_crystal_pattern(
Dictionary with crystal and capacitor references, wire IDs,
and ground symbol details.
"""
err = _require_sch_api()
if err:
return err
schematic_path = _expand(schematic_path)
verr = _validate_schematic_path(schematic_path)
if verr:
return verr
schematic_path = _expand(schematic_path)
try:
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(
sch=sch,
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 tempfile
from typing import Any
import uuid as _uuid_mod
logger = logging.getLogger(__name__)
@ -252,8 +251,7 @@ def _find_component_for_pin(
# Match by unit number
for comp in all_units:
data = getattr(comp, "_data", None)
comp_unit = data.unit if data else 1
comp_unit = getattr(comp, "unit", 1)
if comp_unit == target_unit:
return comp
@ -548,225 +546,11 @@ def _extract_named_section(content: str, keyword: str, name: str) -> str | 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():
"""Context manager that suppresses OSError (for cleanup paths)."""
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
# ---------------------------------------------------------------------------
@ -964,9 +748,11 @@ def resolve_pin_position(
if not sexp_pins:
return None
# Find the matching pin
# 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
# 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
for pin in sexp_pins:
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),
)
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
from mckicad.utils.sch_document import SchDocument
# 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
try:
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):
"""Create a schematic with components for testing edit/analysis tools.
Returns the path to the .kicad_sch file, or None if kicad-sch-api
is not installed.
Returns the path to the .kicad_sch file.
"""
if not _HAS_SCH_API:
pytest.skip("kicad-sch-api not installed")
path = os.path.join(tmp_output_dir, "populated.kicad_sch")
sch = create_schematic("test_populated")
sch = SchDocument.create(path)
# Add several components
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
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")
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:C", reference="C1", value="100nF", position=(200, 100))

View File

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

View File

@ -6,11 +6,9 @@ import re
import pytest
from tests.conftest import requires_sch_api
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):
from mckicad.tools.batch import apply_batch
@ -59,7 +57,6 @@ class TestBatchValidation:
assert result["success"] is False
@requires_sch_api
class TestBatchDryRun:
"""Tests for batch dry_run mode."""
@ -115,14 +112,12 @@ class TestBatchDryRun:
assert result["success"] is False
@requires_sch_api
class TestBatchApply:
"""Integration tests for applying batch operations."""
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.utils.sch_helpers import load_schematic
result = apply_batch(
schematic_path=populated_schematic_with_ic,
@ -181,7 +176,7 @@ class TestBatchApply:
with open(sidecar_path, "w") as f:
json.dump(data, f)
# Use relative path should find it in .mckicad/
# Use relative path -- should find it in .mckicad/
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file="sidecar_batch.json",
@ -219,7 +214,6 @@ class TestBatchApply:
assert '(global_label "BATCH_GLOBAL"' in content
@requires_sch_api
class TestBatchPinRefLabels:
"""Tests for pin-referenced label placement in batch operations."""
@ -324,7 +318,6 @@ class TestBatchPinRefLabels:
assert "(wire\n" in content
@requires_sch_api
class TestBatchLabelConnections:
"""Tests for label_connections batch operations."""
@ -422,8 +415,6 @@ class TestBatchLabelConnections:
content = f.read()
# Two labels with same text
import re
matches = re.findall(r'\(global_label "MULTI_PIN_NET"', content)
assert len(matches) == 2
@ -432,7 +423,6 @@ class TestBatchLabelConnections:
assert len(wire_matches) >= 2
@requires_sch_api
class TestBatchLabelOffset:
"""Tests for label auto-offset and direction override."""
@ -464,20 +454,23 @@ class TestBatchLabelOffset:
content = f.read()
# Wire stub should be 7.62mm long (3 grid units), not 2.54mm
# Check that xy coordinates in the wire span ~7.62mm
wire_matches = re.findall(
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content,
# sexp_tree serializer puts each (xy ...) on its own line,
# so we extract pairs of consecutive xy nodes within (pts ...)
xy_matches = re.findall(
r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
)
assert len(wire_matches) >= 1
for x1, y1, x2, y2 in wire_matches:
dx = abs(float(x2) - float(x1))
dy = abs(float(y2) - float(y1))
# Process consecutive xy pairs (each wire has 2 xy nodes)
found_stub = False
for i in range(0, len(xy_matches) - 1, 2):
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
# At least one stub should be ~7.62mm
if abs(length - 7.62) < 0.5:
found_stub = True
break
else:
pytest.fail("No wire stub found with ~7.62mm length")
assert found_stub, "No wire stub found with ~7.62mm length"
def test_custom_stub_length_per_connection(
self, populated_schematic_with_ic, tmp_output_dir,
@ -508,18 +501,20 @@ class TestBatchLabelOffset:
with open(populated_schematic_with_ic) as f:
content = f.read()
wire_matches = re.findall(
r'\(xy ([\d.]+) ([\d.]+)\) \(xy ([\d.]+) ([\d.]+)\)', content,
xy_matches = re.findall(
r'\(xy ([\d.-]+) ([\d.-]+)\)', content,
)
assert len(wire_matches) >= 1
for x1, y1, x2, y2 in wire_matches:
dx = abs(float(x2) - float(x1))
dy = abs(float(y2) - float(y1))
found_stub = False
for i in range(0, len(xy_matches) - 1, 2):
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
if abs(length - 12.7) < 0.5:
found_stub = True
break
else:
pytest.fail("No wire stub found with ~12.7mm length")
assert found_stub, "No wire stub found with ~12.7mm length"
def test_direction_override(
self, populated_schematic_with_ic, tmp_output_dir,
@ -550,7 +545,6 @@ class TestBatchLabelOffset:
assert result["labels_placed"] >= 1
@requires_sch_api
class TestBatchPinRefNoConnects:
"""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"])
@requires_sch_api
class TestBatchHierarchyContext:
"""Tests for hierarchy context in batch operations."""
@ -634,8 +627,6 @@ class TestBatchHierarchyContext:
self, populated_schematic_with_ic, tmp_output_dir,
):
"""Passing parent_uuid and sheet_uuid sets hierarchy context on the schematic."""
from unittest.mock import patch
from mckicad.tools.batch import apply_batch
data = {
@ -647,23 +638,21 @@ class TestBatchHierarchyContext:
with open(batch_path, "w") as f:
json.dump(data, f)
with patch("mckicad.tools.batch._ksa_load") as mock_load:
# Let the real load happen but spy on set_hierarchy_context
from kicad_sch_api import load_schematic
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
parent_uuid="aaaa-bbbb-cccc",
sheet_uuid="dddd-eeee-ffff",
)
real_sch = load_schematic(populated_schematic_with_ic)
mock_load.return_value = real_sch
assert result["success"] is True
result = apply_batch(
schematic_path=populated_schematic_with_ic,
batch_file=batch_path,
parent_uuid="aaaa-bbbb-cccc",
sheet_uuid="dddd-eeee-ffff",
)
# Verify the hierarchy path was written to the file
with open(populated_schematic_with_ic) as f:
content = f.read()
assert result["success"] is True
# Verify the hierarchy path was set
assert real_sch._hierarchy_path == "/aaaa-bbbb-cccc/dddd-eeee-ffff"
assert "sheet_instances" in content
assert "/aaaa-bbbb-cccc/dddd-eeee-ffff" in content
def test_no_hierarchy_context_without_params(
self, populated_schematic_with_ic, tmp_output_dir,
@ -688,93 +677,6 @@ class TestBatchHierarchyContext:
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
class TestMultiUnitComponents:
"""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["cap_count"] == 3
assert result["engine"] == "kicad-sch-api"
assert result["engine"] == "sch_document"
def test_empty_values_rejected(self, populated_schematic_with_ic):
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_pin"] == "2"
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):
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
from tests.conftest import requires_sch_api
@pytest.mark.unit
def test_create_schematic(tmp_output_dir):
"""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
@requires_sch_api
@pytest.mark.unit
def test_list_components_single_lookup(populated_schematic):
"""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"
@requires_sch_api
@pytest.mark.unit
def test_list_components_single_lookup_not_found(populated_schematic):
"""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
@requires_sch_api
@pytest.mark.unit
def test_get_schematic_info_compact(populated_schematic):
"""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
@requires_sch_api
@pytest.mark.unit
def test_get_component_detail(populated_schematic):
"""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"
@requires_sch_api
@pytest.mark.unit
def test_get_component_detail_not_found(populated_schematic):
"""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
@requires_sch_api
@pytest.mark.unit
def test_get_schematic_hierarchy(populated_schematic):
"""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
@requires_sch_api
@pytest.mark.unit
def test_add_hierarchical_sheet_returns_uuids(tmp_output_dir):
"""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)
assert result["success"] is True
assert result["label_type"] == "local"
assert result["engine"] == "sexp-direct"
assert result["engine"] == "sch_document"
with open(path) as f:
content = f.read()
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
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
# Anchor the project in its own subdirectory. The autouse
# _set_test_search_paths fixture drops a second "test_project.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"
# Create a project structure with root + sub-sheet
pro_file = tmp_path / "myproject.kicad_pro"
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')
sub_dir = proj_dir / "sub"
sub_dir = tmp_path / "sub"
sub_dir.mkdir()
sub_sch = sub_dir / "power.kicad_sch"
sub_sch.write_text('(kicad_sch (version 20231120) (uuid "sub"))\n')

View File

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

View File

@ -11,11 +11,6 @@ import pytest
from mckicad.utils.sexp_parser import (
clamp_stub_length,
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_lib_file_symbol_pins,
parse_lib_symbol_pin_units,
@ -357,221 +352,6 @@ class TestResolveLabelCollision:
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:
"""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
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
@ -1545,140 +1281,132 @@ class TestParseLibSymbolPinUnits:
class TestFindComponentForPin:
"""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."""
from kicad_sch_api import load_schematic
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
path = str(tmp_path / "multi.kicad_sch")
with open(path, "w") as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
sch = load_schematic(path)
sch.components.add(
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,
)
sch = SchDocument(path)
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)
# Pin 8 → unit 3
comp = _find_component_for_pin(
sch, "U2", "8", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 3
# Reload to ensure sexp is parsed
sch = SchDocument(path)
# Pin 1 → unit 1
comp = _find_component_for_pin(
sch, "U2", "1", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 1
# Pin 8 → unit 3
comp = _find_component_for_pin(
sch, "U2", "8", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp.unit == 3
# Pin 5 → unit 2
comp = _find_component_for_pin(
sch, "U2", "5", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp._data.unit == 2
finally:
os.unlink(path)
# Pin 1 → unit 1
comp = _find_component_for_pin(
sch, "U2", "1", path, "Amplifier_Operational:TL072",
)
assert comp is not None
assert comp.unit == 1
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()."""
from kicad_sch_api import load_schematic
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import _find_component_for_pin
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
path = str(tmp_path / "single.kicad_sch")
with open(path, "w") as f:
f.write(SAMPLE_SCHEMATIC)
path = f.name
try:
sch = load_schematic(path)
sch.components.add(
lib_id="Device:R", reference="R1",
value="10k", position=(100, 100),
)
sch = SchDocument(path)
sch.components.add(
lib_id="Device:R", reference="R1",
value="10k", x=100, y=100,
)
sch.save(path)
comp = _find_component_for_pin(
sch, "R1", "1", path, "Device:R",
)
assert comp is not None
assert comp.reference == "R1"
finally:
os.unlink(path)
sch = SchDocument(path)
comp = _find_component_for_pin(
sch, "R1", "1", path, "Device:R",
)
assert comp is not None
assert comp.reference == "R1"
class TestMultiUnitPinResolution:
"""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."""
from mckicad.utils.sch_document import SchDocument
from mckicad.utils.sexp_parser import resolve_pin_position
with tempfile.NamedTemporaryFile(
suffix=".kicad_sch", mode="w", delete=False,
) as f:
path = str(tmp_path / "multi_resolve.kicad_sch")
with open(path, "w") as f:
f.write(MULTI_UNIT_SCHEMATIC)
path = f.name
try:
from kicad_sch_api import load_schematic
sch = SchDocument(path)
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)
sch.components.add(
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,
)
# Reload for clean state
sch = SchDocument(path)
# Pin 8 (V+) → unit 3 at y=175
pos_8 = resolve_pin_position(sch, path, "U2", "8")
assert pos_8 is not None
# Y should be near 175 (unit 3), not near 102 (unit 1)
assert abs(pos_8[1] - 175) < 10, (
f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)"
)
# Pin 8 (V+) → unit 3 at y=175
pos_8 = resolve_pin_position(sch, path, "U2", "8")
assert pos_8 is not None
# Y should be near 175 (unit 3), not near 102 (unit 1)
assert abs(pos_8[1] - 175) < 10, (
f"Pin 8 y={pos_8[1]:.1f}, expected near 175 (unit 3)"
)
# Pin 1 (output) → unit 1 at y=102
pos_1 = resolve_pin_position(sch, path, "U2", "1")
assert pos_1 is not None
assert abs(pos_1[1] - 102) < 10, (
f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)"
)
# Pin 1 (output) → unit 1 at y=102
pos_1 = resolve_pin_position(sch, path, "U2", "1")
assert pos_1 is not None
assert abs(pos_1[1] - 102) < 10, (
f"Pin 1 y={pos_1[1]:.1f}, expected near 102 (unit 1)"
)
# Pin 5 (+In) → unit 2 at y=145
pos_5 = resolve_pin_position(sch, path, "U2", "5")
assert pos_5 is not None
assert abs(pos_5[1] - 145) < 10, (
f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)"
)
finally:
os.unlink(path)
# Pin 5 (+In) → unit 2 at y=145
pos_5 = resolve_pin_position(sch, path, "U2", "5")
assert pos_5 is not None
assert abs(pos_5[1] - 145) < 10, (
f"Pin 5 y={pos_5[1]:.1f}, expected near 145 (unit 2)"
)

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 = [
{ 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/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/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/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" },
]
@ -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" },
]
[[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]]
name = "jsonref"
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" },
]
[[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]]
name = "librt"
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" },
]
[[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]]
name = "mckicad"
version = "2026.3.3"
@ -770,7 +710,6 @@ dependencies = [
{ name = "defusedxml" },
{ name = "fastmcp" },
{ name = "kicad-python" },
{ name = "kicad-sch-api" },
{ name = "pyyaml" },
{ name = "requests" },
]
@ -790,7 +729,6 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "fastmcp", specifier = ">=3.1.0" },
{ name = "kicad-python", specifier = ">=0.5.0" },
{ name = "kicad-sch-api", specifier = ">=0.5.6" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ 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" },
]
[[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]]
name = "sniffio"
version = "1.3.1"