From ccd817531a67c4acb26424f2b1700be81e8940c6 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 15:37:52 +0000 Subject: [PATCH 1/2] feat: add get_schematic_component tool and fieldPositions to edit_schematic_component - New `get_schematic_component` MCP tool returns component position and all field values with their label (at x/y/angle) positions - Extends `edit_schematic_component` with optional `fieldPositions` dict so callers can reposition Reference/Value/etc. labels in one call - Adds 18 tests (6 unit, 12 integration) covering parsing, round-trips, and edge cases Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 117 ++++++- .../tests/test_schematic_component_fields.py | 302 ++++++++++++++++++ src/tools/schematic.ts | 40 +++ 3 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 python/tests/test_schematic_component_fields.py diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 40ca581..7d1f02a 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -372,6 +372,7 @@ class KiCADInterface: "add_schematic_component": self._handle_add_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component, "edit_schematic_component": self._handle_edit_schematic_component, + "get_schematic_component": self._handle_get_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, @@ -832,6 +833,7 @@ class KiCADInterface: new_footprint = params.get("footprint") new_value = params.get("value") new_reference = params.get("newReference") + field_positions = params.get("fieldPositions") # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} if not schematic_path: return {"success": False, "message": "schematicPath is required"} @@ -842,11 +844,12 @@ class KiCADInterface: new_footprint is not None, new_value is not None, new_reference is not None, + field_positions is not None, ] ): return { "success": False, - "message": "At least one of footprint, value, or newReference must be provided", + "message": "At least one of footprint, value, newReference, or fieldPositions must be provided", } sch_file = Path(schematic_path) @@ -930,6 +933,16 @@ class KiCADInterface: rf'\1"{new_reference}"', block_text, ) + if field_positions is not None: + for field_name, pos in field_positions.items(): + x = pos.get("x", 0) + y = pos.get("y", 0) + angle = pos.get("angle", 0) + block_text = re.sub( + r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf'\1(at {x} {y} {angle})', + block_text, + ) content = content[:block_start] + block_text + content[block_end + 1 :] @@ -945,6 +958,8 @@ class KiCADInterface: }.items() if v is not None } + if field_positions is not None: + changes["fieldPositions"] = field_positions logger.info(f"Edited schematic component {reference}: {changes}") return {"success": True, "reference": reference, "updated": changes} @@ -955,6 +970,106 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_schematic_component(self, params): + """Return full component info: position and all field values with their (at x y angle) positions.""" + logger.info("Getting schematic component info") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + def find_matching_paren(s, start): + depth = 0 + i = start + while i < len(s): + if s[i] == "(": + depth += 1 + elif s[i] == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + + # Find the placed symbol block for this reference + block_start = block_end = None + search_start = 0 + pattern = re.compile(r'\(symbol\s+\(lib_id\s+"') + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 + continue + end = find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos: end + 1] + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start, block_end = pos, end + break + search_start = end + 1 + + if block_start is None: + return {"success": False, "message": f"Component '{reference}' not found in schematic"} + + block_text = content[block_start: block_end + 1] + + # Extract component position: first (at x y angle) in the symbol header line + comp_at = re.search(r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', block_text) + if comp_at: + comp_pos = {"x": float(comp_at.group(1)), "y": float(comp_at.group(2)), "angle": float(comp_at.group(3))} + else: + comp_pos = None + + # Extract all properties with their at positions + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + fields[name] = { + "value": value, + "x": float(x), + "y": float(y), + "angle": float(angle), + } + + return { + "success": True, + "reference": reference, + "position": comp_pos, + "fields": fields, + } + + except Exception as e: + logger.error(f"Error getting schematic component: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_add_schematic_wire(self, params): """Add a wire to a schematic using WireManager""" logger.info("Adding wire to schematic") diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py new file mode 100644 index 0000000..0a3da02 --- /dev/null +++ b/python/tests/test_schematic_component_fields.py @@ -0,0 +1,302 @@ +""" +Tests for get_schematic_component and edit_schematic_component fieldPositions support. +""" +import re +import sys +import shutil +import tempfile +from pathlib import Path + +import pytest + +# Ensure python/ directory is on path so kicad_interface can be imported +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Helpers shared across tests +# --------------------------------------------------------------------------- + +TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + +# Minimal placed-symbol block we can embed into a schematic for testing +PLACED_RESISTOR_BLOCK = '''\ + (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +''' + + +def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: + """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" + dest = tmp_dir / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + # Insert placed symbol block before the closing paren of the top-level form + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestGetSchematicComponentParsing: + """Unit tests for the regex logic used by _handle_get_schematic_component.""" + + def _parse_fields(self, block_text: str) -> dict: + """Mirrors the regex used in _handle_get_schematic_component.""" + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + fields[name] = {"value": value, "x": float(x), "y": float(y), "angle": float(angle)} + return fields + + def _parse_comp_pos(self, block_text: str): + """Mirrors the regex used to extract symbol position.""" + m = re.search( + r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', + block_text, + ) + if m: + return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))} + return None + + def test_parses_reference_field(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Reference" in fields + assert fields["Reference"]["value"] == "R1" + assert fields["Reference"]["x"] == pytest.approx(51.27) + assert fields["Reference"]["y"] == pytest.approx(47.46) + assert fields["Reference"]["angle"] == pytest.approx(0.0) + + def test_parses_value_field(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Value" in fields + assert fields["Value"]["value"] == "10k" + assert fields["Value"]["x"] == pytest.approx(51.27) + assert fields["Value"]["y"] == pytest.approx(52.54) + + def test_parses_all_four_standard_fields(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} + + def test_parses_component_position(self): + pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) + assert pos is not None + assert pos["x"] == pytest.approx(50.0) + assert pos["y"] == pytest.approx(50.0) + assert pos["angle"] == pytest.approx(0.0) + + def test_field_position_regex_replaces_correctly(self): + """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" + field_name = "Reference" + new_x, new_y, new_angle = 99.0, 88.0, 0 + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf'\1(at {new_x} {new_y} {new_angle})', + block, + ) + fields = self._parse_fields(block) + assert fields["Reference"]["x"] == pytest.approx(99.0) + assert fields["Reference"]["y"] == pytest.approx(88.0) + # Value should be unchanged + assert fields["Value"]["x"] == pytest.approx(51.27) + + def test_field_position_regex_preserves_value(self): + """Replacing position must not change the field value string.""" + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + r'\1(at 0.0 0.0 0)', + block, + ) + fields = self._parse_fields(block) + assert fields["Value"]["value"] == "10k" + + +# --------------------------------------------------------------------------- +# Integration tests – real file I/O using the empty.kicad_sch template +# --------------------------------------------------------------------------- + +@pytest.mark.integration +class TestGetSchematicComponentIntegration: + """Integration tests: write a real .kicad_sch and call the handler.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path): + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self): + """Lazily import KiCADInterface to avoid pcbnew import at collection time.""" + from kicad_interface import KiCADInterface + return KiCADInterface() + + def test_get_returns_success(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["success"] is True + + def test_get_returns_correct_reference(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["reference"] == "R1" + + def test_get_returns_component_position(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["position"] is not None + assert result["position"]["x"] == pytest.approx(50.0) + assert result["position"]["y"] == pytest.approx(50.0) + + def test_get_returns_reference_field_position(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + ref_field = result["fields"]["Reference"] + assert ref_field["value"] == "R1" + assert ref_field["x"] == pytest.approx(51.27) + assert ref_field["y"] == pytest.approx(47.46) + + def test_get_returns_value_field(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + val_field = result["fields"]["Value"] + assert val_field["value"] == "10k" + assert val_field["x"] == pytest.approx(51.27) + assert val_field["y"] == pytest.approx(52.54) + + def test_get_unknown_reference_returns_failure(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R99", + }) + assert result["success"] is False + assert "R99" in result["message"] + + def test_get_missing_path_returns_failure(self): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "reference": "R1", + }) + assert result["success"] is False + + +@pytest.mark.integration +class TestEditSchematicComponentFieldPositions: + """Integration tests for the new fieldPositions parameter.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path): + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self): + from kicad_interface import KiCADInterface + return KiCADInterface() + + def test_reposition_reference_label(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, + }) + assert result["success"] is True + + # Verify the position was actually written + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) + assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) + + def test_reposition_does_not_change_value(self, sch_with_r1): + iface = self._get_interface() + iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, + }) + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + # Value field position must be unchanged + assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) + assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) + + def test_reposition_multiple_fields(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": { + "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, + "Value": {"x": 10.0, "y": 30.0, "angle": 0}, + }, + }) + assert result["success"] is True + + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) + assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) + + def test_fieldpositions_alone_is_valid(self, sch_with_r1): + """fieldPositions without value/footprint/newReference should succeed.""" + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, + }) + assert result["success"] is True + + def test_no_params_still_fails(self, sch_with_r1): + """Providing no update params should return an error.""" + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["success"] is False diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 65ebc44..ebe5782 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -160,6 +160,11 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), + fieldPositions: z.record(z.object({ + x: z.number(), + y: z.number(), + angle: z.number().optional().default(0), + })).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"), }, async (args: { schematicPath: string; @@ -167,6 +172,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp footprint?: string; value?: string; newReference?: string; + fieldPositions?: Record; }) => { const result = await callKicadScript("edit_schematic_component", args); if (result.success) { @@ -193,6 +199,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Get component properties and field positions from schematic + server.tool( + "get_schematic_component", + "Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Component reference designator (e.g. R1, U1)"), + }, + async (args: { schematicPath: string; reference: string }) => { + const result = await callKicadScript("get_schematic_component", args); + if (result.success) { + const pos = result.position + ? `(${result.position.x}, ${result.position.y}, angle=${result.position.angle}°)` + : "unknown"; + const fieldLines = Object.entries(result.fields ?? {}).map( + ([name, f]: [string, any]) => + ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)` + ); + return { + content: [{ + type: "text", + text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, + }], + }; + } + return { + content: [{ + type: "text", + text: `Failed to get component: ${result.message || "Unknown error"}`, + }], + }; + }, + ); + // Connect components with wire server.tool( "add_wire", From 22a7561f5cfe45aaa4f6fcdc83c8b055b89fde65 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 22:33:50 +0000 Subject: [PATCH 2/2] style: apply Black formatting to changed files Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 240 ++++++++++++++---- .../tests/test_schematic_component_fields.py | 213 ++++++++++------ 2 files changed, 324 insertions(+), 129 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 7d1f02a..9970a45 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -524,11 +524,24 @@ class KiCADInterface: # Board-mutating commands that trigger auto-save on SWIG path _BOARD_MUTATING_COMMANDS = { - "place_component", "move_component", "rotate_component", "delete_component", - "route_trace", "route_pad_to_pad", "add_via", "delete_trace", "add_net", - "add_board_outline", "add_mounting_hole", "add_text", "add_board_text", - "add_copper_pour", "refill_zones", "import_svg_logo", - "sync_schematic_to_board", "connect_passthrough", + "place_component", + "move_component", + "rotate_component", + "delete_component", + "route_trace", + "route_pad_to_pad", + "add_via", + "delete_trace", + "add_net", + "add_board_outline", + "add_mounting_hole", + "add_text", + "add_board_text", + "add_copper_pour", + "refill_zones", + "import_svg_logo", + "sync_schematic_to_board", + "connect_passthrough", } def _auto_save_board(self): @@ -833,7 +846,9 @@ class KiCADInterface: new_footprint = params.get("footprint") new_value = params.get("value") new_reference = params.get("newReference") - field_positions = params.get("fieldPositions") # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} + field_positions = params.get( + "fieldPositions" + ) # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} if not schematic_path: return {"success": False, "message": "schematicPath is required"} @@ -939,8 +954,10 @@ class KiCADInterface: y = pos.get("y", 0) angle = pos.get("angle", 0) block_text = re.sub( - r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - rf'\1(at {x} {y} {angle})', + r'(\(property\s+"' + + re.escape(field_name) + + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf"\1(at {x} {y} {angle})", block_text, ) @@ -987,7 +1004,10 @@ class KiCADInterface: sch_file = Path(schematic_path) if not sch_file.exists(): - return {"success": False, "message": f"Schematic not found: {schematic_path}"} + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } with open(sch_file, "r", encoding="utf-8") as f: content = f.read() @@ -1007,7 +1027,9 @@ class KiCADInterface: # Skip lib_symbols section lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + lib_sym_end = ( + find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + ) # Find the placed symbol block for this reference block_start = block_end = None @@ -1025,21 +1047,34 @@ class KiCADInterface: if end < 0: search_start = pos + 1 continue - block_text = content[pos: end + 1] - if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_text = content[pos : end + 1] + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): block_start, block_end = pos, end break search_start = end + 1 if block_start is None: - return {"success": False, "message": f"Component '{reference}' not found in schematic"} + return { + "success": False, + "message": f"Component '{reference}' not found in schematic", + } - block_text = content[block_start: block_end + 1] + block_text = content[block_start : block_end + 1] # Extract component position: first (at x y angle) in the symbol header line - comp_at = re.search(r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', block_text) + comp_at = re.search( + r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', + block_text, + ) if comp_at: - comp_pos = {"x": float(comp_at.group(1)), "y": float(comp_at.group(2)), "angle": float(comp_at.group(3))} + comp_pos = { + "x": float(comp_at.group(1)), + "y": float(comp_at.group(2)), + "angle": float(comp_at.group(3)), + } else: comp_pos = None @@ -1049,7 +1084,13 @@ class KiCADInterface: ) fields = {} for m in prop_pattern.finditer(block_text): - name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + name, value, x, y, angle = ( + m.group(1), + m.group(2), + m.group(3), + m.group(4), + m.group(5), + ) fields[name] = { "value": value, "x": float(x), @@ -1067,6 +1108,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error getting schematic component: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -1457,7 +1499,10 @@ class KiCADInterface: pin_offset = int(params.get("pinOffset", 0)) if not all([schematic_path, source_ref, target_ref]): - return {"success": False, "message": "Missing required parameters: schematicPath, sourceRef, targetRef"} + return { + "success": False, + "message": "Missing required parameters: schematicPath, sourceRef, targetRef", + } result = ConnectionManager.connect_passthrough( Path(schematic_path), source_ref, target_ref, net_prefix, pin_offset @@ -1474,6 +1519,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error in connect_passthrough: {str(e)}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -1488,26 +1534,39 @@ class KiCADInterface: reference = params.get("reference") if not all([schematic_path, reference]): - return {"success": False, "message": "Missing required parameters: schematicPath, reference"} + return { + "success": False, + "message": "Missing required parameters: schematicPath, reference", + } locator = PinLocator() all_pins = locator.get_all_symbol_pins(Path(schematic_path), reference) if not all_pins: - return {"success": False, "message": f"No pins found for {reference} — check reference and schematic path"} + return { + "success": False, + "message": f"No pins found for {reference} — check reference and schematic path", + } # Enrich with pin names and angles from the symbol definition - pins_def = locator.get_symbol_pins( - Path(schematic_path), - locator._get_lib_id(Path(schematic_path), reference), - ) if hasattr(locator, "_get_lib_id") else {} + pins_def = ( + locator.get_symbol_pins( + Path(schematic_path), + locator._get_lib_id(Path(schematic_path), reference), + ) + if hasattr(locator, "_get_lib_id") + else {} + ) result = {} for pin_num, coords in all_pins.items(): entry = {"x": coords[0], "y": coords[1]} if pin_num in pins_def: entry["name"] = pins_def[pin_num].get("name", pin_num) - entry["angle"] = locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0 + entry["angle"] = ( + locator.get_pin_angle(Path(schematic_path), reference, pin_num) + or 0 + ) result[pin_num] = entry return {"success": True, "reference": reference, "pins": result} @@ -1515,6 +1574,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error getting pin locations: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -1562,14 +1622,27 @@ class KiCADInterface: "errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.", } - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp: json_output = tmp.name try: - cmd = [kicad_cli, "sch", "erc", "--format", "json", "--output", json_output, schematic_path] + cmd = [ + kicad_cli, + "sch", + "erc", + "--format", + "json", + "--output", + json_output, + schematic_path, + ] logger.info(f"Running ERC command: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=120 + ) if result.returncode != 0: logger.error(f"ERC command failed: {result.stderr}") @@ -1590,13 +1663,18 @@ class KiCADInterface: items = v.get("items", []) loc = {} if items and "pos" in items[0]: - loc = {"x": items[0]["pos"].get("x", 0), "y": items[0]["pos"].get("y", 0)} - violations.append({ - "type": v.get("type", "unknown"), - "severity": vseverity, - "message": v.get("description", ""), - "location": loc, - }) + loc = { + "x": items[0]["pos"].get("x", 0), + "y": items[0]["pos"].get("y", 0), + } + violations.append( + { + "type": v.get("type", "unknown"), + "severity": vseverity, + "message": v.get("description", ""), + "location": loc, + } + ) if vseverity in severity_counts: severity_counts[vseverity] += 1 @@ -1643,10 +1721,12 @@ class KiCADInterface: def _handle_sync_schematic_to_board(self, params): """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic'). - Reads net connections from the schematic and assigns them to the matching pads in the PCB.""" + Reads net connections from the schematic and assigns them to the matching pads in the PCB. + """ logger.info("Syncing schematic to board") try: from pathlib import Path + schematic_path = params.get("schematicPath") board_path = params.get("boardPath") @@ -1658,7 +1738,10 @@ class KiCADInterface: board = self.board board_path = board.GetFileName() if not board_path else board_path else: - return {"success": False, "message": "No board loaded. Use open_project first or provide boardPath."} + return { + "success": False, + "message": "No board loaded. Use open_project first or provide boardPath.", + } if not board_path: board_path = board.GetFileName() @@ -1675,14 +1758,19 @@ class KiCADInterface: schematic_path = str(sch_files[0]) if not schematic_path or not Path(schematic_path).exists(): - return {"success": False, "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}"} + return { + "success": False, + "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}", + } # Generate netlist from schematic schematic = SchematicManager.load_schematic(schematic_path) if not schematic: return {"success": False, "message": "Failed to load schematic"} - netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path) + netlist = ConnectionManager.generate_netlist( + schematic, schematic_path=schematic_path + ) # Build (reference, pad_number) -> net_name map pad_net_map = {} # {(ref, pin_str): net_name} @@ -1733,7 +1821,9 @@ class KiCADInterface: self.board = board self._update_command_handlers() - logger.info(f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned") + logger.info( + f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned" + ) return { "success": True, "message": f"PCB nets synced from schematic: {len(added_nets)} nets added, {assigned_pads} pads assigned", @@ -1746,6 +1836,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error in sync_schematic_to_board: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -1765,9 +1856,14 @@ class KiCADInterface: filled = bool(params.get("filled", True)) if not pcb_path or not svg_path: - return {"success": False, "message": "Missing required parameters: pcbPath, svgPath"} + return { + "success": False, + "message": "Missing required parameters: pcbPath, svgPath", + } - result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled) + result = import_svg_to_pcb( + pcb_path, svg_path, x, y, width, layer, stroke_width, filled + ) # import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file, # bypassing the pcbnew in-memory board object. Any subsequent board.Save() @@ -1780,13 +1876,16 @@ class KiCADInterface: self._update_command_handlers() logger.info("Reloaded board into pcbnew after SVG logo import") except Exception as reload_err: - logger.warning(f"Board reload after SVG import failed (non-fatal): {reload_err}") + logger.warning( + f"Board reload after SVG import failed (non-fatal): {reload_err}" + ) return result except Exception as e: logger.error(f"Error importing SVG logo: {str(e)}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -1795,9 +1894,10 @@ class KiCADInterface: import shutil from datetime import datetime from pathlib import Path + try: - step = params.get("step", "") - label = params.get("label", "") + step = params.get("step", "") + label = params.get("label", "") prompt_text = params.get("prompt", "") # Determine project directory from loaded board or explicit path project_dir = None @@ -1808,7 +1908,10 @@ class KiCADInterface: if not project_dir: project_dir = params.get("projectPath") if not project_dir or not os.path.isdir(project_dir): - return {"success": False, "message": "Could not determine project directory for snapshot"} + return { + "success": False, + "message": "Could not determine project directory for snapshot", + } ts = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -1818,16 +1921,21 @@ class KiCADInterface: prompt_file = None if prompt_text: - prompt_filename = f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md" + prompt_filename = ( + f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md" + ) prompt_file = logs_dir / prompt_filename prompt_file.write_text(prompt_text, encoding="utf-8") logger.info(f"Prompt saved: {prompt_file}") # Copy current MCP session log into logs/ before snapshotting import platform + system = platform.system() if system == "Windows": - mcp_log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs") + mcp_log_dir = os.path.join( + os.environ.get("APPDATA", ""), "Claude", "logs" + ) elif system == "Darwin": mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude") else: @@ -1842,11 +1950,15 @@ class KiCADInterface: if "Initializing server" in line: session_start = i session_lines = all_lines[session_start:] - log_filename = f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt" + log_filename = ( + f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt" + ) mcp_log_dest = logs_dir / log_filename with open(mcp_log_dest, "w", encoding="utf-8") as f: f.writelines(session_lines) - logger.info(f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)") + logger.info( + f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)" + ) base_name = Path(project_dir).name suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p] @@ -1855,7 +1967,9 @@ class KiCADInterface: snapshots_base.mkdir(exist_ok=True) snapshot_dir = str(snapshots_base / snapshot_name) - shutil.copytree(project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots")) + shutil.copytree( + project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots") + ) logger.info(f"Project snapshot saved: {snapshot_dir}") return { "success": True, @@ -1928,13 +2042,19 @@ class KiCADInterface: # First save the board so the subprocess can load it fresh board_path = self.board.GetFileName() if not board_path: - return {"success": False, "message": "Board has no file path — save first"} + return { + "success": False, + "message": "Board has no file path — save first", + } self.board.Save(board_path) - zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0 + zone_count = ( + self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0 + ) # Run pcbnew zone fill in an isolated subprocess to prevent crashes import subprocess, sys, textwrap + script = textwrap.dedent(f""" import pcbnew, sys board = pcbnew.LoadBoard({repr(board_path)}) @@ -1946,7 +2066,9 @@ print("ok") try: result = subprocess.run( [sys.executable, "-c", script], - capture_output=True, text=True, timeout=60 + capture_output=True, + text=True, + timeout=60, ) if result.returncode == 0 and "ok" in result.stdout: # Reload board after subprocess modified it @@ -1959,12 +2081,18 @@ print("ok") "zoneCount": zone_count, } else: - logger.warning(f"Zone fill subprocess failed: rc={result.returncode} stderr={result.stderr[:200]}") + logger.warning( + f"Zone fill subprocess failed: rc={result.returncode} stderr={result.stderr[:200]}" + ) return { "success": False, "message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.", "zoneCount": zone_count, - "details": result.stderr[:300] if result.stderr else result.stdout[:300], + "details": ( + result.stderr[:300] + if result.stderr + else result.stdout[:300] + ), } except subprocess.TimeoutExpired: logger.warning("Zone fill subprocess timed out after 60s") diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py index 0a3da02..2173b10 100644 --- a/python/tests/test_schematic_component_fields.py +++ b/python/tests/test_schematic_component_fields.py @@ -1,6 +1,7 @@ """ Tests for get_schematic_component and edit_schematic_component fieldPositions support. """ + import re import sys import shutil @@ -21,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" # Minimal placed-symbol block we can embed into a schematic for testing -PLACED_RESISTOR_BLOCK = '''\ +PLACED_RESISTOR_BLOCK = """\ (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) (in_bom yes) (on_board yes) (dnp no) (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") @@ -38,7 +39,7 @@ PLACED_RESISTOR_BLOCK = '''\ (effects (font (size 1.27 1.27)) hide) ) ) -''' +""" def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: @@ -58,6 +59,7 @@ def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: # Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports) # --------------------------------------------------------------------------- + @pytest.mark.unit class TestGetSchematicComponentParsing: """Unit tests for the regex logic used by _handle_get_schematic_component.""" @@ -69,8 +71,19 @@ class TestGetSchematicComponentParsing: ) fields = {} for m in prop_pattern.finditer(block_text): - name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) - fields[name] = {"value": value, "x": float(x), "y": float(y), "angle": float(angle)} + name, value, x, y, angle = ( + m.group(1), + m.group(2), + m.group(3), + m.group(4), + m.group(5), + ) + fields[name] = { + "value": value, + "x": float(x), + "y": float(y), + "angle": float(angle), + } return fields def _parse_comp_pos(self, block_text: str): @@ -80,7 +93,11 @@ class TestGetSchematicComponentParsing: block_text, ) if m: - return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))} + return { + "x": float(m.group(1)), + "y": float(m.group(2)), + "angle": float(m.group(3)), + } return None def test_parses_reference_field(self): @@ -115,8 +132,10 @@ class TestGetSchematicComponentParsing: new_x, new_y, new_angle = 99.0, 88.0, 0 block = PLACED_RESISTOR_BLOCK block = re.sub( - r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - rf'\1(at {new_x} {new_y} {new_angle})', + r'(\(property\s+"' + + re.escape(field_name) + + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf"\1(at {new_x} {new_y} {new_angle})", block, ) fields = self._parse_fields(block) @@ -130,7 +149,7 @@ class TestGetSchematicComponentParsing: block = PLACED_RESISTOR_BLOCK block = re.sub( r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', - r'\1(at 0.0 0.0 0)', + r"\1(at 0.0 0.0 0)", block, ) fields = self._parse_fields(block) @@ -141,6 +160,7 @@ class TestGetSchematicComponentParsing: # Integration tests – real file I/O using the empty.kicad_sch template # --------------------------------------------------------------------------- + @pytest.mark.integration class TestGetSchematicComponentIntegration: """Integration tests: write a real .kicad_sch and call the handler.""" @@ -152,40 +172,53 @@ class TestGetSchematicComponentIntegration: def _get_interface(self): """Lazily import KiCADInterface to avoid pcbnew import at collection time.""" from kicad_interface import KiCADInterface + return KiCADInterface() def test_get_returns_success(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert result["success"] is True def test_get_returns_correct_reference(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert result["reference"] == "R1" def test_get_returns_component_position(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert result["position"] is not None assert result["position"]["x"] == pytest.approx(50.0) assert result["position"]["y"] == pytest.approx(50.0) def test_get_returns_reference_field_position(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) ref_field = result["fields"]["Reference"] assert ref_field["value"] == "R1" assert ref_field["x"] == pytest.approx(51.27) @@ -193,10 +226,13 @@ class TestGetSchematicComponentIntegration: def test_get_returns_value_field(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) val_field = result["fields"]["Value"] assert val_field["value"] == "10k" assert val_field["x"] == pytest.approx(51.27) @@ -204,18 +240,24 @@ class TestGetSchematicComponentIntegration: def test_get_unknown_reference_returns_failure(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R99", - }) + result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R99", + }, + ) assert result["success"] is False assert "R99" in result["message"] def test_get_missing_path_returns_failure(self): iface = self._get_interface() - result = iface.handle_command("get_schematic_component", { - "reference": "R1", - }) + result = iface.handle_command( + "get_schematic_component", + { + "reference": "R1", + }, + ) assert result["success"] is False @@ -229,74 +271,99 @@ class TestEditSchematicComponentFieldPositions: def _get_interface(self): from kicad_interface import KiCADInterface + return KiCADInterface() def test_reposition_reference_label(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("edit_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, - }) + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, + }, + ) assert result["success"] is True # Verify the position was actually written - get_result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) def test_reposition_does_not_change_value(self, sch_with_r1): iface = self._get_interface() - iface.handle_command("edit_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, - }) - get_result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, + }, + ) + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) # Value field position must be unchanged assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) def test_reposition_multiple_fields(self, sch_with_r1): iface = self._get_interface() - result = iface.handle_command("edit_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": { - "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, - "Value": {"x": 10.0, "y": 30.0, "angle": 0}, + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": { + "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, + "Value": {"x": 10.0, "y": 30.0, "angle": 0}, + }, }, - }) + ) assert result["success"] is True - get_result = iface.handle_command("get_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + get_result = iface.handle_command( + "get_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) def test_fieldpositions_alone_is_valid(self, sch_with_r1): """fieldPositions without value/footprint/newReference should succeed.""" iface = self._get_interface() - result = iface.handle_command("edit_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, - }) + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, + }, + ) assert result["success"] is True def test_no_params_still_fails(self, sch_with_r1): """Providing no update params should return an error.""" iface = self._get_interface() - result = iface.handle_command("edit_schematic_component", { - "schematicPath": str(sch_with_r1), - "reference": "R1", - }) + result = iface.handle_command( + "edit_schematic_component", + { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }, + ) assert result["success"] is False