test: add schematic tools tests and apply black formatting
- Add python/tests/conftest.py with MagicMock stubs for pcbnew/skip - Add python/tests/test_schematic_tools.py with 29 tests covering WireManager.delete_wire, WireManager.delete_label (unit + integration), and parameter validation for all 11 new _handle_* methods - Apply black formatting to component_schematic.py, wire_manager.py, and kicad_interface.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,15 +15,20 @@ from typing import List, Tuple, Optional, Dict
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class WireManager:
|
||||
"""Manage wires in KiCad schematics using S-expression manipulation"""
|
||||
|
||||
@staticmethod
|
||||
def add_wire(schematic_path: Path, start_point: List[float], end_point: List[float],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
def add_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a wire to the schematic using S-expression manipulation
|
||||
|
||||
@@ -39,7 +44,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -47,22 +52,28 @@ class WireManager:
|
||||
# Create wire S-expression
|
||||
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
|
||||
wire_sexp = [
|
||||
Symbol('wire'),
|
||||
[Symbol('pts'),
|
||||
[Symbol('xy'), start_point[0], start_point[1]],
|
||||
[Symbol('xy'), end_point[0], end_point[1]]
|
||||
Symbol("wire"),
|
||||
[
|
||||
Symbol("pts"),
|
||||
[Symbol("xy"), start_point[0], start_point[1]],
|
||||
[Symbol("xy"), end_point[0], end_point[1]],
|
||||
],
|
||||
[Symbol('stroke'),
|
||||
[Symbol('width'), stroke_width],
|
||||
[Symbol('type'), Symbol(stroke_type)]
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point (before sheet_instances)
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -75,7 +86,7 @@ class WireManager:
|
||||
logger.info(f"Injected wire from {start_point} to {end_point}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -85,12 +96,17 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_polyline_wire(schematic_path: Path, points: List[List[float]],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
def add_polyline_wire(
|
||||
schematic_path: Path,
|
||||
points: List[List[float]],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a multi-segment wire (polyline) to the schematic
|
||||
|
||||
@@ -109,31 +125,36 @@ class WireManager:
|
||||
return False
|
||||
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
# Create pts list
|
||||
pts_list = [Symbol('pts')]
|
||||
pts_list = [Symbol("pts")]
|
||||
for point in points:
|
||||
pts_list.append([Symbol('xy'), point[0], point[1]])
|
||||
pts_list.append([Symbol("xy"), point[0], point[1]])
|
||||
|
||||
# Create wire S-expression with multiple points
|
||||
wire_sexp = [
|
||||
Symbol('wire'),
|
||||
Symbol("wire"),
|
||||
pts_list,
|
||||
[Symbol('stroke'),
|
||||
[Symbol('width'), stroke_width],
|
||||
[Symbol('type'), Symbol(stroke_type)]
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -146,7 +167,7 @@ class WireManager:
|
||||
logger.info(f"Injected polyline wire with {len(points)} points")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -156,12 +177,18 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding polyline wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_label(schematic_path: Path, text: str, position: List[float],
|
||||
label_type: str = 'label', orientation: int = 0) -> bool:
|
||||
def add_label(
|
||||
schematic_path: Path,
|
||||
text: str,
|
||||
position: List[float],
|
||||
label_type: str = "label",
|
||||
orientation: int = 0,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a net label to the schematic
|
||||
|
||||
@@ -177,7 +204,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -187,19 +214,24 @@ class WireManager:
|
||||
label_sexp = [
|
||||
Symbol(label_type),
|
||||
text,
|
||||
[Symbol('at'), position[0], position[1], orientation],
|
||||
[Symbol('fields_autoplaced'), Symbol('yes')],
|
||||
[Symbol('effects'),
|
||||
[Symbol('font'), [Symbol('size'), 1.27, 1.27]],
|
||||
[Symbol('justify'), Symbol('left'), Symbol('bottom')]
|
||||
[Symbol("at"), position[0], position[1], orientation],
|
||||
[Symbol("fields_autoplaced"), Symbol("yes")],
|
||||
[
|
||||
Symbol("effects"),
|
||||
[Symbol("font"), [Symbol("size"), 1.27, 1.27]],
|
||||
[Symbol("justify"), Symbol("left"), Symbol("bottom")],
|
||||
],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -212,7 +244,7 @@ class WireManager:
|
||||
logger.info(f"Injected label '{text}' at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -222,11 +254,14 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
|
||||
def add_junction(
|
||||
schematic_path: Path, position: List[float], diameter: float = 0
|
||||
) -> bool:
|
||||
"""
|
||||
Add a junction (connection dot) to the schematic
|
||||
|
||||
@@ -240,7 +275,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -248,17 +283,21 @@ class WireManager:
|
||||
# Create junction S-expression
|
||||
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
|
||||
junction_sexp = [
|
||||
Symbol('junction'),
|
||||
[Symbol('at'), position[0], position[1]],
|
||||
[Symbol('diameter'), diameter],
|
||||
[Symbol('color'), 0, 0, 0, 0],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
Symbol("junction"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("diameter"), diameter],
|
||||
[Symbol("color"), 0, 0, 0, 0],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -271,7 +310,7 @@ class WireManager:
|
||||
logger.info(f"Injected junction at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -281,6 +320,7 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding junction: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@@ -298,7 +338,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -306,15 +346,19 @@ class WireManager:
|
||||
# Create no_connect S-expression
|
||||
# Format: (no_connect (at x y) (uuid ...))
|
||||
no_connect_sexp = [
|
||||
Symbol('no_connect'),
|
||||
[Symbol('at'), position[0], position[1]],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
Symbol("no_connect"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('sheet_instances'):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -327,7 +371,7 @@ class WireManager:
|
||||
logger.info(f"Injected no-connect at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -337,12 +381,17 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding no-connect: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_wire(schematic_path: Path, start_point: List[float], end_point: List[float],
|
||||
tolerance: float = 0.5) -> bool:
|
||||
def delete_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a wire from the schematic matching given start/end coordinates.
|
||||
|
||||
@@ -356,7 +405,7 @@ class WireManager:
|
||||
True if a wire was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -365,34 +414,54 @@ class WireManager:
|
||||
ex, ey = end_point
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and len(item) > 0 and item[0] == Symbol('wire')):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("wire")
|
||||
):
|
||||
continue
|
||||
|
||||
# Extract pts from the wire s-expression
|
||||
pts_list = None
|
||||
for part in item[1:]:
|
||||
if isinstance(part, list) and len(part) > 0 and part[0] == Symbol('pts'):
|
||||
if (
|
||||
isinstance(part, list)
|
||||
and len(part) > 0
|
||||
and part[0] == Symbol("pts")
|
||||
):
|
||||
pts_list = part
|
||||
break
|
||||
|
||||
if pts_list is None:
|
||||
continue
|
||||
|
||||
xy_points = [p for p in pts_list[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol('xy')]
|
||||
xy_points = [
|
||||
p
|
||||
for p in pts_list[1:]
|
||||
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
|
||||
]
|
||||
if len(xy_points) < 2:
|
||||
continue
|
||||
|
||||
x1, y1 = float(xy_points[0][1]), float(xy_points[0][2])
|
||||
x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2])
|
||||
|
||||
match_fwd = (abs(x1 - sx) < tolerance and abs(y1 - sy) < tolerance and
|
||||
abs(x2 - ex) < tolerance and abs(y2 - ey) < tolerance)
|
||||
match_rev = (abs(x1 - ex) < tolerance and abs(y1 - ey) < tolerance and
|
||||
abs(x2 - sx) < tolerance and abs(y2 - sy) < tolerance)
|
||||
match_fwd = (
|
||||
abs(x1 - sx) < tolerance
|
||||
and abs(y1 - sy) < tolerance
|
||||
and abs(x2 - ex) < tolerance
|
||||
and abs(y2 - ey) < tolerance
|
||||
)
|
||||
match_rev = (
|
||||
abs(x1 - ex) < tolerance
|
||||
and abs(y1 - ey) < tolerance
|
||||
and abs(x2 - sx) < tolerance
|
||||
and abs(y2 - sy) < tolerance
|
||||
)
|
||||
|
||||
if match_fwd or match_rev:
|
||||
del sch_data[i]
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted wire from {start_point} to {end_point}")
|
||||
return True
|
||||
@@ -403,13 +472,17 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_label(schematic_path: Path, net_name: str,
|
||||
position: Optional[List[float]] = None,
|
||||
tolerance: float = 0.5) -> bool:
|
||||
def delete_label(
|
||||
schematic_path: Path,
|
||||
net_name: str,
|
||||
position: Optional[List[float]] = None,
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a net label from the schematic by name (and optionally position).
|
||||
|
||||
@@ -423,14 +496,17 @@ class WireManager:
|
||||
True if a label was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and len(item) > 0
|
||||
and item[0] == Symbol('label')):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("label")
|
||||
):
|
||||
continue
|
||||
|
||||
# Second element is the label text
|
||||
@@ -440,19 +516,26 @@ class WireManager:
|
||||
if position is not None:
|
||||
# Find (at x y ...) sub-expression and check coordinates
|
||||
at_entry = next(
|
||||
(p for p in item[1:] if isinstance(p, list)
|
||||
and len(p) >= 3 and p[0] == Symbol('at')),
|
||||
(
|
||||
p
|
||||
for p in item[1:]
|
||||
if isinstance(p, list)
|
||||
and len(p) >= 3
|
||||
and p[0] == Symbol("at")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if at_entry is None:
|
||||
continue
|
||||
lx, ly = float(at_entry[1]), float(at_entry[2])
|
||||
if not (abs(lx - position[0]) < tolerance
|
||||
and abs(ly - position[1]) < tolerance):
|
||||
if not (
|
||||
abs(lx - position[0]) < tolerance
|
||||
and abs(ly - position[1]) < tolerance
|
||||
):
|
||||
continue
|
||||
|
||||
del sch_data[i]
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted label '{net_name}'")
|
||||
return True
|
||||
@@ -463,12 +546,14 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_orthogonal_path(start: List[float], end: List[float],
|
||||
prefer_horizontal_first: bool = True) -> List[List[float]]:
|
||||
def create_orthogonal_path(
|
||||
start: List[float], end: List[float], prefer_horizontal_first: bool = True
|
||||
) -> List[List[float]]:
|
||||
"""
|
||||
Create an orthogonal (right-angle) path between two points
|
||||
|
||||
@@ -497,10 +582,11 @@ class WireManager:
|
||||
return [start, corner, end]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
# Test wire creation
|
||||
import sys
|
||||
sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python')
|
||||
|
||||
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -510,8 +596,10 @@ if __name__ == '__main__':
|
||||
print("=" * 80)
|
||||
|
||||
# Create test schematic (cross-platform temp directory)
|
||||
test_path = Path(tempfile.gettempdir()) / 'test_wire_manager.kicad_sch'
|
||||
template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch')
|
||||
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
|
||||
template_path = Path(
|
||||
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
|
||||
)
|
||||
|
||||
shutil.copy(template_path, test_path)
|
||||
print(f"\n✓ Created test schematic: {test_path}")
|
||||
@@ -547,8 +635,9 @@ if __name__ == '__main__':
|
||||
print("\n[Verification] Loading with kicad-skip...")
|
||||
try:
|
||||
from skip import Schematic
|
||||
|
||||
sch = Schematic(str(test_path))
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, 'wire') else 0
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0
|
||||
print(f" ✓ Loaded successfully")
|
||||
print(f" ✓ Wire count: {wire_count}")
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user