From 75cead0860fde94d3aa73c595a08e98e70b61ff5 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 29 Mar 2026 13:01:08 +0100 Subject: [PATCH] style: apply Black formatting to all Python files Add [tool.black] config to pyproject.toml and Black hook to .pre-commit-config.yaml (rev 26.3.1), then auto-format all Python source and test files with line-length=100, target-version=py310. Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 7 + pyproject.toml | 4 + python/commands/__init__.py | 12 +- python/commands/board.py | 2 +- python/commands/board/__init__.py | 13 +- python/commands/board/layers.py | 70 ++- python/commands/board/outline.py | 28 +- python/commands/board/size.py | 38 +- python/commands/board/view.py | 159 ++++--- python/commands/component.py | 410 +++++++++--------- python/commands/component_schematic.py | 52 +-- python/commands/connection_schematic.py | 52 +-- python/commands/datasheet_manager.py | 16 +- python/commands/design_rules.py | 32 +- python/commands/dynamic_symbol_loader.py | 59 +-- python/commands/export.py | 21 +- python/commands/footprint.py | 65 ++- python/commands/freerouting.py | 164 +++---- python/commands/jlcpcb.py | 74 ++-- python/commands/jlcpcb_parts.py | 245 +++++------ python/commands/jlcsearch.py | 34 +- python/commands/library.py | 31 +- python/commands/library_schematic.py | 27 +- python/commands/library_symbol.py | 162 +++---- python/commands/pin_locator.py | 60 +-- python/commands/project.py | 12 +- python/commands/routing.py | 109 +++-- python/commands/schematic.py | 19 +- python/commands/schematic_analysis.py | 54 +-- python/commands/svg_import.py | 274 +++++++----- python/commands/symbol_creator.py | 106 +++-- python/commands/wire_connectivity.py | 12 +- python/commands/wire_manager.py | 55 +-- python/kicad_api/__init__.py | 4 +- python/kicad_api/base.py | 12 +- python/kicad_api/factory.py | 37 +- python/kicad_api/ipc_backend.py | 305 ++++++------- python/kicad_api/swig_backend.py | 13 +- python/kicad_interface.py | 275 +++--------- python/parsers/kicad_mod_parser.py | 30 +- python/resources/__init__.py | 2 +- python/resources/resource_definitions.py | 263 ++++++----- python/schemas/__init__.py | 2 +- python/test_ipc_backend.py | 98 ++--- .../tests/test_delete_schematic_component.py | 16 +- python/tests/test_freerouting.py | 267 +++++------- python/tests/test_schematic_analysis.py | 36 +- python/tests/test_schematic_tools.py | 49 +-- python/tests/test_wire_connectivity.py | 12 +- python/tests/test_wire_junction_changes.py | 69 +-- python/utils/kicad_process.py | 226 +++++----- python/utils/platform_helper.py | 45 +- tests/test_platform_helper.py | 2 + 53 files changed, 1847 insertions(+), 2394 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a65cd00..d66144d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,3 +9,10 @@ repos: - id: check-added-large-files args: ['--maxkb=500'] - id: check-merge-conflict + + - repo: https://github.com/psf/black + rev: 26.3.1 + hooks: + - id: black + language_version: python3 + args: [--config=pyproject.toml] diff --git a/pyproject.toml b/pyproject.toml index 71f7950..1bbf96e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,7 @@ name = "kicad-mcp-server" version = "2.1.0" requires-python = ">=3.10" + +[tool.black] +line-length = 100 +target-version = ["py310"] diff --git a/python/commands/__init__.py b/python/commands/__init__.py index b645122..6d69317 100644 --- a/python/commands/__init__.py +++ b/python/commands/__init__.py @@ -10,10 +10,10 @@ from .design_rules import DesignRuleCommands from .export import ExportCommands __all__ = [ - 'ProjectCommands', - 'BoardCommands', - 'ComponentCommands', - 'RoutingCommands', - 'DesignRuleCommands', - 'ExportCommands' + "ProjectCommands", + "BoardCommands", + "ComponentCommands", + "RoutingCommands", + "DesignRuleCommands", + "ExportCommands", ] diff --git a/python/commands/board.py b/python/commands/board.py index c8202ee..26109f6 100644 --- a/python/commands/board.py +++ b/python/commands/board.py @@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package. from commands.board import BoardCommands # Re-export the BoardCommands class for backward compatibility -__all__ = ['BoardCommands'] +__all__ = ["BoardCommands"] diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 7a14e61..9c23957 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -12,7 +12,8 @@ from .layers import BoardLayerCommands from .outline import BoardOutlineCommands from .view import BoardViewCommands -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardCommands: """Handles board-related KiCAD operations""" @@ -75,8 +76,8 @@ class BoardCommands: """Get a 2D image of the PCB""" self.view_commands.board = self.board return self.view_commands.get_board_2d_view(params) - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - self.view_commands.board = self.board - return self.view_commands.get_board_extents(params) + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + self.view_commands.board = self.board + return self.view_commands.get_board_extents(params) diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index f80f345..0219a5d 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -6,7 +6,8 @@ import pcbnew import logging from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardLayerCommands: """Handles board layer operations""" @@ -22,7 +23,7 @@ class BoardLayerCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } name = params.get("name") @@ -34,7 +35,7 @@ class BoardLayerCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "name, type, and position are required" + "errorDetails": "name, type, and position are required", } # Get layer stack @@ -47,7 +48,7 @@ class BoardLayerCommands: return { "success": False, "message": "Missing layer number", - "errorDetails": "number is required for inner layers" + "errorDetails": "number is required for inner layers", } layer_id = pcbnew.In1_Cu + (number - 1) elif position == "top": @@ -59,7 +60,7 @@ class BoardLayerCommands: return { "success": False, "message": "Invalid layer position", - "errorDetails": "position must be 'top', 'bottom', or 'inner'" + "errorDetails": "position must be 'top', 'bottom', or 'inner'", } # Set layer properties @@ -72,21 +73,12 @@ class BoardLayerCommands: return { "success": True, "message": f"Added layer: {name}", - "layer": { - "name": name, - "type": layer_type, - "position": position, - "number": number - } + "layer": {"name": name, "type": layer_type, "position": position, "number": number}, } except Exception as e: logger.error(f"Error adding layer: {str(e)}") - return { - "success": False, - "message": "Failed to add layer", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)} def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: """Set the active layer for PCB operations""" @@ -95,7 +87,7 @@ class BoardLayerCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } layer = params.get("layer") @@ -103,7 +95,7 @@ class BoardLayerCommands: return { "success": False, "message": "No layer specified", - "errorDetails": "layer parameter is required" + "errorDetails": "layer parameter is required", } # Find layer ID by name @@ -112,7 +104,7 @@ class BoardLayerCommands: return { "success": False, "message": "Layer not found", - "errorDetails": f"Layer '{layer}' does not exist" + "errorDetails": f"Layer '{layer}' does not exist", } # Set active layer @@ -121,10 +113,7 @@ class BoardLayerCommands: return { "success": True, "message": f"Set active layer to: {layer}", - "layer": { - "name": layer, - "id": layer_id - } + "layer": {"name": layer, "id": layer_id}, } except Exception as e: @@ -132,7 +121,7 @@ class BoardLayerCommands: return { "success": False, "message": "Failed to set active layer", - "errorDetails": str(e) + "errorDetails": str(e), } def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -142,32 +131,27 @@ class BoardLayerCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } layers = [] for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): - layers.append({ - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id - # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 - # Active layer is a UI concept not applicable to headless scripting - }) + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), + "id": layer_id, + # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting + } + ) - return { - "success": True, - "layers": layers - } + return {"success": True, "layers": layers} except Exception as e: logger.error(f"Error getting layer list: {str(e)}") - return { - "success": False, - "message": "Failed to get layer list", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)} def _get_layer_type(self, type_name: str) -> int: """Convert layer type name to KiCAD layer type constant""" @@ -175,7 +159,7 @@ class BoardLayerCommands: "copper": pcbnew.LT_SIGNAL, "technical": pcbnew.LT_SIGNAL, "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead - "signal": pcbnew.LT_SIGNAL + "signal": pcbnew.LT_SIGNAL, } return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) @@ -185,7 +169,7 @@ class BoardLayerCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper" + pcbnew.LT_JUMPER: "jumper", } # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 52bf289..05fe16d 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -224,9 +224,7 @@ class BoardOutlineCommands: } # Convert to internal units (nanometers) - scale = ( - 1000000 if position.get("unit", "mm") == "mm" else 25400000 - ) # mm or inch to nm + scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) diameter_nm = int(diameter * scale) @@ -252,9 +250,7 @@ class BoardOutlineCommands: pad = pcbnew.PAD(module) pad.SetNumber(1) pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE) - pad.SetAttribute( - pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH - ) + pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH) pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm)) pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm)) pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module @@ -311,9 +307,7 @@ class BoardOutlineCommands: } # Convert to internal units (nanometers) - scale = ( - 1000000 if position.get("unit", "mm") == "mm" else 25400000 - ) # mm or inch to nm + scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) size_nm = int(size * scale) @@ -372,9 +366,7 @@ class BoardOutlineCommands: "errorDetails": str(e), } - def _add_edge_line( - self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int - ) -> None: + def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None: """Add a line to the edge cuts layer""" line = pcbnew.PCB_SHAPE(self.board) line.SetShape(pcbnew.SHAPE_T_SEGMENT) @@ -396,18 +388,12 @@ class BoardOutlineCommands: """Add a rounded rectangle to the edge cuts layer""" if radius_nm <= 0: # If no radius, create regular rectangle - top_left = pcbnew.VECTOR2I( - center_x_nm - width_nm // 2, center_y_nm - height_nm // 2 - ) - top_right = pcbnew.VECTOR2I( - center_x_nm + width_nm // 2, center_y_nm - height_nm // 2 - ) + top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2) + top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2) bottom_right = pcbnew.VECTOR2I( center_x_nm + width_nm // 2, center_y_nm + height_nm // 2 ) - bottom_left = pcbnew.VECTOR2I( - center_x_nm - width_nm // 2, center_y_nm + height_nm // 2 - ) + bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2) self._add_edge_line(top_left, top_right, layer) self._add_edge_line(top_right, bottom_right, layer) diff --git a/python/commands/board/size.py b/python/commands/board/size.py index a4f9e2e..3d9fd4d 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -6,7 +6,8 @@ import pcbnew import logging from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardSizeCommands: """Handles board size operations""" @@ -22,7 +23,7 @@ class BoardSizeCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } width = params.get("width") @@ -33,41 +34,36 @@ class BoardSizeCommands: return { "success": False, "message": "Missing dimensions", - "errorDetails": "Both width and height are required" + "errorDetails": "Both width and height are required", } # Create board outline using BoardOutlineCommands # This properly creates edge cuts on Edge.Cuts layer from commands.board.outline import BoardOutlineCommands + outline_commands = BoardOutlineCommands(self.board) # Create rectangular outline centered at origin - result = outline_commands.add_board_outline({ - "shape": "rectangle", - "centerX": width / 2, # Center X - "centerY": height / 2, # Center Y - "width": width, - "height": height, - "unit": unit - }) + result = outline_commands.add_board_outline( + { + "shape": "rectangle", + "centerX": width / 2, # Center X + "centerY": height / 2, # Center Y + "width": width, + "height": height, + "unit": unit, + } + ) if result.get("success"): return { "success": True, "message": f"Created board outline: {width}x{height} {unit}", - "size": { - "width": width, - "height": height, - "unit": unit - } + "size": {"width": width, "height": height, "unit": unit}, } else: return result except Exception as e: logger.error(f"Error setting board size: {str(e)}") - return { - "success": False, - "message": "Failed to set board size", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)} diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 4aa5bfe..9e35610 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -10,7 +10,8 @@ from PIL import Image import io import base64 -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardViewCommands: """Handles board viewing operations""" @@ -26,7 +27,7 @@ class BoardViewCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Get board dimensions @@ -42,26 +43,24 @@ class BoardViewCommands: layers = [] for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): if self.board.IsLayerEnabled(layer_id): - layers.append({ - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id - }) + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), + "id": layer_id, + } + ) return { "success": True, "board": { "filename": self.board.GetFileName(), - "size": { - "width": width_mm, - "height": height_mm, - "unit": "mm" - }, + "size": {"width": width_mm, "height": height_mm, "unit": "mm"}, "layers": layers, - "title": self.board.GetTitleBlock().GetTitle() + "title": self.board.GetTitleBlock().GetTitle(), # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0 # Active layer is a UI concept not applicable to headless scripting - } + }, } except Exception as e: @@ -69,7 +68,7 @@ class BoardViewCommands: return { "success": False, "message": "Failed to get board information", - "errorDetails": str(e) + "errorDetails": str(e), } def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -79,7 +78,7 @@ class BoardViewCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Get parameters @@ -126,17 +125,14 @@ class BoardViewCommands: # Convert SVG to requested format if format == "svg": - with open(temp_svg, 'r') as f: + with open(temp_svg, "r") as f: svg_data = f.read() os.remove(temp_svg) - return { - "success": True, - "imageData": svg_data, - "format": "svg" - } + return {"success": True, "imageData": svg_data, "format": "svg"} else: # Use PIL to convert SVG to PNG/JPG from cairosvg import svg2png + png_data = svg2png(url=temp_svg, output_width=width, output_height=height) os.remove(temp_svg) @@ -144,18 +140,18 @@ class BoardViewCommands: # Convert PNG to JPG img = Image.open(io.BytesIO(png_data)) jpg_buffer = io.BytesIO() - img.convert('RGB').save(jpg_buffer, format='JPEG') + img.convert("RGB").save(jpg_buffer, format="JPEG") jpg_data = jpg_buffer.getvalue() return { "success": True, - "imageData": base64.b64encode(jpg_data).decode('utf-8'), - "format": "jpg" + "imageData": base64.b64encode(jpg_data).decode("utf-8"), + "format": "jpg", } else: return { "success": True, - "imageData": base64.b64encode(png_data).decode('utf-8'), - "format": "png" + "imageData": base64.b64encode(png_data).decode("utf-8"), + "format": "png", } except Exception as e: @@ -163,7 +159,7 @@ class BoardViewCommands: return { "success": False, "message": "Failed to get board 2D view", - "errorDetails": str(e) + "errorDetails": str(e), } def _get_layer_type_name(self, type_id: int) -> str: @@ -172,61 +168,58 @@ class BoardViewCommands: pcbnew.LT_SIGNAL: "signal", pcbnew.LT_POWER: "power", pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper" + pcbnew.LT_JUMPER: "jumper", } # Note: LT_USER was removed in KiCAD 9.0 return type_map.get(type_id, "unknown") - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first" - } - - # Get unit preference (default to mm) - unit = params.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch - - # Get board bounding box - board_box = self.board.GetBoardEdgesBoundingBox() - - # Extract bounds in nanometers, then convert - left = board_box.GetLeft() / scale - top = board_box.GetTop() / scale - right = board_box.GetRight() / scale - bottom = board_box.GetBottom() / scale - width = board_box.GetWidth() / scale - height = board_box.GetHeight() / scale - - # Get center point - center_x = board_box.GetCenter().x / scale - center_y = board_box.GetCenter().y / scale - - return { - "success": True, - "extents": { - "left": left, - "top": top, - "right": right, - "bottom": bottom, - "width": width, - "height": height, - "center": { - "x": center_x, - "y": center_y - }, - "unit": unit - } - } - - except Exception as e: - logger.error(f"Error getting board extents: {str(e)}") - return { - "success": False, - "message": "Failed to get board extents", - "errorDetails": str(e) - } + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get unit preference (default to mm) + unit = params.get("unit", "mm") + scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch + + # Get board bounding box + board_box = self.board.GetBoardEdgesBoundingBox() + + # Extract bounds in nanometers, then convert + left = board_box.GetLeft() / scale + top = board_box.GetTop() / scale + right = board_box.GetRight() / scale + bottom = board_box.GetBottom() / scale + width = board_box.GetWidth() / scale + height = board_box.GetHeight() / scale + + # Get center point + center_x = board_box.GetCenter().x / scale + center_y = board_box.GetCenter().y / scale + + return { + "success": True, + "extents": { + "left": left, + "top": top, + "right": right, + "bottom": bottom, + "width": width, + "height": height, + "center": {"x": center_x, "y": center_y}, + "unit": unit, + }, + } + + except Exception as e: + logger.error(f"Error getting board extents: {str(e)}") + return { + "success": False, + "message": "Failed to get board extents", + "errorDetails": str(e), + } diff --git a/python/commands/component.py b/python/commands/component.py index f707f10..e7eb029 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -10,12 +10,15 @@ from typing import Dict, Any, Optional, List, Tuple import base64 from commands.library import LibraryManager -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class ComponentCommands: """Handles component-related KiCAD operations""" - def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None): + def __init__( + self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None + ): """Initialize with optional board instance and library manager""" self.board = board self.library_manager = library_manager or LibraryManager() @@ -27,7 +30,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Get parameters @@ -43,7 +46,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "componentId and position are required" + "errorDetails": "componentId and position are required", } # Find footprint using library manager @@ -55,13 +58,14 @@ class ComponentCommands: suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5) suggestion_text = "" if suggestions: - suggestion_text = "\n\nDid you mean one of these?\n" + \ - "\n".join([f" - {s['full_name']}" for s in suggestions]) + suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join( + [f" - {s['full_name']}" for s in suggestions] + ) return { "success": False, "message": "Footprint not found", - "errorDetails": f"Could not find footprint: {component_id}{suggestion_text}" + "errorDetails": f"Could not find footprint: {component_id}{suggestion_text}", } library_path, footprint_name = footprint_result @@ -78,7 +82,7 @@ class ComponentCommands: return { "success": False, "message": "Internal error", - "errorDetails": "Could not determine library nickname" + "errorDetails": "Could not determine library nickname", } # Load the footprint @@ -87,7 +91,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to load footprint", - "errorDetails": f"Could not load footprint from {library_path}/{footprint_name}" + "errorDetails": f"Could not load footprint from {library_path}/{footprint_name}", } # Set position @@ -145,14 +149,10 @@ class ComponentCommands: "component": { "reference": module.GetReference(), "value": module.GetValue(), - "position": { - "x": position["x"], - "y": position["y"], - "unit": position["unit"] - }, + "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, "rotation": rotation, - "layer": layer - } + "layer": layer, + }, } except Exception as e: @@ -160,7 +160,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to place component", - "errorDetails": str(e) + "errorDetails": str(e), } def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -170,7 +170,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -182,7 +182,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "reference and position are required" + "errorDetails": "reference and position are required", } # Find the component @@ -191,7 +191,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Set new position @@ -218,23 +218,17 @@ class ComponentCommands: "message": f"Moved component: {reference}", "component": { "reference": reference, - "position": { - "x": position["x"], - "y": position["y"], - "unit": position["unit"] - }, - "rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()) - } + "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, + "rotation": ( + rotation if rotation is not None else module.GetOrientation().AsDegrees() + ), + "layer": self.board.GetLayerName(module.GetLayer()), + }, } except Exception as e: logger.error(f"Error moving component: {str(e)}") - return { - "success": False, - "message": "Failed to move component", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to move component", "errorDetails": str(e)} def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Rotate an existing component""" @@ -243,7 +237,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -253,7 +247,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "reference and angle are required" + "errorDetails": "reference and angle are required", } # Find the component @@ -262,7 +256,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Set rotation @@ -272,10 +266,7 @@ class ComponentCommands: return { "success": True, "message": f"Rotated component: {reference}", - "component": { - "reference": reference, - "rotation": angle - } + "component": {"reference": reference, "rotation": angle}, } except Exception as e: @@ -283,7 +274,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to rotate component", - "errorDetails": str(e) + "errorDetails": str(e), } def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -293,7 +284,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -301,7 +292,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -310,23 +301,20 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Remove from board self.board.Remove(module) - return { - "success": True, - "message": f"Deleted component: {reference}" - } + return {"success": True, "message": f"Deleted component: {reference}"} except Exception as e: logger.error(f"Error deleting component: {str(e)}") return { "success": False, "message": "Failed to delete component", - "errorDetails": str(e) + "errorDetails": str(e), } def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -336,7 +324,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -348,7 +336,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -357,7 +345,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Update properties @@ -385,17 +373,13 @@ class ComponentCommands: "component": { "reference": new_reference or reference, "value": value or module.GetValue(), - "footprint": footprint or module.GetFPIDAsString() - } + "footprint": footprint or module.GetFPIDAsString(), + }, } except Exception as e: logger.error(f"Error editing component: {str(e)}") - return { - "success": False, - "message": "Failed to edit component", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)} def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get detailed properties of a component""" @@ -404,7 +388,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -412,7 +396,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -421,7 +405,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Get position in mm @@ -435,19 +419,15 @@ class ComponentCommands: "reference": module.GetReference(), "value": module.GetValue(), "footprint": module.GetFPIDAsString(), - "position": { - "x": x_mm, - "y": y_mm, - "unit": "mm" - }, + "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, "rotation": module.GetOrientation().AsDegrees(), "layer": self.board.GetLayerName(module.GetLayer()), "attributes": { "smd": module.GetAttributes() & pcbnew.FP_SMD, "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, - "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY - } - } + "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY, + }, + }, } except Exception as e: @@ -455,7 +435,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to get component properties", - "errorDetails": str(e) + "errorDetails": str(e), } def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -465,7 +445,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } components = [] @@ -474,30 +454,25 @@ class ComponentCommands: x_mm = pos.x / 1000000 y_mm = pos.y / 1000000 - components.append({ - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": { - "x": x_mm, - "y": y_mm, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()) - }) + components.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + ) - return { - "success": True, - "components": components - } + return {"success": True, "components": components} except Exception as e: logger.error(f"Error getting component list: {str(e)}") return { "success": False, "message": "Failed to get component list", - "errorDetails": str(e) + "errorDetails": str(e), } def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -507,7 +482,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Get search parameters @@ -519,7 +494,7 @@ class ComponentCommands: return { "success": False, "message": "Missing search criteria", - "errorDetails": "At least one of reference, value, or footprint pattern is required" + "errorDetails": "At least one of reference, value, or footprint pattern is required", } matches = [] @@ -539,31 +514,25 @@ class ComponentCommands: if match: pos = module.GetPosition() - matches.append({ - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()) - }) + matches.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + ) - return { - "success": True, - "matchCount": len(matches), - "components": matches - } + return {"success": True, "matchCount": len(matches), "components": matches} except Exception as e: logger.error(f"Error finding components: {str(e)}") return { "success": False, "message": "Failed to find components", - "errorDetails": str(e) + "errorDetails": str(e), } def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -573,7 +542,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -581,7 +550,7 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } # Find the component @@ -590,7 +559,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } pads = [] @@ -606,7 +575,7 @@ class ComponentCommands: pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid", pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect", pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect", - pcbnew.PAD_SHAPE_CUSTOM: "custom" + pcbnew.PAD_SHAPE_CUSTOM: "custom", } shape = shape_map.get(pad.GetShape(), "unknown") @@ -615,29 +584,25 @@ class ComponentCommands: pcbnew.PAD_ATTRIB_PTH: "through_hole", pcbnew.PAD_ATTRIB_SMD: "smd", pcbnew.PAD_ATTRIB_CONN: "connector", - pcbnew.PAD_ATTRIB_NPTH: "npth" + pcbnew.PAD_ATTRIB_NPTH: "npth", } pad_type = type_map.get(pad.GetAttribute(), "unknown") - pads.append({ - "name": pad.GetName(), - "number": pad.GetNumber(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, - "net": pad.GetNetname(), - "netCode": pad.GetNetCode(), - "shape": shape, - "type": pad_type, - "size": { - "x": size.x / 1000000, - "y": size.y / 1000000, - "unit": "mm" - }, - "drillSize": pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None - }) + pads.append( + { + "name": pad.GetName(), + "number": pad.GetNumber(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "net": pad.GetNetname(), + "netCode": pad.GetNetCode(), + "shape": shape, + "type": pad_type, + "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, + "drillSize": ( + pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None + ), + } + ) # Get component position for reference comp_pos = module.GetPosition() @@ -648,10 +613,10 @@ class ComponentCommands: "componentPosition": { "x": comp_pos.x / 1000000, "y": comp_pos.y / 1000000, - "unit": "mm" + "unit": "mm", }, "padCount": len(pads), - "pads": pads + "pads": pads, } except Exception as e: @@ -659,7 +624,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to get component pads", - "errorDetails": str(e) + "errorDetails": str(e), } def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -669,7 +634,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -679,13 +644,13 @@ class ComponentCommands: return { "success": False, "message": "Missing reference", - "errorDetails": "reference parameter is required" + "errorDetails": "reference parameter is required", } if not pad_name: return { "success": False, "message": "Missing pad identifier", - "errorDetails": "padName or padNumber parameter is required" + "errorDetails": "padName or padNumber parameter is required", } # Find the component @@ -694,7 +659,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Find the specific pad @@ -705,7 +670,7 @@ class ComponentCommands: return { "success": False, "message": "Pad not found", - "errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}" + "errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}", } pos = pad.GetPosition() @@ -715,18 +680,10 @@ class ComponentCommands: "success": True, "reference": reference, "padName": pad.GetNumber(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, "net": pad.GetNetname(), "netCode": pad.GetNetCode(), - "size": { - "x": size.x / 1000000, - "y": size.y / 1000000, - "unit": "mm" - } + "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, } except Exception as e: @@ -734,7 +691,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to get pad position", - "errorDetails": str(e) + "errorDetails": str(e), } def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -744,7 +701,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } component_id = params.get("componentId") @@ -757,7 +714,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "componentId and count are required" + "errorDetails": "componentId and count are required", } if pattern == "grid": @@ -773,14 +730,14 @@ class ComponentCommands: return { "success": False, "message": "Missing grid parameters", - "errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required" + "errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required", } if rows * columns != count: return { "success": False, "message": "Invalid grid parameters", - "errorDetails": "rows * columns must equal count" + "errorDetails": "rows * columns must equal count", } placed_components = self._place_grid_array( @@ -793,7 +750,7 @@ class ComponentCommands: reference_prefix, value, rotation, - layer + layer, ) elif pattern == "circular": @@ -808,7 +765,7 @@ class ComponentCommands: return { "success": False, "message": "Missing circular parameters", - "errorDetails": "For circular pattern, center, radius, and angleStep are required" + "errorDetails": "For circular pattern, center, radius, and angleStep are required", } placed_components = self._place_circular_array( @@ -821,20 +778,20 @@ class ComponentCommands: reference_prefix, value, rotation_offset, - layer + layer, ) else: return { "success": False, "message": "Invalid pattern", - "errorDetails": "Pattern must be 'grid' or 'circular'" + "errorDetails": "Pattern must be 'grid' or 'circular'", } return { "success": True, "message": f"Placed {count} components in {pattern} pattern", - "components": placed_components + "components": placed_components, } except Exception as e: @@ -842,7 +799,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to place component array", - "errorDetails": str(e) + "errorDetails": str(e), } def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -852,7 +809,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } references = params.get("references", []) @@ -864,7 +821,7 @@ class ComponentCommands: return { "success": False, "message": "Missing references", - "errorDetails": "At least two component references are required" + "errorDetails": "At least two component references are required", } # Find all referenced components @@ -875,7 +832,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {ref}" + "errorDetails": f"Could not find component: {ref}", } components.append(module) @@ -890,36 +847,34 @@ class ComponentCommands: return { "success": False, "message": "Missing edge parameter", - "errorDetails": "Edge parameter is required for edge alignment" + "errorDetails": "Edge parameter is required for edge alignment", } self._align_components_to_edge(components, edge) else: return { "success": False, "message": "Invalid alignment option", - "errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'" + "errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'", } # Prepare result data aligned_components = [] for module in components: pos = module.GetPosition() - aligned_components.append({ - "reference": module.GetReference(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, - "rotation": module.GetOrientation().AsDegrees() - }) + aligned_components.append( + { + "reference": module.GetReference(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + } + ) return { "success": True, "message": f"Aligned {len(components)} components", "alignment": alignment, "distribution": distribution, - "components": aligned_components + "components": aligned_components, } except Exception as e: @@ -927,7 +882,7 @@ class ComponentCommands: return { "success": False, "message": "Failed to align components", - "errorDetails": str(e) + "errorDetails": str(e), } def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -937,7 +892,7 @@ class ComponentCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } reference = params.get("reference") @@ -949,7 +904,7 @@ class ComponentCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "reference and newReference are required" + "errorDetails": "reference and newReference are required", } # Find the source component @@ -958,7 +913,7 @@ class ComponentCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Could not find component: {reference}" + "errorDetails": f"Could not find component: {reference}", } # Check if new reference already exists @@ -966,7 +921,7 @@ class ComponentCommands: return { "success": False, "message": "Reference already exists", - "errorDetails": f"A component with reference {new_reference} already exists" + "errorDetails": f"A component with reference {new_reference} already exists", } # Create new footprint with the same properties @@ -1014,14 +969,10 @@ class ComponentCommands: "reference": new_reference, "value": new_module.GetValue(), "footprint": new_module.GetFPIDAsString(), - "position": { - "x": pos.x / 1000000, - "y": pos.y / 1000000, - "unit": "mm" - }, + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, "rotation": new_module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(new_module.GetLayer()) - } + "layer": self.board.GetLayerName(new_module.GetLayer()), + }, } except Exception as e: @@ -1029,12 +980,22 @@ class ComponentCommands: return { "success": False, "message": "Failed to duplicate component", - "errorDetails": str(e) + "errorDetails": str(e), } - def _place_grid_array(self, component_id: str, start_position: Dict[str, Any], - rows: int, columns: int, spacing_x: float, spacing_y: float, - reference_prefix: str, value: str, rotation: float, layer: str) -> List[Dict[str, Any]]: + def _place_grid_array( + self, + component_id: str, + start_position: Dict[str, Any], + rows: int, + columns: int, + spacing_x: float, + spacing_y: float, + reference_prefix: str, + value: str, + rotation: float, + layer: str, + ) -> List[Dict[str, Any]]: """Place components in a grid pattern and return the list of placed components""" placed = [] @@ -1058,24 +1019,35 @@ class ComponentCommands: component_reference = f"{reference_prefix}{index}" # Place component - result = self.place_component({ - "componentId": component_id, - "position": {"x": x, "y": y, "unit": unit}, - "reference": component_reference, - "value": value, - "rotation": rotation, - "layer": layer - }) + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": rotation, + "layer": layer, + } + ) if result["success"]: placed.append(result["component"]) return placed - def _place_circular_array(self, component_id: str, center: Dict[str, Any], - radius: float, count: int, angle_start: float, - angle_step: float, reference_prefix: str, - value: str, rotation_offset: float, layer: str) -> List[Dict[str, Any]]: + def _place_circular_array( + self, + component_id: str, + center: Dict[str, Any], + radius: float, + count: int, + angle_start: float, + angle_step: float, + reference_prefix: str, + value: str, + rotation_offset: float, + layer: str, + ) -> List[Dict[str, Any]]: """Place components in a circular pattern and return the list of placed components""" placed = [] @@ -1098,22 +1070,25 @@ class ComponentCommands: component_rotation = angle + rotation_offset # Place component - result = self.place_component({ - "componentId": component_id, - "position": {"x": x, "y": y, "unit": unit}, - "reference": component_reference, - "value": value, - "rotation": component_rotation, - "layer": layer - }) + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": component_rotation, + "layer": layer, + } + ) if result["success"]: placed.append(result["component"]) return placed - def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT], - distribution: str, spacing: Optional[float]) -> None: + def _align_components_horizontally( + self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] + ) -> None: """Align components horizontally and optionally distribute them""" if not components: return @@ -1157,8 +1132,9 @@ class ComponentCommands: x_current += spacing_nm components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y)) - def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT], - distribution: str, spacing: Optional[float]) -> None: + def _align_components_vertically( + self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] + ) -> None: """Align components vertically and optionally distribute them""" if not components: return diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 7be8c4c..23fb4d6 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -13,9 +13,7 @@ try: DYNAMIC_LOADING_AVAILABLE = True except ImportError: - logger.warning( - "Dynamic symbol loader not available - falling back to template-only mode" - ) + logger.warning("Dynamic symbol loader not available - falling back to template-only mode") DYNAMIC_LOADING_AVAILABLE = False @@ -135,32 +133,22 @@ class ComponentManager: # Check if schematic path is available if schematic_path is None: - logger.warning( - "Dynamic loading requires schematic file path but none was provided" - ) + logger.warning("Dynamic loading requires schematic file path but none was provided") fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") return (fallback, False) # Determine library name if library is None: # Default library for common component types - library = ( - "Device" # Most passives and basic components are in Device library - ) + library = "Device" # Most passives and basic components are in Device library try: - logger.info( - f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}" - ) + logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}") # Use dynamic symbol loader to inject symbol and create template - template_ref = loader.load_symbol_dynamically( - schematic_path, library, comp_type - ) + template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type) - logger.info( - f"Successfully loaded symbol dynamically. Template ref: {template_ref}" - ) + logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}") # Signal that schematic needs reload to see new template return (template_ref, True) @@ -198,9 +186,7 @@ class ComponentManager: # Get component type and determine template comp_type = component_def.get("type", "R") - library = component_def.get( - "library", None - ) # Optional library specification + library = component_def.get("library", None) # Optional library specification # Get template reference (static or dynamic) template_ref, needs_reload = ComponentManager.get_or_create_template( @@ -209,9 +195,7 @@ class ComponentManager: # If dynamic loading occurred, reload schematic to see new template if needs_reload and schematic_path: - logger.info( - f"Reloading schematic after dynamic loading: {schematic_path}" - ) + logger.info(f"Reloading schematic after dynamic loading: {schematic_path}") schematic = SchematicManager.load_schematic(str(schematic_path)) # Find template symbol by reference (handles special characters like +) @@ -303,9 +287,7 @@ class ComponentManager: return False @staticmethod - def update_component( - schematic: Schematic, component_ref: str, new_properties: dict - ): + def update_component(schematic: Schematic, component_ref: str, new_properties: dict): """Update component properties by reference designator""" try: symbol_to_update = None @@ -401,19 +383,13 @@ if __name__ == "__main__": # Get a component retrieved_comp = ComponentManager.get_component(test_sch, "C1") if retrieved_comp: - print( - f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})" - ) + print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})") # Update a component - ComponentManager.update_component( - test_sch, "R1", {"value": "20k", "Tolerance": "5%"} - ) + ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"}) # Search components - matching_comps = ComponentManager.search_components( - test_sch, "100" - ) # Search by position + matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position print(f"Search results for '100': {[c.reference for c in matching_comps]}") # Get all components @@ -423,9 +399,7 @@ if __name__ == "__main__": # Remove a component ComponentManager.remove_component(test_sch, "D1") all_comps_after_remove = ComponentManager.get_all_components(test_sch) - print( - f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}" - ) + print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}") # Save the schematic (optional) # SchematicManager.save_schematic(test_sch, "component_test.kicad_sch") diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 1b4ef86..24f08fc 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -48,9 +48,7 @@ class ConnectionManager: logger.error("Schematic does not have label collection") return None - label = schematic.label.append( - text=net_name, at={"x": position[0], "y": position[1]} - ) + label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]}) logger.info(f"Added net label '{net_name}' at {position}") return label except Exception as e: @@ -58,9 +56,7 @@ class ConnectionManager: return None @staticmethod - def connect_to_net( - schematic_path: Path, component_ref: str, pin_name: str, net_name: str - ): + def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): """ Connect a component pin to a named net using a wire stub and label @@ -93,9 +89,7 @@ class ConnectionManager: # Stub direction follows the pin's outward angle from the PinLocator pin_angle_deg = getattr(locator, "_last_pin_angle", 0) try: - pin_angle_deg = ( - locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 - ) + pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 except Exception: pin_angle_deg = 0 import math as _math @@ -172,9 +166,7 @@ class ConnectionManager: connected = [] failed = [] - for pin_num in sorted( - src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0 - ): + for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0): try: net_name = ( f"{net_prefix}_{int(pin_num) + pin_offset}" @@ -200,15 +192,11 @@ class ConnectionManager: failed.append(f"{target_ref}/{pin_num} (pin not found)") continue - connected.append( - f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]" - ) + connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]") except Exception as e: failed.append(f"{source_ref}/{pin_num}: {e}") - logger.info( - f"connect_passthrough: {len(connected)} connected, {len(failed)} failed" - ) + logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed") return {"connected": connected, "failed": failed} @staticmethod @@ -256,9 +244,7 @@ class ConnectionManager: logger.info(f"No labels found for net '{net_name}'") return connections - logger.debug( - f"Found {len(net_label_positions)} labels for net '{net_name}'" - ) + logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") # 2. Find all wires connected to these label positions if not hasattr(schematic, "wire"): @@ -272,9 +258,7 @@ class ConnectionManager: wire_points = [] for point in wire.pts.xy: if hasattr(point, "value"): - wire_points.append( - [float(point.value[0]), float(point.value[1])] - ) + wire_points.append([float(point.value[0]), float(point.value[1])]) # Check if any wire point touches a label wire_connected = False @@ -334,18 +318,14 @@ class ConnectionManager: # Check each pin for pin_num, pin_data in pins.items(): # Get pin location - pin_loc = locator.get_pin_location( - schematic_path, ref, pin_num - ) + pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) if not pin_loc: continue # Check if pin coincides with any wire point for wire_pt in connected_wire_points: if points_coincide(pin_loc, list(wire_pt)): - connections.append( - {"component": ref, "pin": pin_num} - ) + connections.append({"component": ref, "pin": pin_num}) break # Pin found, no need to check more wire points except Exception as e: @@ -364,9 +344,7 @@ class ConnectionManager: # Check if symbol is near any wire point (within 10mm) for wire_pt in connected_wire_points: - dist = ( - (symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2 - ) ** 0.5 + dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5 if dist < 10.0: # 10mm proximity threshold connections.append({"component": ref, "pin": "unknown"}) break # Only add once per component @@ -419,9 +397,7 @@ class ConnectionManager: component_info = { "reference": symbol.property.Reference.value, "value": ( - symbol.property.Value.value - if hasattr(symbol.property, "Value") - else "" + symbol.property.Value.value if hasattr(symbol.property, "Value") else "" ), "footprint": ( symbol.property.Footprint.value @@ -444,9 +420,7 @@ class ConnectionManager: schematic, net_name, schematic_path ) if connections: - netlist["nets"].append( - {"name": net_name, "connections": connections} - ) + netlist["nets"].append({"name": net_name, "connections": connections}) logger.info( f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py index 5fa051f..9e72429 100644 --- a/python/commands/datasheet_manager.py +++ b/python/commands/datasheet_manager.py @@ -81,9 +81,7 @@ class DatasheetManager: return lib_sym_start, lib_sym_end @staticmethod - def _process_symbol_block( - lines: List[str], block_start: int, block_end: int - ) -> Optional[Dict]: + def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]: """ Extract LCSC and Datasheet info from a placed symbol block. @@ -114,9 +112,7 @@ class DatasheetManager: "datasheet_value": datasheet_current, } - def enrich_schematic( - self, schematic_path: Path, dry_run: bool = False - ) -> Dict: + def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict: """ Scan a .kicad_sch file and fill in missing LCSC datasheet URLs. @@ -223,9 +219,7 @@ class DatasheetManager: no_lcsc += 1 elif ds_value not in EMPTY_DATASHEET_VALUES: already_set += 1 - logger.debug( - f"Symbol {reference}: Datasheet already set to {ds_value!r}" - ) + logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}") else: url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm) if not dry_run: @@ -256,9 +250,7 @@ class DatasheetManager: if not dry_run and updated > 0: with open(schematic_path, "w", encoding="utf-8") as f: f.write("\n".join(new_lines)) - logger.info( - f"Saved {schematic_path.name}: {updated} datasheet URLs written" - ) + logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written") return { "success": True, diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 71eb7aa..766a573 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -58,13 +58,9 @@ class DesignRuleCommands: # Set micro via settings (use properties - methods removed in KiCAD 9.0) if "microViaDiameter" in params: - design_settings.m_MicroViasMinSize = int( - params["microViaDiameter"] * scale - ) + design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) if "microViaDrill" in params: - design_settings.m_MicroViasMinDrill = int( - params["microViaDrill"] * scale - ) + design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) # Set minimum values if "minTrackWidth" in params: @@ -77,19 +73,13 @@ class DesignRuleCommands: design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) if "minMicroViaDiameter" in params: - design_settings.m_MicroViasMinSize = int( - params["minMicroViaDiameter"] * scale - ) + design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) if "minMicroViaDrill" in params: - design_settings.m_MicroViasMinDrill = int( - params["minMicroViaDrill"] * scale - ) + design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill if "minHoleDiameter" in params: - design_settings.m_MinThroughDrill = int( - params["minHoleDiameter"] * scale - ) + design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) # KiCAD 9.0: Added hole clearance settings if "holeClearance" in params: @@ -216,9 +206,7 @@ class DesignRuleCommands: } # Create temporary JSON output file - 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: @@ -297,9 +285,7 @@ class DesignRuleCommands: # Determine where to save the violations file board_dir = os.path.dirname(board_file) board_name = os.path.splitext(os.path.basename(board_file))[0] - violations_file = os.path.join( - board_dir, f"{board_name}_drc_violations.json" - ) + violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") # Always save violations to JSON file (for large result sets) with open(violations_file, "w", encoding="utf-8") as f: @@ -453,9 +439,7 @@ class DesignRuleCommands: # Filter by severity if specified if severity != "all": - filtered_violations = [ - v for v in all_violations if v.get("severity") == severity - ] + filtered_violations = [v for v in all_violations if v.get("severity") == severity] else: filtered_violations = all_violations diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index 96f0df2..c102394 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -46,7 +46,12 @@ class DynamicSymbolLoader: Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols", Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols", ] - for env_var in ["KICAD10_SYMBOL_DIR", "KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]: + for env_var in [ + "KICAD10_SYMBOL_DIR", + "KICAD9_SYMBOL_DIR", + "KICAD8_SYMBOL_DIR", + "KICAD_SYMBOL_DIR", + ]: if env_var in os.environ: possible_paths.insert(0, Path(os.environ[env_var])) @@ -83,7 +88,9 @@ class DynamicSymbolLoader: with open(table_path, "r", encoding="utf-8") as f: content = f.read() - lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?' + lib_pattern = ( + r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?' + ) for match in re.finditer(lib_pattern, content, re.IGNORECASE): nickname = match.group(1) if nickname != library_name: @@ -208,9 +215,7 @@ class DynamicSymbolLoader: return items - def _inline_extends_symbol( - self, lib_content: str, symbol_name: str, child_block: str - ) -> str: + def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str: """ Fully inline a child symbol that uses (extends "ParentName") by merging the parent's pins / graphics into the child definition. @@ -255,22 +260,16 @@ class DynamicSymbolLoader: for item in self._iter_top_level_items(parent_block): prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item) - sub_match = re.search( - r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item - ) + sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item) if prop_match: pname = prop_match.group(1) parent_prop_names.add(pname) - body_lines.append( - child_props[pname] if pname in child_props else item - ) + body_lines.append(child_props[pname] if pname in child_props else item) elif sub_match: # Rename ParentName_0_1 → ChildName_0_1 - body_lines.append( - item.replace(f'"{parent_name}_', f'"{symbol_name}_') - ) - elif re.match(r'[\s\t]*\(extends ', item): + body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_')) + elif re.match(r"[\s\t]*\(extends ", item): pass # drop extends clause else: body_lines.append(item) # pin_names, in_bom, on_board … @@ -280,16 +279,12 @@ class DynamicSymbolLoader: if pname not in parent_prop_names: body_lines.append(pblock) - first_line = parent_block.split("\n")[0].replace( - f'"{parent_name}"', f'"{symbol_name}"' - ) + first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"') last_line = parent_block.split("\n")[-1] return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line - def extract_symbol_from_library( - self, library_name: str, symbol_name: str - ) -> Optional[str]: + def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]: """ Extract a symbol definition from a KiCad .kicad_sym library file. Returns the raw text block, ready to be injected into a schematic. @@ -311,9 +306,7 @@ class DynamicSymbolLoader: block = self._extract_symbol_block(lib_content, symbol_name) if block is None: - logger.warning( - f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym" - ) + logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym") return None # If the symbol uses (extends "ParentName"), inline the parent content @@ -322,9 +315,7 @@ class DynamicSymbolLoader: # load a schematic whose lib_symbols section contains it. if re.search(r'\(extends "([^"]+)"\)', block): parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1) - logger.info( - f"Symbol {symbol_name} extends {parent_name}, inlining parent content" - ) + logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content") block = self._inline_extends_symbol(lib_content, symbol_name, block) # Prefix top-level symbol name with library @@ -362,9 +353,7 @@ class DynamicSymbolLoader: # Extract symbol from library symbol_block = self.extract_symbol_from_library(library_name, symbol_name) if not symbol_block: - raise ValueError( - f"Symbol '{symbol_name}' not found in library '{library_name}'" - ) + raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'") # Indent the block to match lib_symbols indentation (4 spaces for top-level) indented_lines = [] @@ -399,11 +388,7 @@ class DynamicSymbolLoader: f.write(content) # Handle both Path objects and strings - sch_name = ( - schematic_path.name - if hasattr(schematic_path, "name") - else str(schematic_path) - ) + sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path) logger.info(f"Injected symbol {full_name} into {sch_name}") return True @@ -457,9 +442,7 @@ class DynamicSymbolLoader: with open(schematic_path, "w", encoding="utf-8") as f: f.write(content) - logger.info( - f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})" - ) + logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})") return True def load_symbol_dynamically( diff --git a/python/commands/export.py b/python/commands/export.py index d15e533..f7a2fd9 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -105,22 +105,16 @@ class ExportCommands: ] try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=60 - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0: # Get list of generated drill files for file in os.listdir(output_dir): if file.endswith((".drl", ".cnc")): drill_files.append(file) else: - logger.warning( - f"Drill file generation failed: {result.stderr}" - ) + logger.warning(f"Drill file generation failed: {result.stderr}") except Exception as drill_error: - logger.warning( - f"Could not generate drill files: {str(drill_error)}" - ) + logger.warning(f"Could not generate drill files: {str(drill_error)}") else: logger.warning("kicad-cli not available for drill file generation") @@ -236,9 +230,7 @@ class ExportCommands: # Get the actual output filename that was created board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0] actual_filename = f"{board_name}-{base_name}.pdf" - actual_output_path = os.path.join( - os.path.dirname(output_path), actual_filename - ) + actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename) return { "success": True, @@ -395,9 +387,7 @@ class ExportCommands: if not include_components: cmd.append("--no-components") if include_copper: - cmd.extend( - ["--include-tracks", "--include-pads", "--include-zones"] - ) + cmd.extend(["--include-tracks", "--include-pads", "--include-zones"]) if include_silkscreen: cmd.append("--include-silkscreen") if include_solder_mask: @@ -696,6 +686,7 @@ class ExportCommands: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") from pathlib import Path + logs_dir = Path(project_dir) / "logs" logs_dir.mkdir(exist_ok=True) dest = str(logs_dir / f"mcp_log_{timestamp}.txt") diff --git a/python/commands/footprint.py b/python/commands/footprint.py index 97777d9..5398672 100644 --- a/python/commands/footprint.py +++ b/python/commands/footprint.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger("kicad_interface") -KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files +KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files @@ -106,7 +106,7 @@ class FootprintCreator: # ---- header ---- lines.append(f'(footprint "{name}"') - lines.append(f' (version {KICAD9_FOOTPRINT_VERSION})') + lines.append(f" (version {KICAD9_FOOTPRINT_VERSION})") lines.append(f' (generator "kicad-mcp")') lines.append(f' (generator_version "9.0")') lines.append(f' (layer "F.Cu")') @@ -122,25 +122,21 @@ class FootprintCreator: val_x = value_position.get("x", 0.0) if value_position else 0.0 val_y = value_position.get("y", 1.27) if value_position else 1.27 - lines.append( - f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)' - ) + lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)') lines.append(f' (layer "F.SilkS")') lines.append(f' (uuid "{_new_uuid()}")') - lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') - lines.append(f' )') - lines.append( - f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)' - ) + lines.append(f" (effects (font (size 1 1) (thickness 0.15)))") + lines.append(f" )") + lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)') lines.append(f' (layer "F.Fab")') lines.append(f' (uuid "{_new_uuid()}")') - lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') - lines.append(f' )') + lines.append(f" (effects (font (size 1 1) (thickness 0.15)))") + lines.append(f" )") lines.append(f' (property "Datasheet" "" (at 0 0 0)') lines.append(f' (layer "F.Fab")') lines.append(f' (uuid "{_new_uuid()}")') - lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') - lines.append(f' )') + lines.append(f" (effects (font (size 1 1) (thickness 0.15)))") + lines.append(f" )") lines.append("") # ---- courtyard ---- @@ -217,33 +213,32 @@ class FootprintCreator: changes = [] if size: new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})' - block, n = re.subn(r'\(size\s+[\d.]+\s+[\d.]+\)', new_size, block) + block, n = re.subn(r"\(size\s+[\d.]+\s+[\d.]+\)", new_size, block) if n: changes.append(f"size→{new_size}") if at: angle = at.get("angle", 0) new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})' - block, n = re.subn(r'\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_at, block) + block, n = re.subn(r"\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_at, block) if n: changes.append(f"at→{new_at}") if drill is not None: if isinstance(drill, (int, float)): - new_drill = f'(drill {_fmt(drill)})' + new_drill = f"(drill {_fmt(drill)})" else: new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})' - block, n = re.subn(r'\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_drill, block) + block, n = re.subn( + r"\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_drill, block + ) if n: changes.append(f"drill→{new_drill}") else: # Insert drill before closing paren of pad block - block = block.rstrip().rstrip(')') + f'\n {new_drill}\n )' + block = block.rstrip().rstrip(")") + f"\n {new_drill}\n )" changes.append(f"drill (inserted)→{new_drill}") if shape: block, n = re.subn( - r'(pad\s+"[^"]*"\s+\w+\s+)\w+', - lambda m: m.group(1) + shape, - block, - count=1 + r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1 ) if n: changes.append(f"shape→{shape}") @@ -280,7 +275,7 @@ class FootprintCreator: if not updated: return { "success": False, - "error": f"Pad \"{pad_number}\" not found or no changes made in {footprint_path}", + "error": f'Pad "{pad_number}" not found or no changes made in {footprint_path}', } path.write_text("\n".join(result_lines), encoding="utf-8") @@ -429,6 +424,7 @@ class FootprintCreator: # Internal helpers # # ------------------------------------------------------------------ # + def _esc(s: str) -> str: """Escape double-quotes inside S-Expression string values.""" return s.replace('"', '\\"') @@ -436,6 +432,7 @@ def _esc(s: str) -> str: def _new_uuid() -> str: import uuid + return str(uuid.uuid4()) @@ -445,8 +442,8 @@ _DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"] def _pad_lines(pad: Dict[str, Any]) -> List[str]: number = str(pad.get("number", "1")) - ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole - shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect + ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole + shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect at = pad.get("at", {"x": 0.0, "y": 0.0}) size = pad.get("size", {"w": 1.0, "h": 1.0}) drill = pad.get("drill", None) @@ -462,7 +459,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]: sh = _fmt(size.get("h", 1.0)) if layers is None: - layers = _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS + layers = ( + _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS + ) layers_str = " ".join(f'"{l}"' for l in layers) lines = [f' (pad "{number}" {ptype} {shape}'] @@ -494,13 +493,13 @@ def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) - y2 = _fmt(rect.get("y2", 1.0)) w = _fmt(rect.get("width", default_width)) return [ - f' (fp_rect', - f' (start {x1} {y1})', - f' (end {x2} {y2})', - f' (stroke (width {w}) (type default))', - f' (fill none)', + f" (fp_rect", + f" (start {x1} {y1})", + f" (end {x2} {y2})", + f" (stroke (width {w}) (type default))", + f" (fill none)", f' (layer "{layer}")', f' (uuid "{_new_uuid()}")', - f' )', + f" )", "", ] diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index a3b2268..39318d6 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -22,9 +22,7 @@ logger = logging.getLogger("kicad_interface") # Default Freerouting JAR location DEFAULT_FREEROUTING_JAR = os.environ.get( "FREEROUTING_JAR", - os.path.join( - os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar" - ), + os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"), ) DOCKER_IMAGE = "eclipse-temurin:21-jre" @@ -102,21 +100,36 @@ def _build_freerouting_cmd( ses_name = os.path.basename(ses_path) jar_name = os.path.basename(jar_path) return [ - docker_exe, "run", "--rm", - "-v", f"{jar_path}:/app/{jar_name}:ro", - "-v", f"{board_dir}:/work", + docker_exe, + "run", + "--rm", + "-v", + f"{jar_path}:/app/{jar_name}:ro", + "-v", + f"{board_dir}:/work", DOCKER_IMAGE, - "java", "-jar", f"/app/{jar_name}", - "-de", f"/work/{dsn_name}", - "-do", f"/work/{ses_name}", - "-mp", str(passes), + "java", + "-jar", + f"/app/{jar_name}", + "-de", + f"/work/{dsn_name}", + "-do", + f"/work/{ses_name}", + "-mp", + str(passes), ] else: java_exe = _find_java() return [ - java_exe, "-jar", jar_path, - "-de", dsn_path, "-do", ses_path, - "-mp", str(passes), + java_exe, + "-jar", + jar_path, + "-de", + dsn_path, + "-do", + ses_path, + "-mp", + str(passes), ] @@ -126,9 +139,7 @@ class FreeroutingCommands: def __init__(self, board=None): self.board = board - def _resolve_execution_mode( - self, jar_path: str - ) -> Dict[str, Any]: + def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]: """Determine how to run Freerouting: direct or docker. Returns dict with 'mode', 'use_docker', or 'error'. @@ -152,8 +163,7 @@ class FreeroutingCommands: return { "mode": "error", "error": ( - "Neither Java 21+ nor Docker found. " - "Install one of them to use Freerouting." + "Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting." ), } @@ -190,14 +200,10 @@ class FreeroutingCommands: return { "success": False, "message": "No board file path available", - "errorDetails": ( - "Provide boardPath or open a project first" - ), + "errorDetails": ("Provide boardPath or open a project first"), } - jar_path = params.get( - "freeroutingJar", DEFAULT_FREEROUTING_JAR - ) + jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR) timeout = params.get("timeout", 300) passes = params.get("maxPasses", 20) @@ -238,9 +244,7 @@ class FreeroutingCommands: return { "success": False, "message": "DSN export failed", - "errorDetails": ( - f"ExportSpecctraDSN returned: {result}" - ), + "errorDetails": (f"ExportSpecctraDSN returned: {result}"), } except Exception as e: return { @@ -260,14 +264,10 @@ class FreeroutingCommands: logger.info(f"DSN exported: {dsn_size} bytes") # Step 2: Run Freerouting - cmd = _build_freerouting_cmd( - jar_path, dsn_path, ses_path, passes, use_docker - ) + cmd = _build_freerouting_cmd(jar_path, dsn_path, ses_path, passes, use_docker) mode_label = "docker" if use_docker else "direct" - logger.info( - f"Running Freerouting ({mode_label}): {' '.join(cmd)}" - ) + logger.info(f"Running Freerouting ({mode_label}): {' '.join(cmd)}") start_time = time.time() try: @@ -283,10 +283,7 @@ class FreeroutingCommands: if proc.returncode != 0: return { "success": False, - "message": ( - f"Freerouting exited with code " - f"{proc.returncode}" - ), + "message": (f"Freerouting exited with code " f"{proc.returncode}"), "errorDetails": proc.stderr or proc.stdout, "elapsed_seconds": elapsed, "mode": mode_label, @@ -294,12 +291,8 @@ class FreeroutingCommands: except subprocess.TimeoutExpired: return { "success": False, - "message": ( - f"Freerouting timed out after {timeout}s" - ), - "errorDetails": ( - "Increase timeout or reduce board complexity" - ), + "message": (f"Freerouting timed out after {timeout}s"), + "errorDetails": ("Increase timeout or reduce board complexity"), } except Exception as e: return { @@ -313,10 +306,7 @@ class FreeroutingCommands: return { "success": False, "message": "Freerouting did not produce SES output", - "errorDetails": ( - f"Expected at: {ses_path}. " - f"Stdout: {proc.stdout[:500]}" - ), + "errorDetails": (f"Expected at: {ses_path}. " f"Stdout: {proc.stdout[:500]}"), "elapsed_seconds": elapsed, } @@ -331,9 +321,7 @@ class FreeroutingCommands: return { "success": False, "message": "SES import failed", - "errorDetails": ( - f"ImportSpecctraSES returned: {result}" - ), + "errorDetails": (f"ImportSpecctraSES returned: {result}"), "elapsed_seconds": elapsed, } except Exception as e: @@ -348,9 +336,7 @@ class FreeroutingCommands: try: self.board.Save(board_path) except Exception as e: - logger.warning( - f"Board save after autoroute failed: {e}" - ) + logger.warning(f"Board save after autoroute failed: {e}") # Collect stats tracks = self.board.GetTracks() @@ -373,9 +359,7 @@ class FreeroutingCommands: "tracks": track_count, "vias": via_count, }, - "freerouting_stdout": ( - proc.stdout[:1000] if proc.stdout else "" - ), + "freerouting_stdout": (proc.stdout[:1000] if proc.stdout else ""), } def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -396,36 +380,26 @@ class FreeroutingCommands: "errorDetails": "Load or create a board first", } - board_path = ( - params.get("boardPath") or self.board.GetFileName() - ) + board_path = params.get("boardPath") or self.board.GetFileName() output_path = params.get("outputPath") if not output_path: if board_path: - output_path = ( - os.path.splitext(board_path)[0] + ".dsn" - ) + output_path = os.path.splitext(board_path)[0] + ".dsn" else: return { "success": False, "message": "No output path", - "errorDetails": ( - "Provide outputPath or have a board open" - ), + "errorDetails": ("Provide outputPath or have a board open"), } try: - result = pcbnew.ExportSpecctraDSN( - self.board, output_path - ) + result = pcbnew.ExportSpecctraDSN(self.board, output_path) if result is not True and result != 0: return { "success": False, "message": "DSN export failed", - "errorDetails": ( - f"ExportSpecctraDSN returned: {result}" - ), + "errorDetails": (f"ExportSpecctraDSN returned: {result}"), } except Exception as e: return { @@ -434,11 +408,7 @@ class FreeroutingCommands: "errorDetails": str(e), } - file_size = ( - os.path.getsize(output_path) - if os.path.isfile(output_path) - else 0 - ) + file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0 return { "success": True, "message": f"Exported DSN to {output_path}", @@ -469,9 +439,7 @@ class FreeroutingCommands: return { "success": False, "message": "Missing sesPath parameter", - "errorDetails": ( - "Provide the path to the .ses file" - ), + "errorDetails": ("Provide the path to the .ses file"), } if not os.path.isfile(ses_path): @@ -482,16 +450,12 @@ class FreeroutingCommands: } try: - result = pcbnew.ImportSpecctraSES( - self.board, ses_path - ) + result = pcbnew.ImportSpecctraSES(self.board, ses_path) if result is not True and result != 0: return { "success": False, "message": "SES import failed", - "errorDetails": ( - f"ImportSpecctraSES returned: {result}" - ), + "errorDetails": (f"ImportSpecctraSES returned: {result}"), } except Exception as e: return { @@ -500,24 +464,16 @@ class FreeroutingCommands: "errorDetails": str(e), } - board_path = ( - params.get("boardPath") or self.board.GetFileName() - ) + board_path = params.get("boardPath") or self.board.GetFileName() if board_path: try: self.board.Save(board_path) except Exception as e: - logger.warning( - f"Board save after SES import failed: {e}" - ) + logger.warning(f"Board save after SES import failed: {e}") tracks = self.board.GetTracks() - track_count = sum( - 1 for t in tracks if t.GetClass() != "PCB_VIA" - ) - via_count = sum( - 1 for t in tracks if t.GetClass() == "PCB_VIA" - ) + track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA") + via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA") return { "success": True, @@ -528,13 +484,9 @@ class FreeroutingCommands: }, } - def check_freerouting( - self, params: Dict[str, Any] - ) -> Dict[str, Any]: + def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]: """Check if Freerouting and Java/Docker are available.""" - jar_path = params.get( - "freeroutingJar", DEFAULT_FREEROUTING_JAR - ) + jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR) # Check local Java java_exe = _find_java() @@ -548,11 +500,7 @@ class FreeroutingCommands: text=True, timeout=10, ) - java_version = ( - (proc.stderr or proc.stdout) - .strip() - .split("\n")[0] - ) + java_version = (proc.stderr or proc.stdout).strip().split("\n")[0] java_21_ok = _java_version_ok(java_exe) except Exception: pass diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py index f93e40a..8856848 100644 --- a/python/commands/jlcpcb.py +++ b/python/commands/jlcpcb.py @@ -18,7 +18,7 @@ import json from typing import Optional, Dict, List, Callable from pathlib import Path -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCPCBClient: @@ -31,7 +31,12 @@ class JLCPCBClient: BASE_URL = "https://jlcpcb.com/external" - def __init__(self, app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None): + def __init__( + self, + app_id: Optional[str] = None, + access_key: Optional[str] = None, + secret_key: Optional[str] = None, + ): """ Initialize JLCPCB API client @@ -40,20 +45,24 @@ class JLCPCBClient: access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var) secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var) """ - self.app_id = app_id or os.getenv('JLCPCB_APP_ID') - self.access_key = access_key or os.getenv('JLCPCB_API_KEY') - self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET') + self.app_id = app_id or os.getenv("JLCPCB_APP_ID") + self.access_key = access_key or os.getenv("JLCPCB_API_KEY") + self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET") if not self.app_id or not self.access_key or not self.secret_key: - logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.") + logger.warning( + "JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." + ) @staticmethod def _generate_nonce() -> str: """Generate a 32-character random nonce""" chars = string.ascii_letters + string.digits - return ''.join(secrets.choice(chars) for _ in range(32)) + return "".join(secrets.choice(chars) for _ in range(32)) - def _build_signature_string(self, method: str, path: str, timestamp: int, nonce: str, body: str) -> str: + def _build_signature_string( + self, method: str, path: str, timestamp: int, nonce: str, body: str + ) -> str: """ Build the signature string according to JLCPCB spec @@ -87,11 +96,9 @@ class JLCPCBClient: Base64-encoded signature """ signature_bytes = hmac.new( - self.secret_key.encode('utf-8'), - signature_string.encode('utf-8'), - hashlib.sha256 + self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256 ).digest() - return base64.b64encode(signature_bytes).decode('utf-8') + return base64.b64encode(signature_bytes).decode("utf-8") def _get_auth_header(self, method: str, path: str, body: str = "") -> str: """ @@ -106,7 +113,9 @@ class JLCPCBClient: Authorization header value """ if not self.app_id or not self.access_key or not self.secret_key: - raise Exception("JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.") + raise Exception( + "JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." + ) nonce = self._generate_nonce() timestamp = int(time.time()) @@ -116,7 +125,9 @@ class JLCPCBClient: logger.debug(f"Signature string:\n{repr(signature_string)}") logger.debug(f"Signature: {signature}") - logger.debug(f"Auth header: JOP appid=\"{self.app_id}\",accesskey=\"{self.access_key}\",nonce=\"{nonce}\",timestamp=\"{timestamp}\",signature=\"{signature}\"") + logger.debug( + f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' + ) return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' @@ -138,22 +149,16 @@ class JLCPCBClient: # Convert payload to JSON string for signing # For POST requests, we always send JSON, even if empty dict - body_str = json.dumps(payload, separators=(',', ':')) + body_str = json.dumps(payload, separators=(",", ":")) # Generate authorization header auth_header = self._get_auth_header("POST", path, body_str) - headers = { - "Authorization": auth_header, - "Content-Type": "application/json" - } + headers = {"Authorization": auth_header, "Content-Type": "application/json"} try: response = requests.post( - f"{self.BASE_URL}{path}", - headers=headers, - json=payload, - timeout=60 + f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60 ) logger.debug(f"Response status: {response.status_code}") @@ -163,18 +168,19 @@ class JLCPCBClient: response.raise_for_status() data = response.json() - if data.get('code') != 200: - raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}") + if data.get("code") != 200: + raise Exception( + f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}" + ) - return data['data'] + return data["data"] except requests.exceptions.RequestException as e: logger.error(f"Failed to fetch parts page: {e}") raise Exception(f"JLCPCB API request failed: {e}") def download_full_database( - self, - callback: Optional[Callable[[int, int, str], None]] = None + self, callback: Optional[Callable[[int, int, str], None]] = None ) -> List[Dict]: """ Download entire parts library from JLCPCB @@ -197,10 +203,10 @@ class JLCPCBClient: try: data = self.fetch_parts_page(last_key) - parts = data.get('componentInfos', []) + parts = data.get("componentInfos", []) all_parts.extend(parts) - last_key = data.get('lastKey') + last_key = data.get("lastKey") if callback: callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...") @@ -245,7 +251,9 @@ class JLCPCBClient: return None -def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None) -> bool: +def test_jlcpcb_connection( + app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None +) -> bool: """ Test JLCPCB API connection @@ -268,7 +276,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st return False -if __name__ == '__main__': +if __name__ == "__main__": # Test the JLCPCB client logging.basicConfig(level=logging.INFO) @@ -279,7 +287,7 @@ if __name__ == '__main__': client = JLCPCBClient() print("\nFetching first page of parts...") data = client.fetch_parts_page() - parts = data.get('componentInfos', []) + parts = data.get("componentInfos", []) print(f"✓ Retrieved {len(parts)} parts in first page") if parts: diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index 249bdbb..ff36424 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import List, Dict, Optional from datetime import datetime -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCPCBPartsManager: @@ -49,7 +49,7 @@ class JLCPCBPartsManager: cursor = self.conn.cursor() # Create components table - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS components ( lcsc TEXT PRIMARY KEY, category TEXT, @@ -65,17 +65,19 @@ class JLCPCBPartsManager: price_json TEXT, last_updated INTEGER ) - ''') + """) # Create indexes for fast searching - cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)') + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)" + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)") # Full-text search index for descriptions - cursor.execute(''' + cursor.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( lcsc, description, @@ -83,7 +85,7 @@ class JLCPCBPartsManager: manufacturer, content=components ) - ''') + """) self.conn.commit() logger.info(f"Initialized JLCPCB parts database at {self.db_path}") @@ -103,32 +105,35 @@ class JLCPCBPartsManager: for i, part in enumerate(parts): try: # Extract price breaks - price_json = json.dumps(part.get('prices', [])) + price_json = json.dumps(part.get("prices", [])) # Determine library type library_type = self._determine_library_type(part) - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO components ( lcsc, category, subcategory, mfr_part, package, solder_joints, manufacturer, library_type, description, datasheet, stock, price_json, last_updated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - part.get('componentCode'), # lcsc - part.get('firstSortName'), # category - part.get('secondSortName'), # subcategory - part.get('componentModelEn'), # mfr_part - part.get('componentSpecificationEn'), # package - part.get('soldPoint'), # solder_joints - part.get('componentBrandEn'), # manufacturer - library_type, # library_type - part.get('describe'), # description - part.get('dataManualUrl'), # datasheet - part.get('stockCount', 0), # stock - price_json, # price_json - int(datetime.now().timestamp()) # last_updated - )) + """, + ( + part.get("componentCode"), # lcsc + part.get("firstSortName"), # category + part.get("secondSortName"), # subcategory + part.get("componentModelEn"), # mfr_part + part.get("componentSpecificationEn"), # package + part.get("soldPoint"), # solder_joints + part.get("componentBrandEn"), # manufacturer + library_type, # library_type + part.get("describe"), # description + part.get("dataManualUrl"), # datasheet + part.get("stockCount", 0), # stock + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) imported += 1 @@ -140,10 +145,10 @@ class JLCPCBPartsManager: skipped += 1 # Update FTS index - cursor.execute(''' + cursor.execute(""" INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer) SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components - ''') + """) self.conn.commit() logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") @@ -151,16 +156,16 @@ class JLCPCBPartsManager: def _determine_library_type(self, part: Dict) -> str: """Determine if part is Basic, Extended, or Preferred""" # JLCPCB API should provide this, but if not, we infer from assembly type - assembly_type = part.get('assemblyType', '') + assembly_type = part.get("assemblyType", "") - if 'Basic' in assembly_type or part.get('libraryType') == 'base': - return 'Basic' - elif 'Extended' in assembly_type: - return 'Extended' - elif 'Prefer' in assembly_type: - return 'Preferred' + if "Basic" in assembly_type or part.get("libraryType") == "base": + return "Basic" + elif "Extended" in assembly_type: + return "Extended" + elif "Prefer" in assembly_type: + return "Preferred" else: - return 'Extended' # Default to Extended + return "Extended" # Default to Extended def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None): """ @@ -178,56 +183,59 @@ class JLCPCBPartsManager: try: # JLCSearch format is different from official API # LCSC is an integer, we need to add 'C' prefix - lcsc = part.get('lcsc') + lcsc = part.get("lcsc") if isinstance(lcsc, int): lcsc = f"C{lcsc}" # Build price JSON from jlcsearch single price - price = part.get('price') or part.get('price1') + price = part.get("price") or part.get("price1") price_json = json.dumps([{"qty": 1, "price": price}] if price else []) # Determine library type from is_basic flag - library_type = 'Basic' if part.get('is_basic') else 'Extended' - if part.get('is_preferred'): - library_type = 'Preferred' + library_type = "Basic" if part.get("is_basic") else "Extended" + if part.get("is_preferred"): + library_type = "Preferred" # Extract description from various fields description_parts = [] - if 'resistance' in part: + if "resistance" in part: description_parts.append(f"{part['resistance']}Ω") - if 'capacitance' in part: + if "capacitance" in part: description_parts.append(f"{part['capacitance']}F") - if 'tolerance_fraction' in part: - tol = part['tolerance_fraction'] * 100 + if "tolerance_fraction" in part: + tol = part["tolerance_fraction"] * 100 description_parts.append(f"±{tol}%") - if 'power_watts' in part: + if "power_watts" in part: description_parts.append(f"{part['power_watts']}mW") - if 'voltage' in part: + if "voltage" in part: description_parts.append(f"{part['voltage']}V") - description = part.get('description', ' '.join(description_parts)) + description = part.get("description", " ".join(description_parts)) - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO components ( lcsc, category, subcategory, mfr_part, package, solder_joints, manufacturer, library_type, description, datasheet, stock, price_json, last_updated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - lcsc, # lcsc with C prefix - part.get('category', ''), # category - part.get('subcategory', ''), # subcategory - part.get('mfr', ''), # mfr_part - part.get('package', ''), # package - 0, # solder_joints (not in jlcsearch) - part.get('manufacturer', ''), # manufacturer - library_type, # library_type - description, # description - '', # datasheet (not in jlcsearch) - part.get('stock', 0), # stock - price_json, # price_json - int(datetime.now().timestamp()) # last_updated - )) + """, + ( + lcsc, # lcsc with C prefix + part.get("category", ""), # category + part.get("subcategory", ""), # subcategory + part.get("mfr", ""), # mfr_part + part.get("package", ""), # package + 0, # solder_joints (not in jlcsearch) + part.get("manufacturer", ""), # manufacturer + library_type, # library_type + description, # description + "", # datasheet (not in jlcsearch) + part.get("stock", 0), # stock + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) imported += 1 @@ -239,10 +247,10 @@ class JLCPCBPartsManager: skipped += 1 # Update FTS index - cursor.execute(''' + cursor.execute(""" INSERT INTO components_fts(components_fts) VALUES('rebuild') - ''') + """) self.conn.commit() logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") @@ -255,7 +263,7 @@ class JLCPCBPartsManager: library_type: Optional[str] = None, manufacturer: Optional[str] = None, in_stock: bool = True, - limit: int = 20 + limit: int = 20, ) -> List[Dict]: """ Search for parts with filters @@ -283,15 +291,14 @@ class JLCPCBPartsManager: # Add prefix wildcard to each term for partial matching # (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR") fts_query = " ".join( - f"{term}*" if not term.endswith("*") else term - for term in query.strip().split() + f"{term}*" if not term.endswith("*") else term for term in query.strip().split() ) - sql_parts.append(''' + sql_parts.append(""" AND lcsc IN ( SELECT lcsc FROM components_fts WHERE components_fts MATCH ? ) - ''') + """) params.append(fts_query) if category: @@ -343,11 +350,11 @@ class JLCPCBPartsManager: if row: part = dict(row) # Parse price JSON - if part.get('price_json'): + if part.get("price_json"): try: - part['price_breaks'] = json.loads(part['price_json']) + part["price_breaks"] = json.loads(part["price_json"]) except: - part['price_breaks'] = [] + part["price_breaks"] = [] return part return None @@ -356,23 +363,25 @@ class JLCPCBPartsManager: cursor = self.conn.cursor() cursor.execute("SELECT COUNT(*) as total FROM components") - total = cursor.fetchone()['total'] + total = cursor.fetchone()["total"] cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'") - basic = cursor.fetchone()['basic'] + basic = cursor.fetchone()["basic"] - cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'") - extended = cursor.fetchone()['extended'] + cursor.execute( + "SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'" + ) + extended = cursor.fetchone()["extended"] cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0") - in_stock = cursor.fetchone()['in_stock'] + in_stock = cursor.fetchone()["in_stock"] return { - 'total_parts': total, - 'basic_parts': basic, - 'extended_parts': extended, - 'in_stock': in_stock, - 'db_path': self.db_path + "total_parts": total, + "basic_parts": basic, + "extended_parts": extended, + "in_stock": in_stock, + "db_path": self.db_path, } def map_package_to_footprint(self, package: str) -> List[str]: @@ -390,43 +399,22 @@ class JLCPCBPartsManager: "0402": [ "Resistor_SMD:R_0402_1005Metric", "Capacitor_SMD:C_0402_1005Metric", - "LED_SMD:LED_0402_1005Metric" + "LED_SMD:LED_0402_1005Metric", ], "0603": [ "Resistor_SMD:R_0603_1608Metric", "Capacitor_SMD:C_0603_1608Metric", - "LED_SMD:LED_0603_1608Metric" + "LED_SMD:LED_0603_1608Metric", ], - "0805": [ - "Resistor_SMD:R_0805_2012Metric", - "Capacitor_SMD:C_0805_2012Metric" - ], - "1206": [ - "Resistor_SMD:R_1206_3216Metric", - "Capacitor_SMD:C_1206_3216Metric" - ], - "SOT-23": [ - "Package_TO_SOT_SMD:SOT-23", - "Package_TO_SOT_SMD:SOT-23-3" - ], - "SOT-23-5": [ - "Package_TO_SOT_SMD:SOT-23-5" - ], - "SOT-23-6": [ - "Package_TO_SOT_SMD:SOT-23-6" - ], - "SOIC-8": [ - "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm" - ], - "SOIC-16": [ - "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm" - ], - "QFN-20": [ - "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm" - ], - "QFN-32": [ - "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm" - ] + "0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"], + "1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"], + "SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"], + "SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"], + "SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"], + "SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"], + "SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"], + "QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"], + "QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"], } # Normalize package name @@ -457,24 +445,21 @@ class JLCPCBPartsManager: # Search for parts in same category with same package alternatives = self.search_parts( - category=part['subcategory'], - package=part['package'], - in_stock=True, - limit=limit * 3 + category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3 ) # Filter out the original part - alternatives = [p for p in alternatives if p['lcsc'] != lcsc_number] + alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number] # Sort by: Basic first, then by price, then by stock def sort_key(p): - is_basic = 1 if p.get('library_type') == 'Basic' else 0 + is_basic = 1 if p.get("library_type") == "Basic" else 0 try: - prices = json.loads(p.get('price_json', '[]')) - price = float(prices[0].get('price', 999)) if prices else 999 + prices = json.loads(p.get("price_json", "[]")) + price = float(prices[0].get("price", 999)) if prices else 999 except: price = 999 - stock = p.get('stock', 0) + stock = p.get("stock", 0) return (-is_basic, price, -stock) @@ -488,7 +473,7 @@ class JLCPCBPartsManager: self.conn.close() -if __name__ == '__main__': +if __name__ == "__main__": # Test the parts manager logging.basicConfig(level=logging.INFO) @@ -503,8 +488,10 @@ if __name__ == '__main__': print(f" In stock: {stats['in_stock']}") print(f" Database: {stats['db_path']}") - if stats['total_parts'] > 0: + if stats["total_parts"] > 0: print("\nSearching for '10k resistor'...") results = manager.search_parts(query="10k resistor", limit=5) for part in results: - print(f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})") + print( + f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})" + ) diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index b6e4584..b51088a 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -10,7 +10,7 @@ import requests from typing import Optional, Dict, List, Callable import time -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class JLCSearchClient: @@ -28,11 +28,7 @@ class JLCSearchClient: pass def search_components( - self, - category: str = "components", - limit: int = 100, - offset: int = 0, - **filters + self, category: str = "components", limit: int = 100, offset: int = 0, **filters ) -> List[Dict]: """ Search components in JLCSearch database @@ -48,11 +44,7 @@ class JLCSearchClient: """ url = f"{self.BASE_URL}/{category}/list.json" - params = { - "limit": limit, - "offset": offset, - **filters - } + params = {"limit": limit, "offset": offset, **filters} try: response = requests.get(url, params=params, timeout=30) @@ -71,7 +63,9 @@ class JLCSearchClient: logger.error(f"Failed to search JLCSearch: {e}") raise Exception(f"JLCSearch API request failed: {e}") - def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]: + def search_resistors( + self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100 + ) -> List[Dict]: """ Search for resistors @@ -100,7 +94,9 @@ class JLCSearchClient: return self.search_components("resistors", limit=limit, **filters) - def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]: + def search_capacitors( + self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100 + ) -> List[Dict]: """ Search for capacitors @@ -141,9 +137,7 @@ class JLCSearchClient: return None def download_all_components( - self, - callback: Optional[Callable[[int, str], None]] = None, - batch_size: int = 100 + self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100 ) -> List[Dict]: """ Download all components from jlcsearch database @@ -165,11 +159,7 @@ class JLCSearchClient: while True: try: - batch = self.search_components( - "components", - limit=batch_size, - offset=offset - ) + batch = self.search_components("components", limit=batch_size, offset=offset) # Stop if no results returned (end of catalog) if not batch or len(batch) == 0: @@ -219,7 +209,7 @@ def test_jlcsearch_connection() -> bool: return False -if __name__ == '__main__': +if __name__ == "__main__": # Test the JLCSearch client logging.basicConfig(level=logging.INFO) diff --git a/python/commands/library.py b/python/commands/library.py index c1d1582..678a7c2 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -116,9 +116,7 @@ class LibraryManager: self.libraries[nickname] = resolved_uri logger.debug(f" Found library: {nickname} -> {resolved_uri}") else: - logger.warning( - f" Could not resolve URI for library {nickname}: {uri}" - ) + logger.warning(f" Could not resolve URI for library {nickname}: {uri}") except Exception as e: logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") @@ -221,12 +219,7 @@ class LibraryManager: / "9.0" / "kicad_common.json", # macOS Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux - Path.home() - / "AppData" - / "Roaming" - / "kicad" - / "9.0" - / "kicad_common.json", # Windows + Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows ] for config_path in kicad_common_paths: @@ -352,9 +345,7 @@ class LibraryManager: for library_nickname, library_path in self.libraries.items(): fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" if fp_file.exists(): - logger.info( - f"Found footprint {footprint_name} in library {library_nickname}" - ) + logger.info(f"Found footprint {footprint_name} in library {library_nickname}") return (library_path, footprint_name) logger.warning(f"Footprint not found in any library: {footprint_name}") @@ -461,9 +452,7 @@ class LibraryCommands: # Filter by library if specified if library_filter: results = [ - r - for r in results - if r.get("library", "").lower() == library_filter.lower() + r for r in results if r.get("library", "").lower() == library_filter.lower() ] results = results[:limit] @@ -527,7 +516,6 @@ class LibraryCommands: info: Dict = { "library": library_nickname, "name": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", "library_path": library_path, } @@ -536,19 +524,24 @@ class LibraryCommands: try: from parsers.kicad_mod_parser import parse_kicad_mod from pathlib import Path as _Path + mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") parsed = parse_kicad_mod(mod_file) if parsed: # Merge parser output into info; keep our resolved library context info.update(parsed) - info["name"] = footprint_name # entry name wins over in-file name + info["name"] = footprint_name # entry name wins over in-file name info["library"] = library_nickname info["full_name"] = f"{library_nickname}:{footprint_name}" info["library_path"] = library_path else: - logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info") + logger.warning( + f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info" + ) except Exception as parse_err: - logger.warning(f"get_footprint_info: parser error ({parse_err}), using minimal info") + logger.warning( + f"get_footprint_info: parser error ({parse_err}), using minimal info" + ) return {"success": True, "info": info} diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 45c2ae9..b9c8fd4 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -1,8 +1,10 @@ from skip import Schematic + # Symbol class might not be directly importable in the current version import os import glob + class LibraryManager: """Manage symbol libraries""" @@ -14,9 +16,11 @@ class LibraryManager: # This would need to be configured for the specific environment search_paths = [ "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern - "/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern + "/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern - os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern + os.path.expanduser( + "~/Documents/KiCad/*/symbols/*.kicad_sym" + ), # User libraries pattern ] libraries = [] @@ -30,7 +34,9 @@ class LibraryManager: # Extract library names from paths library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries] - print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}") + print( + f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}" + ) # Return both full paths and library names return {"paths": libraries, "names": library_names} @@ -47,7 +53,9 @@ class LibraryManager: # A potential approach would be to load the library file using KiCAD's Python API # or by parsing the library file format. # KiCAD symbol libraries are .kicad_sym files which are S-expression format - print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.") + print( + f"Attempted to list symbols in library {library_path}. This requires advanced implementation." + ) return [] except Exception as e: print(f"Error listing symbols in library {library_path}: {e}") @@ -59,7 +67,9 @@ class LibraryManager: try: # Similar to list_library_symbols, this might require a more direct approach # using KiCAD's Python API or by parsing the symbol library. - print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.") + print( + f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation." + ) return {} except Exception as e: print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}") @@ -78,7 +88,9 @@ class LibraryManager: libraries = LibraryManager.list_available_libraries(search_paths) results = [] - print(f"Searched for symbols matching '{query}'. This requires advanced implementation.") + print( + f"Searched for symbols matching '{query}'. This requires advanced implementation." + ) return results except Exception as e: print(f"Error searching for symbols matching '{query}': {e}") @@ -119,7 +131,8 @@ class LibraryManager: # Default fallback return {"library": "Device", "symbol": "R"} -if __name__ == '__main__': + +if __name__ == "__main__": # Example Usage (for testing) # List available libraries libraries = LibraryManager.list_available_libraries() diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 4de88f8..434a622 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -12,26 +12,27 @@ from pathlib import Path from typing import Dict, List, Optional from dataclasses import dataclass, asdict -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") @dataclass class SymbolInfo: """Information about a symbol in a library""" - name: str # Symbol name (without library prefix) - library: str # Library nickname - full_ref: str # "Library:SymbolName" - value: str = "" # Value property - description: str = "" # Description property - footprint: str = "" # Footprint reference if present - lcsc_id: str = "" # LCSC property if present + + name: str # Symbol name (without library prefix) + library: str # Library nickname + full_ref: str # "Library:SymbolName" + value: str = "" # Value property + description: str = "" # Description property + footprint: str = "" # Footprint reference if present + lcsc_id: str = "" # LCSC property if present manufacturer: str = "" # Manufacturer property - mpn: str = "" # Part/MPN property - category: str = "" # Category property - datasheet: str = "" # Datasheet URL - stock: str = "" # Stock (from JLCPCB libs) - price: str = "" # Price (from JLCPCB libs) - lib_class: str = "" # Basic/Preferred/Extended + mpn: str = "" # Part/MPN property + category: str = "" # Category property + datasheet: str = "" # Datasheet URL + stock: str = "" # Stock (from JLCPCB libs) + price: str = "" # Price (from JLCPCB libs) + lib_class: str = "" # Basic/Preferred/Extended class SymbolLibraryManager: @@ -107,7 +108,7 @@ class SymbolLibraryManager: ) """ try: - with open(table_path, 'r', encoding='utf-8') as f: + with open(table_path, "r", encoding="utf-8") as f: content = f.read() # Simple regex-based parser for lib entries @@ -155,25 +156,25 @@ class SymbolLibraryManager: # Common KiCAD environment variables env_vars = { - 'KICAD10_SYMBOL_DIR': self._find_kicad_symbol_dir(), - 'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(), - 'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(), - 'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(), - 'KICAD10_3RD_PARTY': self._find_3rd_party_dir(), - 'KICAD9_3RD_PARTY': self._find_3rd_party_dir(), - 'KICAD8_3RD_PARTY': self._find_3rd_party_dir(), - 'KISYSSYM': self._find_kicad_symbol_dir(), + "KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(), + "KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(), + "KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(), + "KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(), + "KICAD10_3RD_PARTY": self._find_3rd_party_dir(), + "KICAD9_3RD_PARTY": self._find_3rd_party_dir(), + "KICAD8_3RD_PARTY": self._find_3rd_party_dir(), + "KISYSSYM": self._find_kicad_symbol_dir(), } # Project directory if self.project_path: - env_vars['KIPRJMOD'] = str(self.project_path) + env_vars["KIPRJMOD"] = str(self.project_path) # Replace environment variables for var, value in env_vars.items(): if value: - resolved = resolved.replace(f'${{{var}}}', value) - resolved = resolved.replace(f'${var}', value) + resolved = resolved.replace(f"${{{var}}}", value) + resolved = resolved.replace(f"${var}", value) # Expand ~ to home directory resolved = os.path.expanduser(resolved) @@ -199,10 +200,10 @@ class SymbolLibraryManager: ] # Check environment variable - if 'KICAD9_SYMBOL_DIR' in os.environ: - possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR']) - if 'KICAD8_SYMBOL_DIR' in os.environ: - possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR']) + if "KICAD9_SYMBOL_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"]) + if "KICAD8_SYMBOL_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"]) for path in possible_paths: if os.path.isdir(path): @@ -219,12 +220,12 @@ class SymbolLibraryManager: ] # Check environment variable - if 'KICAD10_3RD_PARTY' in os.environ: - possible_paths.insert(0, os.environ['KICAD10_3RD_PARTY']) - if 'KICAD9_3RD_PARTY' in os.environ: - possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY']) - if 'KICAD8_3RD_PARTY' in os.environ: - possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY']) + if "KICAD10_3RD_PARTY" in os.environ: + possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"]) + if "KICAD9_3RD_PARTY" in os.environ: + possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"]) + if "KICAD8_3RD_PARTY" in os.environ: + possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"]) for path in possible_paths: if os.path.isdir(path): @@ -246,7 +247,7 @@ class SymbolLibraryManager: symbols = [] try: - with open(library_path, 'r', encoding='utf-8') as f: + with open(library_path, "r", encoding="utf-8") as f: content = f.read() # Find all top-level symbol definitions @@ -261,7 +262,7 @@ class SymbolLibraryManager: symbol_name = match.group(1) # Skip sub-symbols (they contain _0_, _1_, etc. suffixes) - if re.search(r'_\d+_\d+$', symbol_name): + if re.search(r"_\d+_\d+$", symbol_name): continue # Find the start position of this symbol @@ -280,17 +281,17 @@ class SymbolLibraryManager: name=symbol_name, library=library_name, full_ref=f"{library_name}:{symbol_name}", - value=properties.get('Value', ''), - description=properties.get('Description', ''), - footprint=properties.get('Footprint', ''), - lcsc_id=properties.get('LCSC', ''), - manufacturer=properties.get('Manufacturer', ''), - mpn=properties.get('Part', properties.get('MPN', '')), - category=properties.get('Category', ''), - datasheet=properties.get('Datasheet', ''), - stock=properties.get('Stock', ''), - price=properties.get('Price', ''), - lib_class=properties.get('Class', ''), + value=properties.get("Value", ""), + description=properties.get("Description", ""), + footprint=properties.get("Footprint", ""), + lcsc_id=properties.get("LCSC", ""), + manufacturer=properties.get("Manufacturer", ""), + mpn=properties.get("Part", properties.get("MPN", "")), + category=properties.get("Category", ""), + datasheet=properties.get("Datasheet", ""), + stock=properties.get("Stock", ""), + price=properties.get("Price", ""), + lib_class=properties.get("Class", ""), ) symbols.append(symbol_info) @@ -351,7 +352,9 @@ class SymbolLibraryManager: return symbols - def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]: + def search_symbols( + self, query: str, limit: int = 20, library_filter: Optional[str] = None + ) -> List[SymbolInfo]: """ Search for symbols matching a query @@ -370,7 +373,9 @@ class SymbolLibraryManager: libraries_to_search = self.libraries.keys() if library_filter: filter_lower = library_filter.lower() - libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()] + libraries_to_search = [ + lib for lib in libraries_to_search if filter_lower in lib.lower() + ] for library_nickname in libraries_to_search: symbols = self.list_symbols(library_nickname) @@ -495,17 +500,13 @@ class SymbolLibraryCommands: """List all available symbol libraries""" try: libraries = self.library_manager.list_libraries() - return { - "success": True, - "libraries": libraries, - "count": len(libraries) - } + return {"success": True, "libraries": libraries, "count": len(libraries)} except Exception as e: logger.error(f"Error listing symbol libraries: {e}") return { "success": False, "message": "Failed to list symbol libraries", - "errorDetails": str(e) + "errorDetails": str(e), } def search_symbols(self, params: Dict) -> Dict: @@ -513,10 +514,7 @@ class SymbolLibraryCommands: try: query = params.get("query", "") if not query: - return { - "success": False, - "message": "Missing query parameter" - } + return {"success": False, "message": "Missing query parameter"} limit = params.get("limit", 20) library_filter = params.get("library") @@ -527,25 +525,18 @@ class SymbolLibraryCommands: "success": True, "symbols": [asdict(s) for s in results], "count": len(results), - "query": query + "query": query, } except Exception as e: logger.error(f"Error searching symbols: {e}") - return { - "success": False, - "message": "Failed to search symbols", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)} def list_library_symbols(self, params: Dict) -> Dict: """List all symbols in a specific library""" try: library = params.get("library") if not library: - return { - "success": False, - "message": "Missing library parameter" - } + return {"success": False, "message": "Missing library parameter"} # Check if library exists in sym-lib-table if library not in self.library_manager.libraries: @@ -554,10 +545,10 @@ class SymbolLibraryCommands: "success": False, "message": f"Library '{library}' not found in sym-lib-table", "errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. " - f"Found {len(available_libs)} libraries. " - f"Please add this library to your sym-lib-table file, or use one of the available libraries.", + f"Found {len(available_libs)} libraries. " + f"Please add this library to your sym-lib-table file, or use one of the available libraries.", "available_libraries_count": len(available_libs), - "suggestion": "Use 'list_symbol_libraries' to see all available libraries" + "suggestion": "Use 'list_symbol_libraries' to see all available libraries", } symbols = self.library_manager.list_symbols(library) @@ -566,14 +557,14 @@ class SymbolLibraryCommands: "success": True, "library": library, "symbols": [asdict(s) for s in symbols], - "count": len(symbols) + "count": len(symbols), } except Exception as e: logger.error(f"Error listing library symbols: {e}") return { "success": False, "message": "Failed to list library symbols", - "errorDetails": str(e) + "errorDetails": str(e), } def get_symbol_info(self, params: Dict) -> Dict: @@ -581,34 +572,25 @@ class SymbolLibraryCommands: try: symbol_spec = params.get("symbol") if not symbol_spec: - return { - "success": False, - "message": "Missing symbol parameter" - } + return {"success": False, "message": "Missing symbol parameter"} result = self.library_manager.find_symbol(symbol_spec) if result: - return { - "success": True, - "symbol_info": asdict(result) - } + return {"success": True, "symbol_info": asdict(result)} else: - return { - "success": False, - "message": f"Symbol not found: {symbol_spec}" - } + return {"success": False, "message": f"Symbol not found: {symbol_spec}"} except Exception as e: logger.error(f"Error getting symbol info: {e}") return { "success": False, "message": "Failed to get symbol info", - "errorDetails": str(e) + "errorDetails": str(e), } -if __name__ == '__main__': +if __name__ == "__main__": # Test the symbol library manager import json diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 040cbbd..3657c46 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -117,11 +117,7 @@ class PinLocator: # Find lib_symbols section lib_symbols = None for item in sch_data: - if ( - isinstance(item, list) - and len(item) > 0 - and item[0] == Symbol("lib_symbols") - ): + if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): lib_symbols = item break @@ -131,11 +127,7 @@ class PinLocator: # Find the specific symbol definition for item in lib_symbols[1:]: # Skip 'lib_symbols' itself - if ( - isinstance(item, list) - and len(item) > 1 - and item[0] == Symbol("symbol") - ): + if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"): symbol_name = str(item[1]).strip('"') if symbol_name == lib_id: # Found the symbol, parse pins @@ -220,20 +212,14 @@ class PinLocator: symbol_at = target_symbol.at.value symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 - lib_id = ( - target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - ) + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None if not lib_id: return None pins = self.get_symbol_pins(schematic_path, lib_id) if pin_number not in pins: matched_num = next( - ( - num - for num, data in pins.items() - if data.get("name") == pin_number - ), + (num for num, data in pins.items() if data.get("name") == pin_number), None, ) if matched_num: @@ -290,9 +276,7 @@ class PinLocator: symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 # Get symbol lib_id - lib_id = ( - target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - ) + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None if not lib_id: logger.error(f"Symbol {symbol_reference} has no lib_id") return None @@ -311,11 +295,7 @@ class PinLocator: if pin_number not in pins: # Try matching by pin name (e.g. "VCC1", "SDA", "GND") matched_num = next( - ( - num - for num, data in pins.items() - if data.get("name") == pin_number - ), + (num for num, data in pins.items() if data.get("name") == pin_number), None, ) if matched_num: @@ -336,26 +316,18 @@ class PinLocator: pin_rel_x = pin_data["x"] pin_rel_y = pin_data["y"] - logger.debug( - f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})" - ) + logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") # Apply symbol rotation to pin position if symbol_rotation != 0: - pin_rel_x, pin_rel_y = self.rotate_point( - pin_rel_x, pin_rel_y, symbol_rotation - ) - logger.debug( - f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})" - ) + pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) + logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") # Calculate absolute position abs_x = symbol_x + pin_rel_x abs_y = symbol_y + pin_rel_y - logger.info( - f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})" - ) + logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") return [abs_x, abs_y] except Exception as e: @@ -397,9 +369,7 @@ class PinLocator: return {} # Get lib_id - lib_id = ( - target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - ) + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None if not lib_id: logger.error(f"Symbol {symbol_reference} has no lib_id") return {} @@ -412,9 +382,7 @@ class PinLocator: # Calculate location for each pin result = {} for pin_num in pins.keys(): - location = self.get_pin_location( - schematic_path, symbol_reference, pin_num - ) + location = self.get_pin_location(schematic_path, symbol_reference, pin_num) if location: result[pin_num] = location @@ -444,9 +412,7 @@ if __name__ == "__main__": # Create test schematic with components (cross-platform temp directory) test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch" template_path = ( - Path(__file__).parent.parent - / "templates" - / "template_with_symbols_expanded.kicad_sch" + Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch" ) shutil.copy(template_path, test_path) diff --git a/python/commands/project.py b/python/commands/project.py index 46872cf..2a2e403 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -22,9 +22,7 @@ class ProjectCommands: """Create a new KiCAD project""" try: # Accept both 'name' (from MCP tool) and 'projectName' (legacy) - project_name = params.get("name") or params.get( - "projectName", "New_Project" - ) + project_name = params.get("name") or params.get("projectName", "New_Project") path = params.get("path", os.getcwd()) template = params.get("template") @@ -101,9 +99,7 @@ class ProjectCommands: schematic_uuid = str(uuid_module.uuid4()) with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: - f.write( - '(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n' - ) + f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n') f.write(f" (uuid {schematic_uuid})\n\n") f.write(' (paper "A4")\n\n') f.write(" (lib_symbols\n )\n\n") @@ -207,9 +203,7 @@ class ProjectCommands: "success": True, "message": f"Saved project to: {self.board.GetFileName()}", "project": { - "name": os.path.splitext( - os.path.basename(self.board.GetFileName()) - )[0], + "name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0], "path": self.board.GetFileName(), }, } diff --git a/python/commands/routing.py b/python/commands/routing.py index 5dd0101..da66436 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -149,9 +149,9 @@ class RoutingCommands: # KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects # the actual placed layer after Flip(). fp_start = footprints[from_ref] - fp_end = footprints[to_ref] + fp_end = footprints[to_ref] start_layer = self.board.GetLayerName(fp_start.GetLayer()) - end_layer = self.board.GetLayerName(fp_end.GetLayer()) + end_layer = self.board.GetLayerName(fp_end.GetLayer()) copper_layers = {"F.Cu", "B.Cu"} needs_via = ( start_layer in copper_layers @@ -168,24 +168,34 @@ class RoutingCommands: via_y = (start_pos.y + end_pos.y) / 2 / scale # Trace on start layer: start_pad → via - r1 = self.route_trace({ - "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, - "end": {"x": via_x, "y": via_y, "unit": "mm"}, - "layer": start_layer, "width": width, "net": net, - }) + r1 = self.route_trace( + { + "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, + "end": {"x": via_x, "y": via_y, "unit": "mm"}, + "layer": start_layer, + "width": width, + "net": net, + } + ) # Via connecting both layers - self.add_via({ - "position": {"x": via_x, "y": via_y, "unit": "mm"}, - "net": net, - "from_layer": start_layer, - "to_layer": end_layer, - }) + self.add_via( + { + "position": {"x": via_x, "y": via_y, "unit": "mm"}, + "net": net, + "from_layer": start_layer, + "to_layer": end_layer, + } + ) # Trace on end layer: via → end_pad - r2 = self.route_trace({ - "start": {"x": via_x, "y": via_y, "unit": "mm"}, - "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, - "layer": end_layer, "width": width, "net": net, - }) + r2 = self.route_trace( + { + "start": {"x": via_x, "y": via_y, "unit": "mm"}, + "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, + "layer": end_layer, + "width": width, + "net": net, + } + ) success = r1.get("success") and r2.get("success") result = { "success": success, @@ -195,21 +205,28 @@ class RoutingCommands: } else: # Same layer — direct trace - result = self.route_trace({ - "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, - "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, - "layer": layer if layer else start_layer, - "width": width, "net": net, - }) + result = self.route_trace( + { + "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, + "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, + "layer": layer if layer else start_layer, + "width": width, + "net": net, + } + ) if result.get("success"): result["fromPad"] = { - "ref": from_ref, "pad": from_pad, - "x": start_pos.x / scale, "y": start_pos.y / scale, + "ref": from_ref, + "pad": from_pad, + "x": start_pos.x / scale, + "y": start_pos.y / scale, } result["toPad"] = { - "ref": to_ref, "pad": to_pad, - "x": end_pos.x / scale, "y": end_pos.y / scale, + "ref": to_ref, + "pad": to_pad, + "x": end_pos.x / scale, + "y": end_pos.y / scale, } return result @@ -352,21 +369,15 @@ class RoutingCommands: via = pcbnew.PCB_VIA(self.board) # Set position - scale = ( - 1000000 if position["unit"] == "mm" else 25400000 - ) # mm or inch to nm + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) # Set size and drill (default to board's current via settings) design_settings = self.board.GetDesignSettings() - via.SetWidth( - int(size * 1000000) if size else design_settings.GetCurrentViaSize() - ) - via.SetDrill( - int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill() - ) + via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize()) + via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()) # Set layers from_id = self.board.GetLayerID(from_layer) @@ -500,9 +511,7 @@ class RoutingCommands: # Find track by position if position: - scale = ( - 1000000 if position["unit"] == "mm" else 25400000 - ) # mm or inch to nm + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) point = pcbnew.VECTOR2I(x_nm, y_nm) @@ -940,9 +949,7 @@ class RoutingCommands: else: traces_to_copy.append(track) - filter_method = ( - "net-based" if use_net_filter else "geometric (pads have no nets)" - ) + filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)" logger.info( f"copy_routing_pattern: {len(traces_to_copy)} traces, " f"{len(vias_to_copy)} vias selected via {filter_method}" @@ -958,9 +965,7 @@ class RoutingCommands: # Create new track new_track = pcbnew.PCB_TRACK(self.board) - new_track.SetStart( - pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y) - ) + new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)) new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y)) new_track.SetLayer(track.GetLayer()) @@ -1320,15 +1325,11 @@ class RoutingCommands: pos_start = pcbnew.VECTOR2I( int(start_point.x + offset_x), int(start_point.y + offset_y) ) - pos_end = pcbnew.VECTOR2I( - int(end_point.x + offset_x), int(end_point.y + offset_y) - ) + pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y)) neg_start = pcbnew.VECTOR2I( int(start_point.x - offset_x), int(start_point.y - offset_y) ) - neg_end = pcbnew.VECTOR2I( - int(end_point.x - offset_x), int(end_point.y - offset_y) - ) + neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y)) # Create positive trace pos_track = pcbnew.PCB_TRACK(self.board) @@ -1395,9 +1396,7 @@ class RoutingCommands: return pad.GetPosition() raise ValueError("Invalid point specification") - def _point_to_track_distance( - self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK - ) -> float: + def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float: """Calculate distance from point to track segment""" start = track.GetStart() end = track.GetEnd() diff --git a/python/commands/schematic.py b/python/commands/schematic.py index 8647062..9365f25 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -31,31 +31,28 @@ class SchematicManager: # Regenerate UUID to ensure uniqueness for each created schematic import re - with open(output_path, 'r', encoding='utf-8') as f: + + with open(output_path, "r", encoding="utf-8") as f: content = f.read() new_uuid = str(uuid.uuid4()) content = re.sub( - r'\(uuid [0-9a-fA-F-]+\)', - f'(uuid {new_uuid})', + r"\(uuid [0-9a-fA-F-]+\)", + f"(uuid {new_uuid})", content, - count=1 # Only replace first (schematic) UUID + count=1, # Only replace first (schematic) UUID ) - with open(output_path, 'w', encoding='utf-8', newline='\n') as f: + with open(output_path, "w", encoding="utf-8", newline="\n") as f: f.write(content) logger.info(f"Created schematic from template: {output_path}") else: # Fallback: create minimal schematic - logger.warning( - f"Template not found at {template_path}, creating minimal schematic" - ) + logger.warning(f"Template not found at {template_path}, creating minimal schematic") # Generate unique UUID for this schematic schematic_uuid = str(uuid.uuid4()) # Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility with open(output_path, "w", encoding="utf-8", newline="\n") as f: - f.write( - '(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n' - ) + f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n') f.write(f" (uuid {schematic_uuid})\n\n") f.write(' (paper "A4")\n\n') f.write(" (lib_symbols\n )\n\n") diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index 09d284d..4044972 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -169,28 +169,16 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: for sub in sexp[1:]: if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): for pt in sub[1:]: - if ( - isinstance(pt, list) - and len(pt) >= 3 - and pt[0] == Symbol("xy") - ): + if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): points.append((float(pt[1]), float(pt[2]))) elif tag == Symbol("circle"): # (circle (center x y) (radius r) ...) cx, cy, r = 0.0, 0.0, 0.0 for sub in sexp[1:]: - if ( - isinstance(sub, list) - and len(sub) >= 3 - and sub[0] == Symbol("center") - ): + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"): cx, cy = float(sub[1]), float(sub[2]) - elif ( - isinstance(sub, list) - and len(sub) >= 2 - and sub[0] == Symbol("radius") - ): + elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"): r = float(sub[1]) if r > 0: points.extend( @@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: for sub in sexp[1:]: if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): for pt in sub[1:]: - if ( - isinstance(pt, list) - and len(pt) >= 3 - and pt[0] == Symbol("xy") - ): + if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): points.append((float(pt[1]), float(pt[2]))) else: @@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: """ lib_symbols_section = None for item in sexp_data: - if ( - isinstance(item, list) - and len(item) > 0 - and item[0] == Symbol("lib_symbols") - ): + if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): lib_symbols_section = item break @@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct( # --------------------------------------------------------------------------- -def find_overlapping_elements( - schematic_path: Path, tolerance: float = 0.5 -) -> Dict[str, Any]: +def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]: """ Detect spatially overlapping symbols, wires, and labels. @@ -490,9 +468,7 @@ def find_overlapping_elements( # --- Symbol-symbol overlap using bounding-box intersection (O(n²)) --- non_template_symbols = [ - s - for s in symbols - if not s["reference"].startswith("_TEMPLATE") and s["reference"] + s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"] ] # Pre-compute bounding boxes for all non-template symbols @@ -503,9 +479,7 @@ def find_overlapping_elements( graphics_points = lib_data.get("graphics_points", []) bbox = None if pin_defs: - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) symbol_bboxes.append((sym, bbox)) for i in range(len(symbol_bboxes)): @@ -706,9 +680,7 @@ def get_elements_in_region( if ( _point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y) or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y) - or _line_segment_intersects_aabb( - s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y - ) + or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y) ): region_wires.append( { @@ -877,15 +849,11 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: ux, uy = dx / length, dy / length if start_at_pin: nsx, nsy = sx + ux * nudge, sy + uy * nudge - if not _line_segment_intersects_aabb( - nsx, nsy, ex, ey, bx1, by1, bx2, by2 - ): + if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2): continue # Wire terminates at pin from outside else: nex, ney = ex - ux * nudge, ey - uy * nudge - if not _line_segment_intersects_aabb( - sx, sy, nex, ney, bx1, by1, bx2, by2 - ): + if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2): continue # Wire terminates at pin from outside sym = sd["sym"] diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index f0b7386..e337871 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -34,9 +34,7 @@ Polygon = List[Point] # --------------------------------------------------------------------------- # SVG path tokenizer # --------------------------------------------------------------------------- -_TOKEN_RE = re.compile( - r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)" -) +_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)") def _tokenize_path(d: str) -> List[str]: @@ -57,9 +55,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: """ polygons: List[Polygon] = [] current: Polygon = [] - cx, cy = 0.0, 0.0 # current point - sx, sy = 0.0, 0.0 # subpath start - last_ctrl = None # last bezier control point (for S/T commands) + cx, cy = 0.0, 0.0 # current point + sx, sy = 0.0, 0.0 # subpath start + last_ctrl = None # last bezier control point (for S/T commands) last_cmd = "" i = 0 @@ -73,13 +71,15 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: i += n return vals - def cubic_bezier_points(p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16) -> List[Point]: + def cubic_bezier_points( + p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16 + ) -> List[Point]: pts = [] for k in range(1, steps + 1): t = k / steps mt = 1 - t - x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0] - y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1] + x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0] + y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1] pts.append((x, y)) return pts @@ -88,13 +88,23 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: for k in range(1, steps + 1): t = k / steps mt = 1 - t - x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0] - y = mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1] + x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0] + y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1] pts.append((x, y)) return pts - def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float, - large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]: + def arc_points( + x1: float, + y1: float, + rx: float, + ry: float, + phi_deg: float, + large_arc: int, + sweep: int, + x2: float, + y2: float, + steps: int = 20, + ) -> List[Point]: """Approximate SVG arc as polygon points (endpoint parameterization → centre).""" if rx == 0 or ry == 0: return [(x2, y2)] @@ -104,13 +114,13 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: x1p = cos_phi * dx + sin_phi * dy y1p = -sin_phi * dx + cos_phi * dy rx, ry = abs(rx), abs(ry) - lam = (x1p / rx)**2 + (y1p / ry)**2 + lam = (x1p / rx) ** 2 + (y1p / ry) ** 2 if lam > 1: lam = math.sqrt(lam) rx *= lam ry *= lam - num = max(0.0, (rx*ry)**2 - (rx*y1p)**2 - (ry*x1p)**2) - den = (rx*y1p)**2 + (ry*x1p)**2 + num = max(0.0, (rx * ry) ** 2 - (rx * y1p) ** 2 - (ry * x1p) ** 2) + den = (rx * y1p) ** 2 + (ry * x1p) ** 2 sq = math.sqrt(num / den) if den != 0 else 0 if large_arc == sweep: sq = -sq @@ -120,8 +130,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2 def angle(ux, uy, vx, vy): - a = math.acos(max(-1, min(1, (ux*vx + uy*vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))) - if ux*vy - uy*vx < 0: + a = math.acos( + max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))) + ) + if ux * vy - uy * vx < 0: a = -a return a @@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: # --- main loop --- while i < len(tokens): tok = tokens[i] - if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \ - re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok): + if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip( + "+-" + ).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok): # implicit repeat of last command pass else: @@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: rel = cmd.islower() - if cmd in ('M', 'm'): + if cmd in ("M", "m"): x, y = consume(2) if rel: cx, cy = cx + x, cy + y @@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: current = [(cx, cy)] sx, sy = cx, cy # subsequent coordinates are implicit L/l - cmd = 'l' if rel else 'L' + cmd = "l" if rel else "L" - elif cmd in ('L', 'l'): + elif cmd in ("L", "l"): x, y = consume(2) if rel: cx, cy = cx + x, cy + y @@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: cx, cy = x, y current.append((cx, cy)) - elif cmd in ('H', 'h'): - x = float(tokens[i]); i += 1 + elif cmd in ("H", "h"): + x = float(tokens[i]) + i += 1 cx = cx + x if rel else x current.append((cx, cy)) - elif cmd in ('V', 'v'): - y = float(tokens[i]); i += 1 + elif cmd in ("V", "v"): + y = float(tokens[i]) + i += 1 cy = cy + y if rel else y current.append((cx, cy)) - elif cmd in ('Z', 'z'): + elif cmd in ("Z", "z"): current.append((sx, sy)) # close polygons.append(current) current = [] cx, cy = sx, sy - elif cmd in ('C', 'c'): + elif cmd in ("C", "c"): x1, y1, x2, y2, x, y = consume(6) if rel: - x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy + x1 += cx + y1 += cy + x2 += cx + y2 += cy + x += cx + y += cy pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y)) current.extend(pts) last_ctrl = (x2, y2) cx, cy = x, y - elif cmd in ('S', 's'): + elif cmd in ("S", "s"): x2, y2, x, y = consume(4) if rel: - x2 += cx; y2 += cy; x += cx; y += cy - if last_ctrl and last_cmd in ('C', 'c', 'S', 's'): + x2 += cx + y2 += cy + x += cx + y += cy + if last_ctrl and last_cmd in ("C", "c", "S", "s"): x1 = 2 * cx - last_ctrl[0] y1 = 2 * cy - last_ctrl[1] else: @@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: last_ctrl = (x2, y2) cx, cy = x, y - elif cmd in ('Q', 'q'): + elif cmd in ("Q", "q"): x1, y1, x, y = consume(4) if rel: - x1 += cx; y1 += cy; x += cx; y += cy + x1 += cx + y1 += cy + x += cx + y += cy pts = quad_bezier_points((cx, cy), (x1, y1), (x, y)) current.extend(pts) last_ctrl = (x1, y1) cx, cy = x, y - elif cmd in ('T', 't'): + elif cmd in ("T", "t"): x, y = consume(2) if rel: - x += cx; y += cy - if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'): + x += cx + y += cy + if last_ctrl and last_cmd in ("Q", "q", "T", "t"): x1 = 2 * cx - last_ctrl[0] y1 = 2 * cy - last_ctrl[1] else: @@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: last_ctrl = (x1, y1) cx, cy = x, y - elif cmd in ('A', 'a'): + elif cmd in ("A", "a"): rx, ry, phi, large, sweep, x, y = consume(7) large, sweep = int(large), int(sweep) if rel: - x += cx; y += cy + x += cx + y += cy pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y) current.extend(pts) cx, cy = x, y @@ -264,48 +292,45 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: # --------------------------------------------------------------------------- def _parse_transform(transform_str: str) -> List[List[float]]: """Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1].""" + def identity(): return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] def mat_mul(A, B): - return [ - [sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] - for r in range(3) - ] + return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)] result = identity() for m in re.finditer( - r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)', - transform_str + r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str ): func = m.group(1) - args = [float(v) for v in re.split(r'[\s,]+', m.group(2).strip()) if v] + args = [float(v) for v in re.split(r"[\s,]+", m.group(2).strip()) if v] mat = identity() - if func == 'matrix' and len(args) == 6: + if func == "matrix" and len(args) == 6: a, b, c, d, e, f = args mat = [[a, c, e], [b, d, f], [0, 0, 1]] - elif func == 'translate': + elif func == "translate": tx = args[0] ty = args[1] if len(args) > 1 else 0 mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]] - elif func == 'scale': + elif func == "scale": sx = args[0] sy = args[1] if len(args) > 1 else sx mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]] - elif func == 'rotate': + elif func == "rotate": angle = math.radians(args[0]) cos, sin = math.cos(angle), math.sin(angle) if len(args) == 3: cx_, cy_ = args[1], args[2] t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]] - r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] + r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]] mat = mat_mul(mat_mul(t1, r), t2) else: mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] - elif func == 'skewX': + elif func == "skewX": mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]] - elif func == 'skewY': + elif func == "skewY": mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]] result = mat_mul(result, mat) return result @@ -321,25 +346,22 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]: def _mat_mul(A, B): - return [ - [sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] - for r in range(3) - ] + return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)] # --------------------------------------------------------------------------- # SVG element → polygon extractor # --------------------------------------------------------------------------- -SVG_NS = re.compile(r'\{[^}]+\}') +SVG_NS = re.compile(r"\{[^}]+\}") def _tag(el: ET.Element) -> str: - return SVG_NS.sub('', el.tag) + return SVG_NS.sub("", el.tag) def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]: for key in el.attrib: - if SVG_NS.sub('', key) == name: + if SVG_NS.sub("", key) == name: return el.attrib[key] return default @@ -351,13 +373,13 @@ def _identity(): def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]: """Recursively extract all polygons from an SVG element tree.""" tag = _tag(el) - display = _get_attr(el, 'display', 'inline') - visibility = _get_attr(el, 'visibility', 'visible') - if display == 'none' or visibility == 'hidden': + display = _get_attr(el, "display", "inline") + visibility = _get_attr(el, "visibility", "visible") + if display == "none" or visibility == "hidden": return [] # Accumulate transform - transform_str = _get_attr(el, 'transform', '') + transform_str = _get_attr(el, "transform", "") if transform_str: local_mat = _parse_transform(transform_str) mat = _mat_mul(parent_mat, local_mat) @@ -366,65 +388,73 @@ def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]] result: List[Polygon] = [] - if tag == 'g' or tag == 'svg': + if tag == "g" or tag == "svg": for child in el: result.extend(_extract_polygons_from_element(child, mat)) - elif tag == 'path': - d = _get_attr(el, 'd', '') + elif tag == "path": + d = _get_attr(el, "d", "") if d: tokens = _tokenize_path(d) polygons = _parse_path_tokens(tokens) for poly in polygons: result.append(_apply_transform(poly, mat)) - elif tag == 'rect': - x = float(_get_attr(el, 'x', '0') or 0) - y = float(_get_attr(el, 'y', '0') or 0) - w = float(_get_attr(el, 'width', '0') or 0) - h = float(_get_attr(el, 'height', '0') or 0) + elif tag == "rect": + x = float(_get_attr(el, "x", "0") or 0) + y = float(_get_attr(el, "y", "0") or 0) + w = float(_get_attr(el, "width", "0") or 0) + h = float(_get_attr(el, "height", "0") or 0) if w > 0 and h > 0: pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)] result.append(_apply_transform(pts, mat)) - elif tag == 'circle': - cx_ = float(_get_attr(el, 'cx', '0') or 0) - cy_ = float(_get_attr(el, 'cy', '0') or 0) - r = float(_get_attr(el, 'r', '0') or 0) + elif tag == "circle": + cx_ = float(_get_attr(el, "cx", "0") or 0) + cy_ = float(_get_attr(el, "cy", "0") or 0) + r = float(_get_attr(el, "r", "0") or 0) if r > 0: steps = 36 - pts = [(cx_ + r * math.cos(2 * math.pi * k / steps), - cy_ + r * math.sin(2 * math.pi * k / steps)) - for k in range(steps + 1)] + pts = [ + ( + cx_ + r * math.cos(2 * math.pi * k / steps), + cy_ + r * math.sin(2 * math.pi * k / steps), + ) + for k in range(steps + 1) + ] result.append(_apply_transform(pts, mat)) - elif tag == 'ellipse': - cx_ = float(_get_attr(el, 'cx', '0') or 0) - cy_ = float(_get_attr(el, 'cy', '0') or 0) - rx = float(_get_attr(el, 'rx', '0') or 0) - ry = float(_get_attr(el, 'ry', '0') or 0) + elif tag == "ellipse": + cx_ = float(_get_attr(el, "cx", "0") or 0) + cy_ = float(_get_attr(el, "cy", "0") or 0) + rx = float(_get_attr(el, "rx", "0") or 0) + ry = float(_get_attr(el, "ry", "0") or 0) if rx > 0 and ry > 0: steps = 36 - pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps), - cy_ + ry * math.sin(2 * math.pi * k / steps)) - for k in range(steps + 1)] + pts = [ + ( + cx_ + rx * math.cos(2 * math.pi * k / steps), + cy_ + ry * math.sin(2 * math.pi * k / steps), + ) + for k in range(steps + 1) + ] result.append(_apply_transform(pts, mat)) - elif tag in ('polygon', 'polyline'): - points_str = _get_attr(el, 'points', '') + elif tag in ("polygon", "polyline"): + points_str = _get_attr(el, "points", "") if points_str: - nums = [float(v) for v in re.split(r'[\s,]+', points_str.strip()) if v] + nums = [float(v) for v in re.split(r"[\s,]+", points_str.strip()) if v] pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)] - if tag == 'polygon' and pts: + if tag == "polygon" and pts: pts.append(pts[0]) # close if pts: result.append(_apply_transform(pts, mat)) - elif tag == 'line': - x1 = float(_get_attr(el, 'x1', '0') or 0) - y1 = float(_get_attr(el, 'y1', '0') or 0) - x2 = float(_get_attr(el, 'x2', '0') or 0) - y2 = float(_get_attr(el, 'y2', '0') or 0) + elif tag == "line": + x1 = float(_get_attr(el, "x1", "0") or 0) + y1 = float(_get_attr(el, "y1", "0") or 0) + x2 = float(_get_attr(el, "x2", "0") or 0) + y2 = float(_get_attr(el, "y2", "0") or 0) pts = [(x1, y1), (x2, y2)] result.append(_apply_transform(pts, mat)) @@ -453,20 +483,24 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled: row = [] fill_str = "yes" if filled else "none" uid = str(uuid.uuid4()) - lines = [ - "\t(gr_poly", - "\t\t(pts", - ] + pts_lines + [ - "\t\t)", - "\t\t(stroke", - f"\t\t\t(width {stroke_width:.4f})", - "\t\t\t(type solid)", - "\t\t)", - f"\t\t(fill {fill_str})", - f'\t\t(layer "{layer}")', - f'\t\t(uuid "{uid}")', - "\t)", - ] + lines = ( + [ + "\t(gr_poly", + "\t\t(pts", + ] + + pts_lines + + [ + "\t\t)", + "\t\t(stroke", + f"\t\t\t(width {stroke_width:.4f})", + "\t\t\t(type solid)", + "\t\t)", + f"\t\t(fill {fill_str})", + f'\t\t(layer "{layer}")', + f'\t\t(uuid "{uid}")', + "\t)", + ] + ) return "\n".join(lines) @@ -510,15 +544,15 @@ def import_svg_to_pcb( root = tree.getroot() # Determine SVG viewport - vb = _get_attr(root, 'viewBox') + vb = _get_attr(root, "viewBox") if vb: - parts = [float(v) for v in re.split(r'[\s,]+', vb.strip()) if v] + parts = [float(v) for v in re.split(r"[\s,]+", vb.strip()) if v] svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3] else: - w_str = _get_attr(root, 'width', '100') or '100' - h_str = _get_attr(root, 'height', '100') or '100' - svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100) - svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100) + w_str = _get_attr(root, "width", "100") or "100" + h_str = _get_attr(root, "height", "100") or "100" + svg_w = float(re.sub(r"[^\d.]", "", w_str) or 100) + svg_h = float(re.sub(r"[^\d.]", "", h_str) or 100) svg_x0, svg_y0 = 0.0, 0.0 if svg_w == 0 or svg_h == 0: @@ -569,7 +603,10 @@ def import_svg_to_pcb( insert_block = "\n" + "\n".join(gr_lines) + "\n" last_paren = pcb_content.rfind(")") if last_paren == -1: - return {"success": False, "message": "PCB file format error: no closing parenthesis found"} + return { + "success": False, + "message": "PCB file format error: no closing parenthesis found", + } new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:] @@ -597,5 +634,6 @@ def import_svg_to_pcb( except Exception as e: logger.error(f"SVG import failed: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} diff --git a/python/commands/symbol_creator.py b/python/commands/symbol_creator.py index a54d239..e06a07f 100644 --- a/python/commands/symbol_creator.py +++ b/python/commands/symbol_creator.py @@ -24,15 +24,31 @@ KICAD9_SYMBOL_LIB_VERSION = "20241209" # Pin electrical types PIN_TYPES = { - "input", "output", "bidirectional", "tri_state", "passive", - "free", "unspecified", "power_in", "power_out", - "open_collector", "open_emitter", "no_connect", + "input", + "output", + "bidirectional", + "tri_state", + "passive", + "free", + "unspecified", + "power_in", + "power_out", + "open_collector", + "open_emitter", + "no_connect", } # Pin graphic shapes PIN_SHAPES = { - "line", "inverted", "clock", "inverted_clock", "input_low", - "clock_low", "output_low", "falling_edge_clock", "non_logic", + "line", + "inverted", + "clock", + "inverted_clock", + "input_low", + "clock_low", + "output_low", + "falling_edge_clock", + "non_logic", } @@ -125,11 +141,11 @@ class SymbolCreator: lib_content = lib_path.read_text(encoding="utf-8") else: lib_content = ( - f'(kicad_symbol_lib\n' - f' (version {KICAD9_SYMBOL_LIB_VERSION})\n' + f"(kicad_symbol_lib\n" + f" (version {KICAD9_SYMBOL_LIB_VERSION})\n" f' (generator "kicad-mcp")\n' f' (generator_version "9.0")\n' - f')\n' + f")\n" ) # Check for duplicate @@ -209,7 +225,7 @@ class SymbolCreator: # Only top-level symbols (not sub-symbols like _0_1 or _1_1) names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE) # Filter out sub-symbols (contain _N_N suffix) - symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)] + symbols = [n for n in names if not re.search(r"_\d+_\d+$", n)] return { "success": True, "library_path": str(lib_path), @@ -332,9 +348,9 @@ class SymbolCreator: board_str = "yes" if on_board else "no" lines.append(f' (symbol "{name}"') - lines.append(f' (exclude_from_sim no)') - lines.append(f' (in_bom {bom_str})') - lines.append(f' (on_board {board_str})') + lines.append(f" (exclude_from_sim no)") + lines.append(f" (in_bom {bom_str})") + lines.append(f" (on_board {board_str})") # Properties lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True)) @@ -351,15 +367,15 @@ class SymbolCreator: lines.extend(_rect_sym_lines(rect)) for pl in polylines: lines.extend(_polyline_lines(pl)) - lines.append(f' )') + lines.append(f" )") # Sub-symbol _1_1: pins lines.append(f' (symbol "{name}_1_1"') for pin in pins: lines.extend(_pin_lines(pin)) - lines.append(f' )') + lines.append(f" )") - lines.append(f' )') + lines.append(f" )") return "\n".join(lines) def _remove_symbol(self, content: str, name: str) -> str: @@ -372,8 +388,9 @@ class SymbolCreator: for line in lines: stripped = line.strip() if not skip: - if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \ - not re.search(r'_\d+_\d+"', line): + if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and not re.search( + r'_\d+_\d+"', line + ): skip = True depth = stripped.count("(") - stripped.count(")") continue @@ -390,17 +407,16 @@ class SymbolCreator: # S-Expression helper functions # # ------------------------------------------------------------------ # -def _property_block( - key: str, value: str, x: float, y: float, visible: bool = True -) -> List[str]: + +def _property_block(key: str, value: str, x: float, y: float, visible: bool = True) -> List[str]: hide = "" if visible else "\n (hide yes)" return [ f' (property "{_esc(key)}" "{_esc(value)}"', - f' (at {_fmt(x)} {_fmt(y)} 0)', - f' (effects', - f' (font (size 1.27 1.27))', - f' ){hide}', - f' )', + f" (at {_fmt(x)} {_fmt(y)} 0)", + f" (effects", + f" (font (size 1.27 1.27))", + f" ){hide}", + f" )", ] @@ -412,12 +428,12 @@ def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]: w = _fmt(rect.get("width", 0.254)) fill = rect.get("fill", "background") return [ - f' (rectangle', - f' (start {x1} {y1})', - f' (end {x2} {y2})', - f' (stroke (width {w}) (type default))', - f' (fill (type {fill}))', - f' )', + f" (rectangle", + f" (start {x1} {y1})", + f" (end {x2} {y2})", + f" (stroke (width {w}) (type default))", + f" (fill (type {fill}))", + f" )", ] @@ -426,16 +442,16 @@ def _polyline_lines(pl: Dict[str, Any]) -> List[str]: w = _fmt(pl.get("width", 0.254)) fill = pl.get("fill", "none") lines = [ - f' (polyline', - f' (pts', + f" (polyline", + f" (pts", ] for pt in pts: lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})') lines += [ - f' )', - f' (stroke (width {w}) (type default))', - f' (fill (type {fill}))', - f' )', + f" )", + f" (stroke (width {w}) (type default))", + f" (fill (type {fill}))", + f" )", ] return lines @@ -452,14 +468,14 @@ def _pin_lines(pin: Dict[str, Any]) -> List[str]: pin_number = str(pin.get("number", "1")) return [ - f' (pin {ptype} {shape}', - f' (at {x} {y} {angle})', - f' (length {length})', + f" (pin {ptype} {shape}", + f" (at {x} {y} {angle})", + f" (length {length})", f' (name "{_esc(pin_name)}"', - f' (effects (font (size 1.27 1.27)))', - f' )', + f" (effects (font (size 1.27 1.27)))", + f" )", f' (number "{_esc(pin_number)}"', - f' (effects (font (size 1.27 1.27)))', - f' )', - f' )', + f" (effects (font (size 1.27 1.27)))", + f" )", + f" )", ] diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index dfba1e8..eeb897b 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -95,9 +95,7 @@ def _parse_virtual_connections(schematic, schematic_path): locator = PinLocator() for symbol in schematic.symbol: try: - if not hasattr(symbol, "property") or not hasattr( - symbol.property, "Reference" - ): + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): continue ref = symbol.property.Reference.value if not ref.startswith("#PWR"): @@ -194,9 +192,7 @@ def _find_pins_on_net( ref = None for symbol in schematic.symbol: try: - if not hasattr(symbol, "property") or not hasattr( - symbol.property, "Reference" - ): + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): continue ref = symbol.property.Reference.value if ref.startswith("_TEMPLATE"): @@ -241,9 +237,7 @@ def get_wire_connections( adjacency, iu_to_wires = _build_adjacency(all_wires) - point_to_label, label_to_points = _parse_virtual_connections( - schematic, schematic_path - ) + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) visited, net_points = _find_connected_wires( x_mm, diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 1fba738..d1046f5 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -76,11 +76,7 @@ class WireManager: # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -146,20 +142,14 @@ class WireManager: # KiCAD wire elements only support exactly 2 pts each. # Split N waypoints into N-1 individual wire segments. wire_sexps = [ - WireManager._make_wire_sexp( - points[i], points[i + 1], stroke_width, stroke_type - ) + WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type) for i in range(len(points) - 1) ] # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -235,11 +225,7 @@ class WireManager: # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -274,11 +260,7 @@ class WireManager: Parse a wire S-expression item in a single pass. Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire. """ - if not ( - isinstance(wire_item, list) - and len(wire_item) >= 2 - and wire_item[0] == _SYM_WIRE - ): + if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE): return None start = end = None stroke_width: float = 0 @@ -379,9 +361,7 @@ class WireManager: return splits @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. @@ -423,11 +403,7 @@ class WireManager: # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -484,11 +460,7 @@ class WireManager: # 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] == _SYM_SHEET_INSTANCES - ): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: sheet_instances_index = i break @@ -544,9 +516,7 @@ 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] == _SYM_WIRE - ): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE): continue # Extract pts from the wire s-expression @@ -626,9 +596,7 @@ class WireManager: 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] == _SYM_LABEL - ): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL): continue # Second element is the label text @@ -649,8 +617,7 @@ class WireManager: continue lx, ly = float(at_entry[1]), float(at_entry[2]) if not ( - abs(lx - position[0]) < tolerance - and abs(ly - position[1]) < tolerance + abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance ): continue diff --git a/python/kicad_api/__init__.py b/python/kicad_api/__init__.py index 0bdca28..223b009 100644 --- a/python/kicad_api/__init__.py +++ b/python/kicad_api/__init__.py @@ -23,5 +23,5 @@ Usage: from kicad_api.factory import create_backend from kicad_api.base import KiCADBackend -__all__ = ['create_backend', 'KiCADBackend'] -__version__ = '2.0.0-alpha.1' +__all__ = ["create_backend", "KiCADBackend"] +__version__ = "2.0.0-alpha.1" diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index f64e1f6..4a9157a 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -3,6 +3,7 @@ Abstract base class for KiCAD API backends Defines the interface that all KiCAD backends must implement. """ + from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Dict, Any, List @@ -97,7 +98,7 @@ class KiCADBackend(ABC): # Board Operations @abstractmethod - def get_board(self) -> 'BoardAPI': + def get_board(self) -> "BoardAPI": """ Get board API for current project @@ -167,7 +168,7 @@ class BoardAPI(ABC): x: float, y: float, rotation: float = 0, - layer: str = "F.Cu" + layer: str = "F.Cu", ) -> bool: """ Place a component on the board @@ -194,7 +195,7 @@ class BoardAPI(ABC): end_y: float, width: float = 0.25, layer: str = "F.Cu", - net_name: Optional[str] = None + net_name: Optional[str] = None, ) -> bool: """ Add a track (trace) to the board @@ -220,7 +221,7 @@ class BoardAPI(ABC): diameter: float = 0.8, drill: float = 0.4, net_name: Optional[str] = None, - via_type: str = "through" + via_type: str = "through", ) -> bool: """ Add a via to the board @@ -275,14 +276,17 @@ class BoardAPI(ABC): class BackendError(Exception): """Base exception for backend errors""" + pass class ConnectionError(BackendError): """Raised when connection to KiCAD fails""" + pass class APINotAvailableError(BackendError): """Raised when required API is not available""" + pass diff --git a/python/kicad_api/factory.py b/python/kicad_api/factory.py index 2eabfed..3ead056 100644 --- a/python/kicad_api/factory.py +++ b/python/kicad_api/factory.py @@ -3,6 +3,7 @@ Backend factory for creating appropriate KiCAD API backend Auto-detects available backends and provides fallback mechanism. """ + import os import logging from typing import Optional @@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend: """ # Check environment variable override if backend_type is None: - backend_type = os.environ.get('KICAD_BACKEND', 'auto').lower() + backend_type = os.environ.get("KICAD_BACKEND", "auto").lower() logger.info(f"Requested backend: {backend_type}") # Try specific backend if requested - if backend_type == 'ipc': + if backend_type == "ipc": return _create_ipc_backend() - elif backend_type == 'swig': + elif backend_type == "swig": return _create_swig_backend() - elif backend_type == 'auto': + elif backend_type == "auto": return _auto_detect_backend() else: raise ValueError(f"Unknown backend type: {backend_type}") @@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend: """ try: from kicad_api.ipc_backend import IPCBackend + logger.info("Creating IPC backend") return IPCBackend() except ImportError as e: logger.error(f"IPC backend not available: {e}") raise APINotAvailableError( - "IPC backend requires 'kicad-python' package. " - "Install with: pip install kicad-python" + "IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python" ) from e @@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend: """ try: from kicad_api.swig_backend import SWIGBackend + logger.info("Creating SWIG backend") logger.warning( "SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. " @@ -92,8 +94,7 @@ def _create_swig_backend() -> KiCADBackend: except ImportError as e: logger.error(f"SWIG backend not available: {e}") raise APINotAvailableError( - "SWIG backend requires 'pcbnew' module. " - "Ensure KiCAD Python module is in PYTHONPATH." + "SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH." ) from e @@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend: try: backend = _create_swig_backend() logger.warning( - "Using deprecated SWIG backend. " - "For best results, use IPC API with KiCAD running." + "Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running." ) return backend except (ImportError, APINotAvailableError) as e: @@ -160,22 +160,18 @@ def get_available_backends() -> dict: # Check IPC (kicad-python uses 'kipy' module name) try: import kipy - results['ipc'] = { - 'available': True, - 'version': getattr(kipy, '__version__', 'unknown') - } + + results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")} except ImportError: - results['ipc'] = {'available': False, 'version': None} + results["ipc"] = {"available": False, "version": None} # Check SWIG try: import pcbnew - results['swig'] = { - 'available': True, - 'version': pcbnew.GetBuildVersion() - } + + results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()} except ImportError: - results['swig'] = {'available': False, 'version': None} + results["swig"] = {"available": False, "version": None} return results @@ -183,6 +179,7 @@ def get_available_backends() -> dict: if __name__ == "__main__": # Quick diagnostic import json + print("KiCAD Backend Availability:") print(json.dumps(get_available_backends(), indent=2)) diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 94deda9..cd4e0e8 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -13,18 +13,14 @@ Key Benefits over SWIG: - Stable API that won't break between versions - Multi-language support """ + import logging import os import platform from pathlib import Path from typing import Optional, Dict, Any, List, Callable -from kicad_api.base import ( - KiCADBackend, - BoardAPI, - ConnectionError, - APINotAvailableError -) +from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError logger = logging.getLogger(__name__) @@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend): # Common socket locations (Unix-like systems only) # Windows uses named pipes, handled by auto-detect if platform.system() != "Windows": - socket_paths_to_try.append('ipc:///tmp/kicad/api.sock') # Linux default + socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default # XDG runtime directory (requires getuid, Unix only) - if hasattr(os, 'getuid'): - socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock') + if hasattr(os, "getuid"): + socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock") # Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets) socket_paths_to_try.append(None) @@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend): except ImportError as e: logger.error("kicad-python library not found") raise APINotAvailableError( - "IPC backend requires kicad-python. " - "Install with: pip install kicad-python" + "IPC backend requires kicad-python. " "Install with: pip install kicad-python" ) from e except Exception as e: logger.error(f"Failed to connect via IPC: {e}") @@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend): return { "success": False, "message": "Direct project creation not supported via IPC", - "suggestion": "Open KiCAD and create a new project, or use SWIG backend" + "suggestion": "Open KiCAD and create a new project, or use SWIG backend", } def open_project(self, path: Path) -> Dict[str, Any]: @@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend): return { "success": True, "message": f"Project already open: {path}", - "path": str(path) + "path": str(path), } return { "success": False, "message": "Project not currently open in KiCAD", - "suggestion": "Open the project in KiCAD first, then connect via IPC" + "suggestion": "Open the project in KiCAD first, then connect via IPC", } except Exception as e: logger.error(f"Failed to check project: {e}") - return { - "success": False, - "message": "Failed to check project", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to check project", "errorDetails": str(e)} def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: """Save current project via IPC.""" @@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend): self._notify_change("save", {"path": str(path) if path else "current"}) - return { - "success": True, - "message": "Project saved successfully" - } + return {"success": True, "message": "Project saved successfully"} except Exception as e: logger.error(f"Failed to save project: {e}") - return { - "success": False, - "message": "Failed to save project", - "errorDetails": str(e) - } + return {"success": False, "message": "Failed to save project", "errorDetails": str(e)} def close_project(self) -> None: """Close current project (not supported via IPC).""" @@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI): # Find bounding box of edge cuts from kipy.util.units import to_mm - min_x = min_y = float('inf') - max_x = max_y = float('-inf') + min_x = min_y = float("inf") + max_x = max_y = float("-inf") for shape in shapes: # Check if on Edge.Cuts layer @@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI): max_x = max(max_x, bbox.max.x) max_y = max(max_y, bbox.max.y) - if min_x == float('inf'): + if min_x == float("inf"): return {"width": 0, "height": 0, "unit": "mm"} - return { - "width": to_mm(max_x - min_x), - "height": to_mm(max_y - min_y), - "unit": "mm" - } + return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"} except Exception as e: logger.error(f"Failed to get board size: {e}") @@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI): for fp in footprints: try: pos = fp.position - components.append({ - "reference": fp.reference_field.text.value if fp.reference_field else "", - "value": fp.value_field.text.value if fp.value_field else "", - "footprint": str(fp.definition.library_link) if fp.definition else "", - "position": { - "x": to_mm(pos.x) if pos else 0, - "y": to_mm(pos.y) if pos else 0, - "unit": "mm" - }, - "rotation": fp.orientation.degrees if fp.orientation else 0, - "layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu", - "id": str(fp.id) if hasattr(fp, 'id') else "" - }) + components.append( + { + "reference": ( + fp.reference_field.text.value if fp.reference_field else "" + ), + "value": fp.value_field.text.value if fp.value_field else "", + "footprint": str(fp.definition.library_link) if fp.definition else "", + "position": { + "x": to_mm(pos.x) if pos else 0, + "y": to_mm(pos.y) if pos else 0, + "unit": "mm", + }, + "rotation": fp.orientation.degrees if fp.orientation else 0, + "layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu", + "id": str(fp.id) if hasattr(fp, "id") else "", + } + ) except Exception as e: logger.warning(f"Error processing footprint: {e}") continue @@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI): y: float, rotation: float = 0, layer: str = "F.Cu", - value: str = "" + value: str = "", ) -> bool: """ Place a component on the board. @@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI): ) else: # Fallback: Create a basic placeholder footprint via IPC - logger.warning(f"Could not load footprint '{footprint}' from library, creating placeholder") + logger.warning( + f"Could not load footprint '{footprint}' from library, creating placeholder" + ) return self._place_placeholder_footprint( reference, footprint, x, y, rotation, layer, value ) @@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI): import pcbnew # Parse library and footprint name - if ':' in footprint_path: - lib_name, fp_name = footprint_path.split(':', 1) + if ":" in footprint_path: + lib_name, fp_name = footprint_path.split(":", 1) else: # Try to find the footprint in all libraries lib_name = None @@ -560,14 +546,7 @@ class IPCBoardAPI(BoardAPI): return None def _place_loaded_footprint( - self, - loaded_fp, - reference: str, - x: float, - y: float, - rotation: float, - layer: str, - value: str + self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str ) -> bool: """ Place a loaded pcbnew footprint onto the board. @@ -589,7 +568,7 @@ class IPCBoardAPI(BoardAPI): try: docs = self._kicad.get_open_documents() for doc in docs: - if hasattr(doc, 'path') and str(doc.path).endswith('.kicad_pcb'): + if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"): board_path = str(doc.path) break except Exception as e: @@ -637,24 +616,27 @@ class IPCBoardAPI(BoardAPI): except Exception as e: logger.debug(f"Could not refresh IPC board: {e}") - self._notify("component_placed", { - "reference": reference, - "footprint": loaded_fp.GetFPIDAsString(), - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": True - }) + self._notify( + "component_placed", + { + "reference": reference, + "footprint": loaded_fp.GetFPIDAsString(), + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "loaded_from_library": True, + }, + ) - logger.info(f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm") + logger.info( + f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm" + ) return True except Exception as e: logger.error(f"Error placing loaded footprint: {e}") # Fall back to placeholder - return self._place_placeholder_footprint( - reference, "", x, y, rotation, layer, value - ) + return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value) def _place_placeholder_footprint( self, @@ -664,7 +646,7 @@ class IPCBoardAPI(BoardAPI): y: float, rotation: float, layer: str, - value: str + value: str, ) -> bool: """ Place a placeholder footprint when library loading fails. @@ -701,15 +683,18 @@ class IPCBoardAPI(BoardAPI): board.create_items(fp) board.push_commit(commit, f"Placed component {reference}") - self._notify("component_placed", { - "reference": reference, - "footprint": footprint, - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": False, - "is_placeholder": True - }) + self._notify( + "component_placed", + { + "reference": reference, + "footprint": footprint, + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "loaded_from_library": False, + "is_placeholder": True, + }, + ) logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm") return True @@ -718,7 +703,9 @@ class IPCBoardAPI(BoardAPI): logger.error(f"Failed to place placeholder component: {e}") return False - def move_component(self, reference: str, x: float, y: float, rotation: Optional[float] = None) -> bool: + def move_component( + self, reference: str, x: float, y: float, rotation: Optional[float] = None + ) -> bool: """Move a component to a new position (updates UI immediately).""" try: from kipy.geometry import Vector2, Angle @@ -749,11 +736,10 @@ class IPCBoardAPI(BoardAPI): board.update_items([target_fp]) board.push_commit(commit, f"Moved component {reference}") - self._notify("component_moved", { - "reference": reference, - "position": {"x": x, "y": y}, - "rotation": rotation - }) + self._notify( + "component_moved", + {"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation}, + ) return True @@ -799,7 +785,7 @@ class IPCBoardAPI(BoardAPI): end_y: float, width: float = 0.25, layer: str = "F.Cu", - net_name: Optional[str] = None + net_name: Optional[str] = None, ) -> bool: """ Add a track (trace) to the board. @@ -842,13 +828,16 @@ class IPCBoardAPI(BoardAPI): board.create_items(track) board.push_commit(commit, "Added track") - self._notify("track_added", { - "start": {"x": start_x, "y": start_y}, - "end": {"x": end_x, "y": end_y}, - "width": width, - "layer": layer, - "net": net_name - }) + self._notify( + "track_added", + { + "start": {"x": start_x, "y": start_y}, + "end": {"x": end_x, "y": end_y}, + "width": width, + "layer": layer, + "net": net_name, + }, + ) logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm") return True @@ -864,7 +853,7 @@ class IPCBoardAPI(BoardAPI): diameter: float = 0.8, drill: float = 0.4, net_name: Optional[str] = None, - via_type: str = "through" + via_type: str = "through", ) -> bool: """ Add a via to the board. @@ -906,13 +895,16 @@ class IPCBoardAPI(BoardAPI): board.create_items(via) board.push_commit(commit, "Added via") - self._notify("via_added", { - "position": {"x": x, "y": y}, - "diameter": diameter, - "drill": drill, - "net": net_name, - "type": via_type - }) + self._notify( + "via_added", + { + "position": {"x": x, "y": y}, + "diameter": diameter, + "drill": drill, + "net": net_name, + "type": via_type, + }, + ) logger.info(f"Added via at ({x}, {y}) mm") return True @@ -928,7 +920,7 @@ class IPCBoardAPI(BoardAPI): y: float, layer: str = "F.SilkS", size: float = 1.0, - rotation: float = 0 + rotation: float = 0, ) -> bool: """Add text to the board.""" try: @@ -959,11 +951,7 @@ class IPCBoardAPI(BoardAPI): board.create_items(board_text) board.push_commit(commit, f"Added text: {text}") - self._notify("text_added", { - "text": text, - "position": {"x": x, "y": y}, - "layer": layer - }) + self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer}) return True @@ -982,20 +970,16 @@ class IPCBoardAPI(BoardAPI): result = [] for track in tracks: try: - result.append({ - "start": { - "x": to_mm(track.start.x), - "y": to_mm(track.start.y) - }, - "end": { - "x": to_mm(track.end.x), - "y": to_mm(track.end.y) - }, - "width": to_mm(track.width), - "layer": str(track.layer), - "net": track.net.name if track.net else "", - "id": str(track.id) if hasattr(track, 'id') else "" - }) + result.append( + { + "start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)}, + "end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)}, + "width": to_mm(track.width), + "layer": str(track.layer), + "net": track.net.name if track.net else "", + "id": str(track.id) if hasattr(track, "id") else "", + } + ) except Exception as e: logger.warning(f"Error processing track: {e}") continue @@ -1017,17 +1001,16 @@ class IPCBoardAPI(BoardAPI): result = [] for via in vias: try: - result.append({ - "position": { - "x": to_mm(via.position.x), - "y": to_mm(via.position.y) - }, - "diameter": to_mm(via.diameter), - "drill": to_mm(via.drill_diameter), - "net": via.net.name if via.net else "", - "type": str(via.type), - "id": str(via.id) if hasattr(via, 'id') else "" - }) + result.append( + { + "position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)}, + "diameter": to_mm(via.diameter), + "drill": to_mm(via.drill_diameter), + "net": via.net.name if via.net else "", + "type": str(via.type), + "id": str(via.id) if hasattr(via, "id") else "", + } + ) except Exception as e: logger.warning(f"Error processing via: {e}") continue @@ -1047,10 +1030,9 @@ class IPCBoardAPI(BoardAPI): result = [] for net in nets: try: - result.append({ - "name": net.name, - "code": net.code if hasattr(net, 'code') else 0 - }) + result.append( + {"name": net.name, "code": net.code if hasattr(net, "code") else 0} + ) except Exception as e: logger.warning(f"Error processing net: {e}") continue @@ -1070,7 +1052,7 @@ class IPCBoardAPI(BoardAPI): min_thickness: float = 0.25, priority: int = 0, fill_mode: str = "solid", - name: str = "" + name: str = "", ) -> bool: """ Add a copper pour zone to the board. @@ -1157,12 +1139,10 @@ class IPCBoardAPI(BoardAPI): board.create_items(zone) board.push_commit(commit, f"Added copper zone on {layer}") - self._notify("zone_added", { - "layer": layer, - "net": net_name, - "points": len(points), - "priority": priority - }) + self._notify( + "zone_added", + {"layer": layer, "net": net_name, "points": len(points), "priority": priority}, + ) logger.info(f"Added zone on {layer} with {len(points)} points") return True @@ -1182,14 +1162,18 @@ class IPCBoardAPI(BoardAPI): result = [] for zone in zones: try: - result.append({ - "name": zone.name if hasattr(zone, 'name') else "", - "net": zone.net.name if zone.net else "", - "priority": zone.priority if hasattr(zone, 'priority') else 0, - "layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [], - "filled": zone.filled if hasattr(zone, 'filled') else False, - "id": str(zone.id) if hasattr(zone, 'id') else "" - }) + result.append( + { + "name": zone.name if hasattr(zone, "name") else "", + "net": zone.net.name if zone.net else "", + "priority": zone.priority if hasattr(zone, "priority") else 0, + "layers": ( + [str(l) for l in zone.layers] if hasattr(zone, "layers") else [] + ), + "filled": zone.filled if hasattr(zone, "filled") else False, + "id": str(zone.id) if hasattr(zone, "id") else "", + } + ) except Exception as e: logger.warning(f"Error processing zone: {e}") continue @@ -1219,10 +1203,9 @@ class IPCBoardAPI(BoardAPI): result = [] for item in selection: - result.append({ - "type": type(item).__name__, - "id": str(item.id) if hasattr(item, 'id') else "" - }) + result.append( + {"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""} + ) return result except Exception as e: @@ -1241,4 +1224,4 @@ class IPCBoardAPI(BoardAPI): # Export for factory -__all__ = ['IPCBackend', 'IPCBoardAPI'] +__all__ = ["IPCBackend", "IPCBoardAPI"] diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index 4aedcd2..312e0b6 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -8,16 +8,12 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0 and will be removed in KiCAD 10.0. Please migrate to IPC backend. """ + import logging from pathlib import Path from typing import Optional, Dict, Any, List -from kicad_api.base import ( - KiCADBackend, - BoardAPI, - ConnectionError, - APINotAvailableError -) +from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError logger = logging.getLogger(__name__) @@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend): """ try: import pcbnew + self._pcbnew = pcbnew version = pcbnew.GetBuildVersion() logger.info(f"✓ Connected to pcbnew (SWIG): {version}") @@ -191,7 +188,7 @@ class SWIGBoardAPI(BoardAPI): x: float, y: float, rotation: float = 0, - layer: str = "F.Cu" + layer: str = "F.Cu", ) -> bool: """Place component using existing implementation""" from commands.component import ComponentCommands @@ -202,7 +199,7 @@ class SWIGBoardAPI(BoardAPI): position={"x": x, "y": y, "unit": "mm"}, reference=reference, rotation=rotation, - layer=layer + layer=layer, ) return result.get("success", False) except Exception as e: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 3d78e6e..80fab5f 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -52,9 +52,7 @@ if sys.platform == "win32": # List versions try: versions = [ - d - for d in os.listdir(base_path) - if os.path.isdir(os.path.join(base_path, d)) + d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d)) ] logger.info(f" Versions found: {', '.join(versions)}") for version in versions: @@ -92,9 +90,7 @@ paths_added = PlatformHelper.add_kicad_to_python_path() if paths_added: logger.info("Successfully added KiCAD Python paths to sys.path") else: - logger.warning( - "No KiCAD Python paths found - attempting to import pcbnew from system path" - ) + logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path") logger.info(f"Current Python path: {sys.path}") @@ -434,9 +430,7 @@ class KiCADInterface: "check_freerouting": self.freerouting_commands.check_freerouting, } - logger.info( - f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})" - ) + logger.info(f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})") # Commands that can be handled via IPC for real-time updates IPC_CAPABLE_COMMANDS = { @@ -475,11 +469,7 @@ class KiCADInterface: try: # Check if we can use IPC for this command (real-time UI sync) - if ( - self.use_ipc - and self.ipc_board_api - and command in self.IPC_CAPABLE_COMMANDS - ): + if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS: ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command] ipc_handler = getattr(self, ipc_handler_name, None) @@ -602,9 +592,7 @@ class KiCADInterface: # - TypeScript tools use: name, path # - Python schema uses: filename, title # - Legacy uses: projectName, path, metadata - project_name = ( - params.get("projectName") or params.get("name") or params.get("title") - ) + project_name = params.get("projectName") or params.get("name") or params.get("title") # Handle filename parameter - it may contain full path filename = params.get("filename") @@ -665,13 +653,9 @@ class KiCADInterface: board_path = params.get("boardPath") if board_path: board_path_norm = str(Path(board_path).resolve()) - current_board_file = ( - str(Path(self.board.GetFileName()).resolve()) if self.board else "" - ) + current_board_file = str(Path(self.board.GetFileName()).resolve()) if self.board else "" if board_path_norm != current_board_file: - logger.info( - f"boardPath differs from current board — reloading: {board_path}" - ) + logger.info(f"boardPath differs from current board — reloading: {board_path}") try: self.board = pcbnew.LoadBoard(board_path) self._update_command_handlers() @@ -689,9 +673,7 @@ class KiCADInterface: self._current_project_path = project_path local_lib = FootprintLibraryManager(project_path=project_path) self.component_commands = ComponentCommands(self.board, local_lib) - logger.info( - f"Reloaded FootprintLibraryManager with project_path={project_path}" - ) + logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}") return self.component_commands.place_component(params) @@ -788,9 +770,7 @@ 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 ALL placed symbol blocks matching the reference (handles duplicates). # Use content-string search so multi-line KiCAD format is handled correctly: @@ -840,9 +820,7 @@ class KiCADInterface: f.write(content) deleted_count = len(blocks_to_delete) - logger.info( - f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}" - ) + logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") return { "success": True, "reference": reference, @@ -918,9 +896,7 @@ 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 placed symbol blocks that match the reference # Search for (symbol (lib_id "...") ... (property "Reference" "" ...) ...) @@ -1052,9 +1028,7 @@ 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 @@ -1215,9 +1189,7 @@ class KiCADInterface: match = find_nearest_pin(points[-1], snap_tolerance) if match: ref, pin_num, coords = match - logger.info( - f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})" - ) + logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})") snapped_info.append( f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" ) @@ -1312,9 +1284,7 @@ class KiCADInterface: def _handle_create_footprint(self, params): """Create a new .kicad_mod footprint file in a .pretty library.""" - logger.info( - f"create_footprint: {params.get('name')} in {params.get('libraryPath')}" - ) + logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") try: creator = FootprintCreator() return creator.create_footprint( @@ -1358,9 +1328,7 @@ class KiCADInterface: logger.info("list_footprint_libraries") try: creator = FootprintCreator() - return creator.list_footprint_libraries( - search_paths=params.get("searchPaths") - ) + return creator.list_footprint_libraries(search_paths=params.get("searchPaths")) except Exception as e: logger.error(f"list_footprint_libraries error: {e}") return {"success": False, "error": str(e)} @@ -1387,9 +1355,7 @@ class KiCADInterface: def _handle_create_symbol(self, params): """Create a new symbol in a .kicad_sym library.""" - logger.info( - f"create_symbol: {params.get('name')} in {params.get('libraryPath')}" - ) + logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") try: creator = SymbolCreator() return creator.create_symbol( @@ -1413,9 +1379,7 @@ class KiCADInterface: def _handle_delete_symbol(self, params): """Delete a symbol from a .kicad_sym library.""" - logger.info( - f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}" - ) + logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") try: creator = SymbolCreator() return creator.delete_symbol( @@ -1663,8 +1627,7 @@ class KiCADInterface: 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 + locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0 ) result[pin_num] = entry @@ -1747,9 +1710,7 @@ class KiCADInterface: "message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg", } - png_data = svg2png( - url=svg_path, output_width=width, output_height=height - ) + png_data = svg2png(url=svg_path, output_width=width, output_height=height) return { "success": True, @@ -1814,15 +1775,9 @@ class KiCADInterface: if ref_prefix_filter and not ref.startswith(ref_prefix_filter): continue - value = ( - symbol.property.Value.value - if hasattr(symbol.property, "Value") - else "" - ) + value = symbol.property.Value.value if hasattr(symbol.property, "Value") else "" footprint = ( - symbol.property.Footprint.value - if hasattr(symbol.property, "Footprint") - else "" + symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else "" ) position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else "" @@ -1849,9 +1804,7 @@ class KiCADInterface: "position": {"x": coords[0], "y": coords[1]}, } if pin_num in pins_def: - pin_info["name"] = pins_def[pin_num].get( - "name", pin_num - ) + pin_info["name"] = pins_def[pin_num].get("name", pin_num) pin_list.append(pin_info) comp["pins"] = pin_list except Exception: @@ -2016,9 +1969,7 @@ class KiCADInterface: if not ref.startswith("#PWR"): continue value = ( - symbol.property.Value.value - if hasattr(symbol.property, "Value") - else ref + symbol.property.Value.value if hasattr(symbol.property, "Value") else ref ) pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] labels.append( @@ -2239,9 +2190,7 @@ class KiCADInterface: start_point = [start.get("x", 0), start.get("y", 0)] end_point = [end.get("x", 0), end.get("y", 0)] - deleted = WireManager.delete_wire( - Path(schematic_path), start_point, end_point - ) + deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point) if deleted: return {"success": True} else: @@ -2453,9 +2402,7 @@ 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: @@ -2471,9 +2418,7 @@ class KiCADInterface: ] 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}") @@ -2542,9 +2487,7 @@ class KiCADInterface: 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) return {"success": True, "netlist": netlist} except Exception as e: logger.error(f"Error generating netlist: {str(e)}") @@ -2599,9 +2542,7 @@ class KiCADInterface: 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} @@ -2890,9 +2831,7 @@ class KiCADInterface: "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() @@ -2950,9 +2889,7 @@ 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}") @@ -2962,9 +2899,7 @@ class KiCADInterface: 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: @@ -2979,15 +2914,11 @@ 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] @@ -2996,9 +2927,7 @@ 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, @@ -3077,9 +3006,7 @@ class KiCADInterface: } 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 @@ -3117,11 +3044,7 @@ print("ok") "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") @@ -3151,16 +3074,8 @@ print("ok") net = params.get("net") # Handle both dict format and direct x/y - start_x = ( - start.get("x", 0) - if isinstance(start, dict) - else params.get("startX", 0) - ) - start_y = ( - start.get("y", 0) - if isinstance(start, dict) - else params.get("startY", 0) - ) + start_x = start.get("x", 0) if isinstance(start, dict) else params.get("startX", 0) + start_y = start.get("y", 0) if isinstance(start, dict) else params.get("startY", 0) end_x = end.get("x", 0) if isinstance(end, dict) else params.get("endX", 0) end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0) @@ -3177,9 +3092,7 @@ print("ok") return { "success": success, "message": ( - "Added trace (visible in KiCAD UI)" - if success - else "Failed to add trace" + "Added trace (visible in KiCAD UI)" if success else "Failed to add trace" ), "trace": { "start": {"x": start_x, "y": start_y, "unit": "mm"}, @@ -3197,16 +3110,8 @@ print("ok") """IPC handler for add_via - adds via with real-time UI update""" try: position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) size = params.get("size", 0.8) drill = params.get("drill", 0.4) @@ -3220,11 +3125,7 @@ print("ok") return { "success": success, - "message": ( - "Added via (visible in KiCAD UI)" - if success - else "Failed to add via" - ), + "message": ("Added via (visible in KiCAD UI)" if success else "Failed to add via"), "via": { "position": {"x": x, "y": y, "unit": "mm"}, "size": size, @@ -3271,9 +3172,7 @@ print("ok") # Convert points format if needed (handle both {x, y} and {x, y, unit}) formatted_points = [] for point in points: - formatted_points.append( - {"x": point.get("x", 0), "y": point.get("y", 0)} - ) + formatted_points.append({"x": point.get("x", 0), "y": point.get("y", 0)}) success = self.ipc_board_api.add_zone( points=formatted_points, @@ -3315,9 +3214,7 @@ print("ok") return { "success": success, "message": ( - "Zones refilled (visible in KiCAD UI)" - if success - else "Failed to refill zones" + "Zones refilled (visible in KiCAD UI)" if success else "Failed to refill zones" ), } except Exception as e: @@ -3329,16 +3226,8 @@ print("ok") try: text = params.get("text", "") position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) layer = params.get("layer", "F.SilkS") size = params.get("size", 1.0) rotation = params.get("rotation", 0) @@ -3412,16 +3301,8 @@ print("ok") reference = params.get("reference", params.get("componentId", "")) footprint = params.get("footprint", "") position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) rotation = params.get("rotation", 0) layer = params.get("layer", "F.Cu") value = params.get("value", "") @@ -3460,16 +3341,8 @@ print("ok") try: reference = params.get("reference", params.get("componentId", "")) position = params.get("position", {}) - x = ( - position.get("x", 0) - if isinstance(position, dict) - else params.get("x", 0) - ) - y = ( - position.get("y", 0) - if isinstance(position, dict) - else params.get("y", 0) - ) + x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) + y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) rotation = params.get("rotation") success = self.ipc_board_api.move_component( @@ -3534,9 +3407,7 @@ print("ok") """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" # IPC API doesn't have a direct delete track method # Fall back to SWIG for this operation - logger.info( - "delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)" - ) + logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") return self.routing_commands.delete_trace(params) def _ipc_get_nets_list(self, params): @@ -3594,9 +3465,7 @@ print("ok") segment.start = Vector2.from_xy( from_mm(start.get("x", 0)), from_mm(start.get("y", 0)) ) - segment.end = Vector2.from_xy( - from_mm(end.get("x", 0)), from_mm(end.get("y", 0)) - ) + segment.end = Vector2.from_xy(from_mm(end.get("x", 0)), from_mm(end.get("y", 0))) segment.layer = BoardLayer.BL_Edge_Cuts segment.attributes.stroke.width = from_mm(width) @@ -3731,9 +3600,7 @@ print("ok") "success": True, "backend": "ipc" if self.use_ipc else "swig", "realtime_sync": self.use_ipc, - "ipc_connected": ( - self.ipc_backend.is_connected() if self.ipc_backend else False - ), + "ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False), "version": self.ipc_backend.get_version() if self.ipc_backend else "N/A", "message": ( "Using IPC backend with real-time UI sync" @@ -3760,9 +3627,7 @@ print("ok") return { "success": success, "message": ( - "Track added (visible in KiCAD UI)" - if success - else "Failed to add track" + "Track added (visible in KiCAD UI)" if success else "Failed to add track" ), "realtime": True, } @@ -3786,11 +3651,7 @@ print("ok") ) return { "success": success, - "message": ( - "Via added (visible in KiCAD UI)" - if success - else "Failed to add via" - ), + "message": ("Via added (visible in KiCAD UI)" if success else "Failed to add via"), "realtime": True, } except Exception as e: @@ -3814,9 +3675,7 @@ print("ok") return { "success": success, "message": ( - "Text added (visible in KiCAD UI)" - if success - else "Failed to add text" + "Text added (visible in KiCAD UI)" if success else "Failed to add text" ), "realtime": True, } @@ -3979,9 +3838,7 @@ print("ok") return {"success": False, "message": f"Part not found: {lcsc_number}"} # Get suggested KiCAD footprints - footprints = self.jlcpcb_parts.map_package_to_footprint( - part.get("package", "") - ) + footprints = self.jlcpcb_parts.map_package_to_footprint(part.get("package", "")) return {"success": True, "part": part, "footprints": footprints} @@ -4013,9 +3870,7 @@ print("ok") reference_price = None if original_part and original_part.get("price_breaks"): try: - reference_price = float( - original_part["price_breaks"][0].get("price", 0) - ) + reference_price = float(original_part["price_breaks"][0].get("price", 0)) except: pass @@ -4141,9 +3996,7 @@ def main(): tools.append(tool_def) else: # Fallback for tools without schemas - logger.warning( - f"No schema defined for tool: {cmd_name}" - ) + logger.warning(f"No schema defined for tool: {cmd_name}") tools.append( { "name": cmd_name, @@ -4172,11 +4025,7 @@ def main(): response = { "jsonrpc": "2.0", "id": request_id, - "result": { - "content": [ - {"type": "text", "text": json.dumps(result)} - ] - }, + "result": {"content": [{"type": "text", "text": json.dumps(result)}]}, } elif method == "resources/list": logger.info("Handling MCP resources/list") @@ -4201,9 +4050,7 @@ def main(): } else: # Read the resource - resource_data = handle_resource_read( - resource_uri, interface - ) + resource_data = handle_resource_read(resource_uri, interface) response = { "jsonrpc": "2.0", diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py index a059f6f..82cbab7 100644 --- a/python/parsers/kicad_mod_parser.py +++ b/python/parsers/kicad_mod_parser.py @@ -27,6 +27,7 @@ logger = logging.getLogger("kicad_interface") # Public API # --------------------------------------------------------------------------- + def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: """ Parse a .kicad_mod file and return a dict whose keys match the fields @@ -56,7 +57,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE) if not m: # Older / unquoted format - m = re.search(r'^\s*\(footprint\s+(\S+)', content, re.MULTILINE) + m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE) result["name"] = _unescape(m.group(1)) if m else path.stem logger.debug(f"parse_kicad_mod: name={result['name']!r}") @@ -78,7 +79,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: # Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom]) # TYPE is smd | through_hole (no quotes) # ------------------------------------------------------------------ - m = re.search(r'\(attr\s+([^)]+)\)', content) + m = re.search(r"\(attr\s+([^)]+)\)", content) if m: tokens = m.group(1).split() result["attributes"] = { @@ -107,7 +108,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: layers: set = set() for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content): layers.add(m.group(1)) - for m in re.finditer(r'\(layers\s+([^)]+)\)', content): + for m in re.finditer(r"\(layers\s+([^)]+)\)", content): for lyr in re.findall(r'"([^"]+)"', m.group(1)): layers.add(lyr) result["layers"] = sorted(layers) @@ -128,6 +129,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: # Internal helpers # --------------------------------------------------------------------------- + def _extract_pads(content: str) -> List[Dict[str, Any]]: """ Parse all (pad …) blocks and return a list of pad objects. @@ -147,8 +149,8 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]: # KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …) quoted_pattern = re.compile( r'\(pad\s+"([^"]*)"\s+' - r'(thru_hole|smd|np_thru_hole|connect)\s+' - r'(rect|circle|oval|roundrect|trapezoid|custom)\b' + r"(thru_hole|smd|np_thru_hole|connect)\s+" + r"(rect|circle|oval|roundrect|trapezoid|custom)\b" ) for m in quoted_pattern.finditer(content): number, ptype, shape = m.group(1), m.group(2), m.group(3) @@ -159,9 +161,9 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]: if not pads: # Older / unquoted format: (pad NUMBER TYPE SHAPE …) unquoted_pattern = re.compile( - r'\(pad\s+(\S+)\s+' - r'(thru_hole|smd|np_thru_hole|connect)\s+' - r'(rect|circle|oval|roundrect|trapezoid|custom)\b' + r"\(pad\s+(\S+)\s+" + r"(thru_hole|smd|np_thru_hole|connect)\s+" + r"(rect|circle|oval|roundrect|trapezoid|custom)\b" ) for m in unquoted_pattern.finditer(content): number, ptype, shape = m.group(1), m.group(2), m.group(3) @@ -183,7 +185,7 @@ def _extract_blocks(content: str, token: str) -> List[str]: parenthesis depth. This correctly handles nested parens inside blocks. """ blocks: List[str] = [] - pattern = re.compile(r'\(' + re.escape(token) + r'\b') + pattern = re.compile(r"\(" + re.escape(token) + r"\b") for match in pattern.finditer(content): start = match.start() @@ -191,9 +193,9 @@ def _extract_blocks(content: str, token: str) -> List[str]: i = start while i < len(content): ch = content[i] - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: blocks.append(content[start : i + 1]) @@ -219,8 +221,8 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: for block in _extract_blocks(content, "fp_rect"): if "F.CrtYd" not in block: continue - s = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', block) - e = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', block) + s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block) + e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block) if s and e: xs += [float(s.group(1)), float(e.group(1))] ys += [float(s.group(2)), float(e.group(2))] @@ -234,7 +236,7 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: for block in _extract_blocks(content, "fp_line"): if "F.CrtYd" not in block: continue - for m in re.finditer(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)', block): + for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block): xs.append(float(m.group(1))) ys.append(float(m.group(2))) diff --git a/python/resources/__init__.py b/python/resources/__init__.py index 65a85e9..782f961 100644 --- a/python/resources/__init__.py +++ b/python/resources/__init__.py @@ -4,4 +4,4 @@ Resource definitions for KiCAD MCP Server from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read -__all__ = ['RESOURCE_DEFINITIONS', 'handle_resource_read'] +__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"] diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index 22f1075..feed376 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -10,7 +10,7 @@ import base64 from typing import Dict, Any, List, Optional import logging -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") # ============================================================================= # RESOURCE DEFINITIONS @@ -21,56 +21,57 @@ RESOURCE_DEFINITIONS = [ "uri": "kicad://project/current/info", "name": "Current Project Information", "description": "Metadata about the currently open KiCAD project including paths, name, and status", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/board", "name": "Board Properties", "description": "Comprehensive board information including dimensions, layer count, and design rules", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/components", "name": "Component List", "description": "List of all components on the board with references, footprints, values, and positions", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/nets", "name": "Electrical Nets", "description": "List of all electrical nets and their connections", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/layers", "name": "Layer Stack", "description": "Board layer configuration and properties", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/design-rules", "name": "Design Rules", "description": "Current design rule settings for clearances, track widths, and constraints", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://project/current/drc-report", "name": "DRC Violations", "description": "Design Rule Check violations and warnings from last DRC run", - "mimeType": "application/json" + "mimeType": "application/json", }, { "uri": "kicad://board/preview.png", "name": "Board Preview Image", "description": "2D rendering of the current board state", - "mimeType": "image/png" - } + "mimeType": "image/png", + }, ] # ============================================================================= # RESOURCE READ HANDLERS # ============================================================================= + def handle_resource_read(uri: str, interface) -> Dict[str, Any]: """ Handle reading a resource by URI @@ -92,7 +93,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]: "kicad://project/current/layers": _get_layers, "kicad://project/current/design-rules": _get_design_rules, "kicad://project/current/drc-report": _get_drc_report, - "kicad://board/preview.png": _get_board_preview + "kicad://board/preview.png": _get_board_preview, } handler = handlers.get(uri) @@ -102,67 +103,71 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]: except Exception as e: logger.error(f"Error reading resource {uri}: {str(e)}") return { - "contents": [{ - "uri": uri, - "mimeType": "text/plain", - "text": f"Error: {str(e)}" - }] + "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}] } else: return { - "contents": [{ - "uri": uri, - "mimeType": "text/plain", - "text": f"Unknown resource: {uri}" - }] + "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}] } + # ============================================================================= # INDIVIDUAL RESOURCE HANDLERS # ============================================================================= + def _get_project_info(interface) -> Dict[str, Any]: """Get current project information""" result = interface.project_commands.get_project_info({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/info", - "mimeType": "application/json", - "text": json.dumps(result.get("project", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "application/json", + "text": json.dumps(result.get("project", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/info", - "mimeType": "text/plain", - "text": "No project currently open" - }] + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "text/plain", + "text": "No project currently open", + } + ] } + def _get_board_info(interface) -> Dict[str, Any]: """Get board properties and metadata""" result = interface.board_commands.get_board_info({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/board", - "mimeType": "application/json", - "text": json.dumps(result.get("board", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "application/json", + "text": json.dumps(result.get("board", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/board", - "mimeType": "text/plain", - "text": "No board currently loaded" - }] + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "text/plain", + "text": "No board currently loaded", + } + ] } + def _get_components(interface) -> Dict[str, Any]: """Get list of all components""" result = interface.component_commands.get_component_list({}) @@ -170,24 +175,28 @@ def _get_components(interface) -> Dict[str, Any]: if result.get("success"): components = result.get("components", []) return { - "contents": [{ - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(components), - "components": components - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(components), "components": components}, indent=2 + ), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "components": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "components": []}, indent=2), + } + ] } + def _get_nets(interface) -> Dict[str, Any]: """Get list of electrical nets""" result = interface.routing_commands.get_nets_list({}) @@ -195,24 +204,26 @@ def _get_nets(interface) -> Dict[str, Any]: if result.get("success"): nets = result.get("nets", []) return { - "contents": [{ - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(nets), - "nets": nets - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": len(nets), "nets": nets}, indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "nets": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "nets": []}, indent=2), + } + ] } + def _get_layers(interface) -> Dict[str, Any]: """Get layer stack information""" result = interface.board_commands.get_layer_list({}) @@ -220,45 +231,52 @@ def _get_layers(interface) -> Dict[str, Any]: if result.get("success"): layers = result.get("layers", []) return { - "contents": [{ - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(layers), - "layers": layers - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": len(layers), "layers": layers}, indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "layers": []}, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "layers": []}, indent=2), + } + ] } + def _get_design_rules(interface) -> Dict[str, Any]: """Get design rule settings""" result = interface.design_rule_commands.get_design_rules({}) if result.get("success"): return { - "contents": [{ - "uri": "kicad://project/current/design-rules", - "mimeType": "application/json", - "text": json.dumps(result.get("rules", {}), indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "application/json", + "text": json.dumps(result.get("rules", {}), indent=2), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/design-rules", - "mimeType": "text/plain", - "text": "Design rules not available" - }] + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "text/plain", + "text": "Design rules not available", + } + ] } + def _get_drc_report(interface) -> Dict[str, Any]: """Get DRC violations""" result = interface.design_rule_commands.get_drc_violations({}) @@ -266,28 +284,35 @@ def _get_drc_report(interface) -> Dict[str, Any]: if result.get("success"): violations = result.get("violations", []) return { - "contents": [{ - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps({ - "count": len(violations), - "violations": violations - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(violations), "violations": violations}, indent=2 + ), + } + ] } else: return { - "contents": [{ - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps({ - "count": 0, - "violations": [], - "message": "Run DRC first to get violations" - }, indent=2) - }] + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + { + "count": 0, + "violations": [], + "message": "Run DRC first to get violations", + }, + indent=2, + ), + } + ] } + def _get_board_preview(interface) -> Dict[str, Any]: """Get board preview as PNG image""" result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) @@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]: # Image data should already be base64 encoded image_data = result.get("imageData", "") return { - "contents": [{ - "uri": "kicad://board/preview.png", - "mimeType": "image/png", - "blob": image_data # Base64 encoded PNG - }] + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "image/png", + "blob": image_data, # Base64 encoded PNG + } + ] } else: # Return a placeholder message return { - "contents": [{ - "uri": "kicad://board/preview.png", - "mimeType": "text/plain", - "text": "Board preview not available" - }] + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "text/plain", + "text": "Board preview not available", + } + ] } diff --git a/python/schemas/__init__.py b/python/schemas/__init__.py index 420eb48..8fbf767 100644 --- a/python/schemas/__init__.py +++ b/python/schemas/__init__.py @@ -4,4 +4,4 @@ Tool schema definitions for KiCAD MCP Server from .tool_schemas import TOOL_SCHEMAS -__all__ = ['TOOL_SCHEMAS'] +__all__ = ["TOOL_SCHEMAS"] diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index 4b59385..c8f6ee1 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -13,6 +13,7 @@ Prerequisites: Usage: ./venv/bin/python python/test_ipc_backend.py """ + import sys import os @@ -23,17 +24,16 @@ import logging # Set up logging logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) def test_connection(): """Test basic IPC connection to KiCAD.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 1: IPC Connection") - print("="*60) + print("=" * 60) try: from kicad_api.ipc_backend import IPCBackend @@ -64,9 +64,9 @@ def test_connection(): def test_board_access(backend): """Test board access and component listing.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 2: Board Access") - print("="*60) + print("=" * 60) try: board_api = backend.get_board() @@ -79,11 +79,11 @@ def test_board_access(backend): if components: print("\n First 5 components:") for comp in components[:5]: - ref = comp.get('reference', 'N/A') - val = comp.get('value', 'N/A') - pos = comp.get('position', {}) - x = pos.get('x', 0) - y = pos.get('y', 0) + ref = comp.get("reference", "N/A") + val = comp.get("value", "N/A") + pos = comp.get("position", {}) + x = pos.get("x", 0) + y = pos.get("y", 0) print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm") return board_api @@ -95,9 +95,9 @@ def test_board_access(backend): def test_board_info(board_api): """Test getting board information.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 3: Board Information") - print("="*60) + print("=" * 60) try: # Get board size @@ -136,28 +136,23 @@ def test_board_info(board_api): def test_realtime_track(board_api, interactive=False): """Test adding a track in real-time (appears immediately in KiCAD UI).""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 4: Real-time Track Addition") - print("="*60) + print("=" * 60) print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.") print("Watch the KiCAD window!") if interactive: response = input("\nProceed with adding a test track? [y/N]: ").strip().lower() - if response != 'y': + if response != "y": print("Skipped track test") return False try: # Add a track success = board_api.add_track( - start_x=100.0, - start_y=100.0, - end_x=120.0, - end_y=100.0, - width=0.25, - layer="F.Cu" + start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu" ) if success: @@ -175,28 +170,22 @@ def test_realtime_track(board_api, interactive=False): def test_realtime_via(board_api, interactive=False): """Test adding a via in real-time (appears immediately in KiCAD UI).""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 5: Real-time Via Addition") - print("="*60) + print("=" * 60) print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.") print("Watch the KiCAD window!") if interactive: response = input("\nProceed with adding a test via? [y/N]: ").strip().lower() - if response != 'y': + if response != "y": print("Skipped via test") return False try: # Add a via - success = board_api.add_via( - x=120.0, - y=100.0, - diameter=0.8, - drill=0.4, - via_type="through" - ) + success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through") if success: print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm") @@ -213,26 +202,20 @@ def test_realtime_via(board_api, interactive=False): def test_realtime_text(board_api, interactive=False): """Test adding text in real-time.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 6: Real-time Text Addition") - print("="*60) + print("=" * 60) print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.") if interactive: response = input("\nProceed with adding test text? [y/N]: ").strip().lower() - if response != 'y': + if response != "y": print("Skipped text test") return False try: - success = board_api.add_text( - text="MCP Test", - x=100.0, - y=95.0, - layer="F.SilkS", - size=1.0 - ) + success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0) if success: print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm") @@ -248,9 +231,9 @@ def test_realtime_text(board_api, interactive=False): def test_selection(board_api, interactive=False): """Test getting the current selection from KiCAD UI.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("TEST 7: UI Selection") - print("="*60) + print("=" * 60) if interactive: print("\nSelect some items in KiCAD, then press Enter...") @@ -274,26 +257,26 @@ def test_selection(board_api, interactive=False): def run_all_tests(interactive=False): """Run all IPC backend tests.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("KiCAD IPC Backend Test Suite") - print("="*60) + print("=" * 60) print("\nThis script tests real-time communication with KiCAD via IPC API.") print("Make sure KiCAD is running with a board open.\n") # Test connection backend = test_connection() if not backend: - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS FAILED: Could not connect to KiCAD") - print("="*60) + print("=" * 60) return False # Test board access board_api = test_board_access(backend) if not board_api: - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS FAILED: Could not access board") - print("="*60) + print("=" * 60) return False # Test board info @@ -307,9 +290,9 @@ def run_all_tests(interactive=False): # Test selection test_selection(board_api, interactive) - print("\n" + "="*60) + print("\n" + "=" * 60) print("TESTS COMPLETE") - print("="*60) + print("=" * 60) print("\nThe IPC backend is working! Changes appear in real-time.") print("No manual reload required - this is the power of the IPC API!") @@ -321,9 +304,14 @@ def run_all_tests(interactive=False): if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend') - parser.add_argument('-i', '--interactive', action='store_true', - help='Run in interactive mode (prompts before modifications)') + + parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend") + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Run in interactive mode (prompts before modifications)", + ) args = parser.parse_args() success = run_all_tests(interactive=args.interactive) diff --git a/python/tests/test_delete_schematic_component.py b/python/tests/test_delete_schematic_component.py index 528d033..cefeb55 100644 --- a/python/tests/test_delete_schematic_component.py +++ b/python/tests/test_delete_schematic_component.py @@ -5,6 +5,7 @@ Key regression: the handler previously used a line-by-line regex that required `(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts them on *separate* lines, so every real-world delete returned "not found". """ + import re import sys import tempfile @@ -17,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" # Inline format (single line) – matches what tests previously used -PLACED_RESISTOR_INLINE = '''\ +PLACED_RESISTOR_INLINE = """\ (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") @@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\ (effects (font (size 1.27 1.27)) hide) ) ) -''' +""" # Multi-line format – as KiCAD's own file writer produces it. # (symbol and (lib_id are on separate lines, which broke the old regex. -PLACED_RESISTOR_MULTILINE = '''\ +PLACED_RESISTOR_MULTILINE = """\ \t(symbol \t\t(lib_id "Device:R") \t\t(at 50 50 0) @@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\ \t\t\t) \t\t) \t) -''' +""" # Multi-line power symbol – the exact scenario that was reported as broken. -PLACED_POWER_SYMBOL_MULTILINE = '''\ +PLACED_POWER_SYMBOL_MULTILINE = """\ \t(symbol \t\t(lib_id "power:VCC") \t\t(at 365.6 38.1 0) @@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\ \t\t\t) \t\t) \t) -''' +""" def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: @@ -112,6 +113,7 @@ def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: # Unit tests – regression proof for the old regex vs the new approach # --------------------------------------------------------------------------- + @pytest.mark.unit class TestDeleteDetectionRegex: """Verify that the new content-string pattern finds blocks in both formats.""" @@ -154,10 +156,12 @@ class TestDeleteDetectionRegex: # Integration tests – real file I/O using the handler # --------------------------------------------------------------------------- + @pytest.mark.integration class TestDeleteSchematicComponentIntegration: def _get_handler(self): from kicad_interface import KiCADInterface + iface = KiCADInterface.__new__(KiCADInterface) return iface._handle_delete_schematic_component diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index c338c80..287c560 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -47,9 +47,7 @@ def reset_pcbnew_mock(): @pytest.fixture def mock_board(): board = MagicMock() - board.GetFileName.return_value = ( - "/tmp/test_project/test.kicad_pcb" - ) + board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" board.GetTracks.return_value = [] return board @@ -101,15 +99,14 @@ def _patch_no_runtime(): class TestCheckFreerouting: def test_no_java_no_docker(self, cmds): - with patch( - "commands.freerouting._find_java", return_value=None - ), patch( - "commands.freerouting._docker_available", - return_value=False, + with ( + patch("commands.freerouting._find_java", return_value=None), + patch( + "commands.freerouting._docker_available", + return_value=False, + ), ): - result = cmds.check_freerouting( - {"freeroutingJar": "/nonexistent.jar"} - ) + result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"}) assert result["success"] is True assert result["java"]["found"] is False assert result["docker"]["available"] is False @@ -119,48 +116,46 @@ class TestCheckFreerouting: def test_java_too_old_docker_available(self, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() - with patch( - "commands.freerouting._find_java", - return_value="/usr/bin/java", - ), patch( - "commands.freerouting._java_version_ok", - return_value=False, - ), patch( - "commands.freerouting._docker_available", - return_value=True, - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "17.0.1"', stdout="" - ) - result = cmds.check_freerouting( - {"freeroutingJar": str(jar)} - ) + with ( + patch( + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), + patch( + "commands.freerouting._java_version_ok", + return_value=False, + ), + patch( + "commands.freerouting._docker_available", + return_value=True, + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(stderr='openjdk version "17.0.1"', stdout="") + result = cmds.check_freerouting({"freeroutingJar": str(jar)}) assert result["ready"] is True assert result["execution_mode"] == "docker" def test_java_21_direct(self, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() - with patch( - "commands.freerouting._find_java", - return_value="/usr/bin/java", - ), patch( - "commands.freerouting._java_version_ok", - return_value=True, - ), patch( - "commands.freerouting._docker_available", - return_value=False, - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "21.0.1"', stdout="" - ) - result = cmds.check_freerouting( - {"freeroutingJar": str(jar)} - ) + with ( + patch( + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), + patch( + "commands.freerouting._java_version_ok", + return_value=True, + ), + patch( + "commands.freerouting._docker_available", + return_value=False, + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") + result = cmds.check_freerouting({"freeroutingJar": str(jar)}) assert result["ready"] is True assert result["execution_mode"] == "direct" @@ -198,9 +193,7 @@ class TestExportDsn: assert result["path"] == output def test_export_failure(self, cmds): - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( - "DSN error" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") result = cmds.export_dsn({}) assert result["success"] is False assert "DSN error" in result["errorDetails"] @@ -213,9 +206,7 @@ class TestExportDsn: class TestImportSes: def test_no_board(self, cmds_no_board): - result = cmds_no_board.import_ses( - {"sesPath": "/tmp/test.ses"} - ) + result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) assert result["success"] is False assert "No board" in result["message"] @@ -225,9 +216,7 @@ class TestImportSes: assert "Missing sesPath" in result["message"] def test_ses_file_not_found(self, cmds): - result = cmds.import_ses( - {"sesPath": "/nonexistent/test.ses"} - ) + result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) assert result["success"] is False assert "not found" in result["message"] @@ -245,9 +234,7 @@ class TestImportSes: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") - pcbnew_mock.ImportSpecctraSES.side_effect = Exception( - "SES error" - ) + pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error") result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is False assert "SES error" in result["errorDetails"] @@ -268,16 +255,12 @@ class TestAutoroute: jar = tmp_path / "freerouting.jar" jar.touch() with _patch_no_runtime(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "No suitable Java runtime" in result["message"] def test_no_jar(self, cmds): - result = cmds.autoroute( - {"freeroutingJar": "/nonexistent/freerouting.jar"} - ) + result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"}) assert result["success"] is False assert "JAR not found" in result["message"] @@ -286,21 +269,15 @@ class TestAutoroute: jar = tmp_path / "freerouting.jar" jar.touch() - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( - "export fail" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail") with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "DSN export failed" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_freerouting_timeout( - self, mock_run, cmds, tmp_path - ): + def test_freerouting_timeout(self, mock_run, cmds, tmp_path): import subprocess jar = tmp_path / "freerouting.jar" @@ -312,24 +289,19 @@ class TestAutoroute: dsn_file = board_dir / "test.dsn" cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.side_effect = subprocess.TimeoutExpired( - cmd="", timeout=10 - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.side_effect = subprocess.TimeoutExpired(cmd="", timeout=10) with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar), "timeout": 10} - ) + result = cmds.autoroute({"freeroutingJar": str(jar), "timeout": 10}) assert result["success"] is False assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success_direct( - self, mock_run, cmds, tmp_path - ): + def test_full_success_direct(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -341,12 +313,11 @@ class TestAutoroute: cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=0, stdout="Routing completed", stderr="" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="") ses_file.write_text("(session)") pcbnew_mock.ImportSpecctraSES.return_value = True @@ -357,9 +328,7 @@ class TestAutoroute: cmds.board.GetTracks.return_value = [track, track, via] with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is True assert result["mode"] == "direct" @@ -368,9 +337,7 @@ class TestAutoroute: assert "elapsed_seconds" in result @patch("commands.freerouting.subprocess.run") - def test_full_success_docker( - self, mock_run, cmds, tmp_path - ): + def test_full_success_docker(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -382,24 +349,24 @@ class TestAutoroute: cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=0, stdout="Routing completed", stderr="" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="") ses_file.write_text("(session)") pcbnew_mock.ImportSpecctraSES.return_value = True cmds.board.GetTracks.return_value = [MagicMock()] - with _patch_docker_mode(), patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", + with ( + _patch_docker_mode(), + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), ): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is True assert result["mode"] == "docker" @@ -409,9 +376,7 @@ class TestAutoroute: assert "--rm" in call_args @patch("commands.freerouting.subprocess.run") - def test_freerouting_nonzero_exit( - self, mock_run, cmds, tmp_path - ): + def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -421,17 +386,14 @@ class TestAutoroute: dsn_file = board_dir / "test.dsn" cmds.board.GetFileName.return_value = str(board_file) - pcbnew_mock.ExportSpecctraDSN.side_effect = ( - lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] - ) - mock_run.return_value = MagicMock( - returncode=1, stdout="", stderr="OutOfMemoryError" - ) + pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: ( + dsn_file.write_text("(pcb)"), + True, + )[1] + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="OutOfMemoryError") with _patch_direct_java(): - result = cmds.autoroute( - {"freeroutingJar": str(jar)} - ) + result = cmds.autoroute({"freeroutingJar": str(jar)}) assert result["success"] is False assert "exited with code 1" in result["message"] @@ -451,10 +413,13 @@ class TestFindJava: assert _find_java() == "/usr/bin/java" def test_none_when_not_found(self): - with patch( - "commands.freerouting.shutil.which", - return_value=None, - ), patch("os.path.isfile", return_value=False): + with ( + patch( + "commands.freerouting.shutil.which", + return_value=None, + ), + patch("os.path.isfile", return_value=False), + ): assert _find_java() is None @@ -462,18 +427,14 @@ class TestFindDocker: def test_finds_docker(self): with patch( "commands.freerouting.shutil.which", - side_effect=lambda x: "/usr/bin/docker" - if x == "docker" - else None, + side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None, ): assert _find_docker() == "/usr/bin/docker" def test_finds_podman(self): with patch( "commands.freerouting.shutil.which", - side_effect=lambda x: "/usr/bin/podman" - if x == "podman" - else None, + side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None, ): assert _find_docker() == "/usr/bin/podman" @@ -487,12 +448,13 @@ class TestFindDocker: class TestDockerAvailable: def test_docker_found(self): - with patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: + with ( + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=0) assert _docker_available() is True @@ -504,33 +466,26 @@ class TestDockerAvailable: assert _docker_available() is False def test_docker_not_running(self): - with patch( - "commands.freerouting._find_docker", - return_value="/usr/bin/docker", - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: + with ( + patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), + patch("commands.freerouting.subprocess.run") as mock_run, + ): mock_run.return_value = MagicMock(returncode=1) assert _docker_available() is False class TestJavaVersionOk: def test_java_21(self): - with patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "21.0.1"', stdout="" - ) + with patch("commands.freerouting.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") assert _java_version_ok("/usr/bin/java") is True def test_java_17(self): - with patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "17.0.18"', stdout="" - ) + with patch("commands.freerouting.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="") assert _java_version_ok("/usr/bin/java") is False def test_java_error(self): diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index c36290a..a0cd46d 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -256,18 +256,14 @@ class TestFindOverlappingElements: def test_overlapping_symbols_detected(self): # Two resistors at nearly the same position — bboxes fully overlap - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 100.1, 100 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1 assert len(result["overlappingSymbols"]) >= 1 def test_well_separated_symbols_not_flagged(self): - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 200, 200 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -294,9 +290,7 @@ class TestFindOverlappingElements: """ # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] # These overlap in Y from 101.19 to 103.81 - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 100, 105 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] >= 1, ( @@ -310,9 +304,7 @@ class TestFindOverlappingElements: R pins at y ±3.81, but different X positions far enough apart. """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( - "R2", 110, 100 - ) + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) tmp = _make_temp_schematic(extra) result = find_overlapping_elements(tmp, tolerance=0.5) assert result["totalOverlaps"] == 0 @@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) result = find_wires_crossing_symbols(tmp) # The wire must not be reported against the far-away R? at (200, 100) - collisions_at_200 = [ - c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5 - ] + collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] assert len(collisions_at_200) == 0, ( "Wire at x≈100 must not be flagged against the R? at x=200; " "likely caused by reference-lookup always returning the first 'R?'" @@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols: tmp = _make_temp_schematic(extra) result = find_wires_crossing_symbols(tmp) d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert ( - len(d1_crossings) == 0 - ), "Wire terminating at pin from outside should not be flagged" + assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" def test_wire_shorts_component_pins_detected_as_collision(self): """Regression: a wire connecting pin1→pin2 of the same component @@ -825,9 +813,7 @@ class TestComputeSymbolBboxWithGraphics: } graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # X should come from rectangle: 100 ± 1.016 @@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics: # Rectangle corners in local coords graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # After 90° rotation, X and Y swap roles @@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox: assert len(graphics_points) >= 2, "Should have extracted rectangle points" - bbox = _compute_symbol_bbox_direct( - r1, pin_defs, graphics_points=graphics_points - ) + bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) assert bbox is not None min_x, min_y, max_x, max_y = bbox # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion diff --git a/python/tests/test_schematic_tools.py b/python/tests/test_schematic_tools.py index bca8419..2c5f51c 100644 --- a/python/tests/test_schematic_tools.py +++ b/python/tests/test_schematic_tools.py @@ -8,6 +8,7 @@ Covers: (tested by calling _handle_* methods on a lightweight stub that avoids importing the full kicad_interface module). """ + import shutil import tempfile from pathlib import Path @@ -43,9 +44,7 @@ _WIRE_SCH = """\ def _write_temp_sch(content: str) -> Path: """Write *content* to a temp file and return its Path.""" - tmp = tempfile.NamedTemporaryFile( - suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8" - ) + tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") tmp.write(content) tmp.close() return Path(tmp.name) @@ -66,9 +65,7 @@ class TestDeleteWireUnit: self.WireManager = WireManager def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_wire( - tmp_path / "nope.kicad_sch", [0, 0], [10, 10] - ) + result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) assert result is False def test_no_matching_wire_returns_false(self, tmp_path): @@ -100,9 +97,7 @@ class TestDeleteLabelUnit: self.WireManager = WireManager def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_label( - tmp_path / "nope.kicad_sch", "VCC" - ) + result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") assert result is False def test_missing_label_returns_false(self, tmp_path): @@ -114,9 +109,7 @@ class TestDeleteLabelUnit: def test_position_kwarg_accepted(self, tmp_path): sch = tmp_path / "test.kicad_sch" shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label( - sch, "VCC", position=[10.0, 20.0], tolerance=0.5 - ) + result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) assert result is False @@ -145,9 +138,7 @@ class TestDeleteWireIntegration: wire_items = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("wire") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") ] assert wire_items == [], "Wire should have been removed from the file" @@ -165,18 +156,14 @@ class TestDeleteWireIntegration: sch.write_text(_WIRE_SCH, encoding="utf-8") # Coordinates differ by 0.3 mm — within default tolerance of 0.5 - result = self.WireManager.delete_wire( - sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5 - ) + result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) assert result is True def test_outside_tolerance_no_delete(self, tmp_path): sch = tmp_path / "test.kicad_sch" sch.write_text(_WIRE_SCH, encoding="utf-8") - result = self.WireManager.delete_wire( - sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0 - ) + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) # tolerance=0.0 means exact float equality — may still match on most # platforms, but the key thing is that a *distant* miss is rejected sch2 = tmp_path / "test2.kicad_sch" @@ -200,9 +187,7 @@ class TestDeleteWireIntegration: labels = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("label") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") ] assert len(labels) == 1 @@ -230,9 +215,7 @@ class TestDeleteLabelIntegration: labels = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("label") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") ] assert labels == [], "Label should have been removed" @@ -247,9 +230,7 @@ class TestDeleteLabelIntegration: sch = tmp_path / "test.kicad_sch" sch.write_text(_WIRE_SCH, encoding="utf-8") - result = self.WireManager.delete_label( - sch, "VCC", position=[99.0, 99.0], tolerance=0.5 - ) + result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) assert result is False def test_wire_preserved_after_label_deletion(self, tmp_path): @@ -260,9 +241,7 @@ class TestDeleteLabelIntegration: wires = [ item for item in data - if isinstance(item, list) - and item - and item[0] == sexpdata.Symbol("wire") + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") ] assert len(wires) == 1 @@ -387,9 +366,7 @@ class TestHandlerParamValidation: params = {} schematic_path = params.get("schematicPath") result = ( - {"success": False, "message": "schematicPath is required"} - if not schematic_path - else {} + {"success": False, "message": "schematicPath is required"} if not schematic_path else {} ) assert result["success"] is False diff --git a/python/tests/test_wire_connectivity.py b/python/tests/test_wire_connectivity.py index e8c7028..6df3c8c 100644 --- a/python/tests/test_wire_connectivity.py +++ b/python/tests/test_wire_connectivity.py @@ -265,18 +265,14 @@ class TestCoreLogic: def test_find_connected_wires_no_wire_at_point(self): wires = [[(0, 0), (10_000, 0)]] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 5.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) assert visited is None assert net_points is None def test_find_connected_wires_single_wire(self): wires = [[(0, 0), (10_000, 0)]] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 0.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) assert visited == {0} assert (0, 0) in net_points assert (10_000, 0) in net_points @@ -289,9 +285,7 @@ class TestCoreLogic: [(20_000, 0), (30_000, 0)], ] adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires( - 0.0, 0.0, wires, iu_to_wires, adjacency - ) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) assert visited == {0, 1, 2} def test_find_connected_wires_does_not_cross_gap(self): diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py index 1becfee..bcf8b34 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/python/tests/test_wire_junction_changes.py @@ -44,11 +44,7 @@ def _parse_sch(path: Path): def _find_elements(sch_data, tag: str): """Return all top-level S-expression elements with the given tag Symbol.""" - return [ - item - for item in sch_data - if isinstance(item, list) and item and item[0] == Symbol(tag) - ] + return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] # --------------------------------------------------------------------------- @@ -188,9 +184,7 @@ class TestHandleAddSchematicWireValidation: assert "Schematic path" in result["message"] def test_missing_waypoints(self): - result = self.iface._handle_add_schematic_wire( - {"schematicPath": "/tmp/x.kicad_sch"} - ) + result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) assert result["success"] is False assert "waypoint" in result["message"].lower() @@ -334,9 +328,7 @@ class TestPinSnapping: def __init__(self): pass - skip_mod.Schematic = lambda path: type( - "FakeSch", (), {"symbol": [FakeSymbol()]} - )() + skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} @@ -350,9 +342,7 @@ class TestPinSnapping: with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): iface = KiCADInterface.__new__(KiCADInterface) - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw: + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: result = iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -371,9 +361,10 @@ class TestPinSnapping: def test_snap_disabled_passes_original_coords(self): """With snapToPins=False the handler should not load PinLocator at all.""" - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls: + with ( + patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, + patch("commands.pin_locator.PinLocator") as mock_locator_cls, + ): result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -389,9 +380,7 @@ class TestPinSnapping: @patch("commands.wire_manager.WireManager.add_wire", return_value=True) def test_snap_miss_leaves_coords_unchanged(self, mock_wire): """Point beyond tolerance should not be snapped.""" - with patch( - "commands.wire_manager.WireManager.add_wire", return_value=True - ) as mw: + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -409,9 +398,7 @@ class TestPinSnapping: def test_intermediate_waypoints_not_snapped(self, mock_poly): """Middle waypoints must remain unchanged even with snapToPins=True.""" mid = [50.0, 50.0] - with patch( - "commands.wire_manager.WireManager.add_polyline_wire", return_value=True - ) as mp: + with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: result = self.iface._handle_add_schematic_wire( { "schematicPath": str(self.sch_path), @@ -451,9 +438,7 @@ class TestHandleAddSchematicJunction: assert "Schematic path" in result["message"] def test_missing_position(self): - result = self.iface._handle_add_schematic_junction( - {"schematicPath": "/tmp/x.kicad_sch"} - ) + result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) assert result["success"] is False assert "Position" in result["message"] @@ -551,9 +536,7 @@ class TestIntegrationWireManager: assert ok is True data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 3 - ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" def test_add_junction_writes_junction_element(self, sch): from commands.wire_manager import WireManager @@ -575,14 +558,10 @@ class TestIntegrationWireManager: data = _parse_sch(sch) wire = _find_elements(data, "wire")[0] pts = [ - item - for item in wire - if isinstance(item, list) and item and item[0] == Symbol("pts") + item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") ][0] xy_entries = [ - item - for item in pts - if isinstance(item, list) and item and item[0] == Symbol("xy") + item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") ] assert xy_entries[0][1] == 5.0 assert xy_entries[0][2] == 7.5 @@ -644,9 +623,7 @@ class TestIntegrationHandlerEndToEnd: assert result["success"] is True data = _parse_sch(self.sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 3 - ), f"4 waypoints should produce 3 wire segments, got {len(wires)}" + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" # --------------------------------------------------------------------------- @@ -812,9 +789,7 @@ class TestMakeWireSexp: def test_custom_stroke(self): from commands.wire_manager import WireManager - sexp = WireManager._make_wire_sexp( - [0, 0], [5, 0], stroke_width=0.5, stroke_type="dash" - ) + sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") parsed = WireManager._parse_wire(sexp) assert parsed is not None _, _, width, stype = parsed @@ -922,9 +897,7 @@ class TestBreakWiresAtPoint: data = [Symbol("kicad_sch")] data.append( - WireManager._make_wire_sexp( - [0, 0], [20, 0], stroke_width=0.5, stroke_type="dash" - ) + WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") ) data.append([Symbol("sheet_instances")]) splits = WireManager._break_wires_at_point(data, [10, 0]) @@ -998,9 +971,7 @@ class TestIntegrationTJunction: WireManager.add_junction(sch, [15, 0]) data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 2 - ), f"Expected 2 wires after junction split, got {len(wires)}" + assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" junctions = _find_elements(data, "junction") assert len(junctions) == 1 @@ -1012,9 +983,7 @@ class TestIntegrationTJunction: WireManager.add_junction(sch, [20, 0]) data = _parse_sch(sch) wires = _find_elements(data, "wire") - assert ( - len(wires) == 1 - ), f"Expected 1 wire (no split at endpoint), got {len(wires)}" + assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" def test_polyline_breaks_existing_wire(self, sch): """Polyline whose start/end hits mid-wire should break it.""" diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index d0d6df6..88ac20c 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -3,77 +3,82 @@ KiCAD Process Management Utilities Detects if KiCAD is running and provides auto-launch functionality. """ -import os -import subprocess -import logging -import platform -import time -import ctypes -from ctypes import wintypes -from pathlib import Path + +import os +import subprocess +import logging +import platform +import time +import ctypes +from ctypes import wintypes +from pathlib import Path from typing import Optional, List logger = logging.getLogger(__name__) -class KiCADProcessManager: - """Manages KiCAD process detection and launching""" - - @staticmethod - def _windows_list_processes() -> List[dict]: - """List running processes on Windows using Toolhelp API.""" - processes: List[dict] = [] - try: - TH32CS_SNAPPROCESS = 0x00000002 - try: - ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] - except AttributeError: - ulong_ptr = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong - - class PROCESSENTRY32W(ctypes.Structure): - _fields_ = [ - ("dwSize", wintypes.DWORD), - ("cntUsage", wintypes.DWORD), - ("th32ProcessID", wintypes.DWORD), - ("th32DefaultHeapID", ulong_ptr), - ("th32ModuleID", wintypes.DWORD), - ("cntThreads", wintypes.DWORD), - ("th32ParentProcessID", wintypes.DWORD), - ("pcPriClassBase", wintypes.LONG), - ("dwFlags", wintypes.DWORD), - ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), - ] - - CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot - Process32FirstW = ctypes.windll.kernel32.Process32FirstW - Process32NextW = ctypes.windll.kernel32.Process32NextW - CloseHandle = ctypes.windll.kernel32.CloseHandle - - snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) - if snapshot == wintypes.HANDLE(-1).value: - return processes - - entry = PROCESSENTRY32W() - entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) - - if Process32FirstW(snapshot, ctypes.byref(entry)): - while True: - processes.append({ - "pid": str(entry.th32ProcessID), - "name": entry.szExeFile, - "command": entry.szExeFile - }) - if not Process32NextW(snapshot, ctypes.byref(entry)): - break - - CloseHandle(snapshot) - except Exception as e: - logger.error(f"Error listing Windows processes: {e}") - - return processes - - @staticmethod - def is_running() -> bool: +class KiCADProcessManager: + """Manages KiCAD process detection and launching""" + + @staticmethod + def _windows_list_processes() -> List[dict]: + """List running processes on Windows using Toolhelp API.""" + processes: List[dict] = [] + try: + TH32CS_SNAPPROCESS = 0x00000002 + try: + ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] + except AttributeError: + ulong_ptr = ( + ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong + ) + + class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ulong_ptr), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), + ] + + CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot + Process32FirstW = ctypes.windll.kernel32.Process32FirstW + Process32NextW = ctypes.windll.kernel32.Process32NextW + CloseHandle = ctypes.windll.kernel32.CloseHandle + + snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == wintypes.HANDLE(-1).value: + return processes + + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + + if Process32FirstW(snapshot, ctypes.byref(entry)): + while True: + processes.append( + { + "pid": str(entry.th32ProcessID), + "name": entry.szExeFile, + "command": entry.szExeFile, + } + ) + if not Process32NextW(snapshot, ctypes.byref(entry)): + break + + CloseHandle(snapshot) + except Exception as e: + logger.error(f"Error listing Windows processes: {e}") + + return processes + + @staticmethod + def is_running() -> bool: """ Check if KiCAD is currently running @@ -87,27 +92,21 @@ class KiCADProcessManager: # Check for actual pcbnew/kicad binaries (not python scripts) # Use exact process name matching to avoid matching our own kicad_interface.py result = subprocess.run( - ["pgrep", "-x", "pcbnew|kicad"], - capture_output=True, - text=True + ["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True ) if result.returncode == 0: return True # Also check with -f for full path matching, but exclude our script result = subprocess.run( - ["pgrep", "-f", "/pcbnew|/kicad"], - capture_output=True, - text=True + ["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True ) # Double-check it's not our own process if result.returncode == 0: - pids = result.stdout.strip().split('\n') + pids = result.stdout.strip().split("\n") for pid in pids: try: cmdline = subprocess.run( - ["ps", "-p", pid, "-o", "command="], - capture_output=True, - text=True + ["ps", "-p", pid, "-o", "command="], capture_output=True, text=True ) if "kicad_interface.py" not in cmdline.stdout: return True @@ -117,18 +116,16 @@ class KiCADProcessManager: elif system == "Darwin": # macOS result = subprocess.run( - ["pgrep", "-f", "KiCad|pcbnew"], - capture_output=True, - text=True + ["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True ) return result.returncode == 0 - elif system == "Windows": - processes = KiCADProcessManager._windows_list_processes() - for proc in processes: - name = (proc.get("name") or "").lower() - if name in ("pcbnew.exe", "kicad.exe"): - return True + elif system == "Windows": + processes = KiCADProcessManager._windows_list_processes() + for proc in processes: + name = (proc.get("name") or "").lower() + if name in ("pcbnew.exe", "kicad.exe"): + return True return False else: @@ -151,14 +148,14 @@ class KiCADProcessManager: # Try to find executable in PATH first for cmd in ["pcbnew", "kicad"]: - result = subprocess.run( - ["which", cmd] if system != "Windows" else ["where", cmd], - capture_output=True, - text=True, - encoding="mbcs" if system == "Windows" else None, - errors="ignore" if system == "Windows" else None, - timeout=5 if system == "Windows" else None - ) + result = subprocess.run( + ["which", cmd] if system != "Windows" else ["where", cmd], + capture_output=True, + text=True, + encoding="mbcs" if system == "Windows" else None, + errors="ignore" if system == "Windows" else None, + timeout=5 if system == "Windows" else None, + ) if result.returncode == 0: path = result.stdout.strip().split("\n")[0] logger.info(f"Found KiCAD executable: {path}") @@ -232,7 +229,7 @@ class KiCADProcessManager: cmd, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.DEVNULL, ) else: # Unix: Use nohup or start in background @@ -240,7 +237,7 @@ class KiCADProcessManager: cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True + start_new_session=True, ) # Wait for process to start @@ -275,28 +272,30 @@ class KiCADProcessManager: try: if system in ["Linux", "Darwin"]: - result = subprocess.run( - ["ps", "aux"], - capture_output=True, - text=True - ) + result = subprocess.run(["ps", "aux"], capture_output=True, text=True) for line in result.stdout.split("\n"): # Only match actual KiCAD binaries, not our MCP server processes - if ("pcbnew" in line.lower() or "kicad" in line.lower()) and "kicad_interface.py" not in line and "grep" not in line: + if ( + ("pcbnew" in line.lower() or "kicad" in line.lower()) + and "kicad_interface.py" not in line + and "grep" not in line + ): # More specific check: must have /pcbnew or /kicad in the path if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line: parts = line.split() if len(parts) >= 11: - processes.append({ - "pid": parts[1], - "name": parts[10], - "command": " ".join(parts[10:]) - }) + processes.append( + { + "pid": parts[1], + "name": parts[10], + "command": " ".join(parts[10:]), + } + ) - elif system == "Windows": - for proc in KiCADProcessManager._windows_list_processes(): - name = (proc.get("name") or "").lower() - if "pcbnew" in name or "kicad" in name: + elif system == "Windows": + for proc in KiCADProcessManager._windows_list_processes(): + name = (proc.get("name") or "").lower() + if "pcbnew" in name or "kicad" in name: processes.append(proc) except Exception as e: @@ -304,6 +303,7 @@ class KiCADProcessManager: return processes + def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict: """ Check if KiCAD is running and optionally launch it @@ -325,7 +325,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "running": True, "launched": False, "processes": processes, - "message": "KiCAD is already running" + "message": "KiCAD is already running", } if not auto_launch: @@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "running": False, "launched": False, "processes": [], - "message": "KiCAD is not running (auto-launch disabled)" + "message": "KiCAD is not running (auto-launch disabled)", } # Try to launch @@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo "launched": success, "processes": manager.get_process_info() if success else [], "message": "KiCAD launched successfully" if success else "Failed to launch KiCAD", - "project": str(project_path) if project_path else None + "project": str(project_path) if project_path else None, } diff --git a/python/utils/platform_helper.py b/python/utils/platform_helper.py index fbc0426..8393153 100644 --- a/python/utils/platform_helper.py +++ b/python/utils/platform_helper.py @@ -4,6 +4,7 @@ Platform detection and path utilities for cross-platform compatibility This module provides helpers for detecting the current platform and getting appropriate paths for KiCAD, configuration, logs, etc. """ + import os import platform import sys @@ -74,19 +75,23 @@ class PlatformHelper: # Also check based on Python version py_version = f"{sys.version_info.major}.{sys.version_info.minor}" - candidates.extend([ - Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), - Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), - ]) + candidates.extend( + [ + Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), + Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), + ] + ) # Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian) # This is where pcbnew.py typically lives on modern systems - candidates.extend([ - Path(f"/usr/lib/python3/dist-packages"), - Path(f"/usr/lib/python{py_version}/dist-packages"), - Path(f"/usr/local/lib/python3/dist-packages"), - Path(f"/usr/local/lib/python{py_version}/dist-packages"), - ]) + candidates.extend( + [ + Path(f"/usr/lib/python3/dist-packages"), + Path(f"/usr/lib/python{py_version}/dist-packages"), + Path(f"/usr/local/lib/python3/dist-packages"), + Path(f"/usr/local/lib/python{py_version}/dist-packages"), + ] + ) paths = [p for p in candidates if p.exists()] @@ -102,7 +107,17 @@ class PlatformHelper: for kicad_app in kicad_app_paths: if kicad_app.exists(): for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: - path = kicad_app / "Contents" / "Frameworks" / "Python.framework" / "Versions" / version / "lib" / f"python{version}" / "site-packages" + path = ( + kicad_app + / "Contents" + / "Frameworks" + / "Python.framework" + / "Versions" + / version + / "lib" + / f"python{version}" + / "site-packages" + ) if path.exists(): paths.append(path) @@ -110,7 +125,7 @@ class PlatformHelper: homebrew_paths = [ Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon Path("/opt/homebrew/lib/python3.11/site-packages"), - Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac + Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac Path("/usr/local/lib/python3.11/site-packages"), ] for hp in homebrew_paths: @@ -161,7 +176,10 @@ class PlatformHelper: patterns = [ "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", "/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", - str(Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"), + str( + Path.home() + / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym" + ), ] # Add user library paths for all platforms @@ -301,6 +319,7 @@ def detect_platform() -> dict: if __name__ == "__main__": # Quick test/diagnostic import json + info = detect_platform() print("Platform Information:") print(json.dumps(info, indent=2)) diff --git a/tests/test_platform_helper.py b/tests/test_platform_helper.py index f242999..0742e98 100644 --- a/tests/test_platform_helper.py +++ b/tests/test_platform_helper.py @@ -3,6 +3,7 @@ Tests for platform_helper utility These are unit tests that work on all platforms. """ + import pytest import platform from pathlib import Path @@ -176,6 +177,7 @@ class TestKiCADPathDetection: PlatformHelper.add_kicad_to_python_path() try: import pcbnew + # If we get here, pcbnew is available assert pcbnew is not None version = pcbnew.GetBuildVersion()