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:
parent
6c6c85b7dc
commit
57872e59c1
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
541
src/mckicad/utils/lib_resolver.py
Normal file
541
src/mckicad/utils/lib_resolver.py
Normal 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)
|
||||
1147
src/mckicad/utils/sch_document.py
Normal file
1147
src/mckicad/utils/sch_document.py
Normal file
File diff suppressed because it is too large
Load Diff
40
src/mckicad/utils/sch_helpers.py
Normal file
40
src/mckicad/utils/sch_helpers.py
Normal 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))
|
||||
@ -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"
|
||||
)
|
||||
|
||||
410
src/mckicad/utils/sexp_tree.py
Normal file
410
src/mckicad/utils/sexp_tree.py
Normal 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)
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
458
tests/test_lib_resolver.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
1582
tests/test_sch_document.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
668
tests/test_sexp_tree.py
Normal 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
83
uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user