Merge pull request #66 from Mehanik/feature/schematic-component-field-inspection

feat: schematic component field inspection and label repositioning
This commit is contained in:
mixelpixx
2026-03-16 21:33:51 -04:00
committed by GitHub
3 changed files with 554 additions and 1 deletions

View File

@@ -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,
@@ -856,6 +857,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}}
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
@@ -866,11 +870,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)
@@ -954,6 +959,18 @@ 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 :]
@@ -969,6 +986,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}
@@ -979,6 +998,131 @@ 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")

View File

@@ -0,0 +1,369 @@
"""
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