fix: merge upstream type annotations into create_schematic signature

Combined our path parameter fix with upstream's type annotation additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Parment
2026-04-08 10:43:13 +02:00
36 changed files with 4805 additions and 4662 deletions

View File

@@ -46,7 +46,9 @@ repos:
files: ^python/
exclude: ^python/commands/board\.py$
args: [--config-file=pyproject.toml]
additional_dependencies: []
additional_dependencies:
- types-requests
- pytest
- repo: local
hooks:

View File

@@ -15,25 +15,28 @@ line_length = 100
python_version = "3.10"
warn_return_any = false
warn_unused_configs = true
ignore_missing_imports = true
check_untyped_defs = false
disallow_untyped_defs = false
explicit_package_bases = true
mypy_path = "."
mypy_path = ".:python"
disable_error_code = [
"import-untyped",
"arg-type",
"attr-defined",
"union-attr",
"call-arg",
"assignment",
"var-annotated",
"annotation-unchecked",
"index",
"has-type",
"dict-item",
"misc",
"list-item",
"return-value",
"operator",
]
[[tool.mypy.overrides]]
module = [
"pcbnew",
"cairosvg",
"sexpdata",
"skip",
"kipy",
"kipy.*",
"schematic",
"PIL",
"PIL.*",
]
ignore_missing_imports = true

View File

@@ -2,7 +2,7 @@ import logging
import os
import uuid
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
@@ -25,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls):
def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader()
@@ -86,7 +86,7 @@ class ComponentManager:
"""
# Helper function to check if template exists in schematic
def template_exists(schematic, template_ref):
def template_exists(schematic: Any, template_ref: str) -> bool:
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if (
@@ -165,7 +165,7 @@ class ComponentManager:
@staticmethod
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
):
) -> Any:
"""
Add a component to the schematic by cloning from template
@@ -265,7 +265,7 @@ class ComponentManager:
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str):
def remove_component(schematic: Schematic, component_ref: str) -> bool:
"""Remove a component from the schematic by reference designator"""
try:
# kicad-skip doesn't have a direct remove_symbol method by reference.
@@ -278,17 +278,17 @@ class ComponentManager:
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
print(f"Removed component {component_ref} from schematic.")
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error removing component {component_ref}: {e}")
logger.error(f"Error removing component {component_ref}: {e}")
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) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -302,29 +302,28 @@ class ComponentManager:
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
# Add as a new property if it doesn't exist
symbol_to_update.property.append(key, value)
print(f"Updated properties for component {component_ref}.")
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error updating component {component_ref}: {e}")
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str):
def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator"""
for symbol in schematic.symbol:
if symbol.reference == component_ref:
print(f"Found component with reference {component_ref}.")
logger.debug(f"Found component with reference {component_ref}.")
return symbol
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str):
def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic
matching_components = []
@@ -339,13 +338,13 @@ class ComponentManager:
)
):
matching_components.append(symbol)
print(f"Found {len(matching_components)} components matching query '{query}'.")
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic):
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
print(f"Retrieving all {len(schematic.symbol)} components.")
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)

View File

@@ -1,7 +1,7 @@
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from skip import Schematic
@@ -25,14 +25,14 @@ class ConnectionManager:
_pin_locator = None
@classmethod
def get_pin_locator(cls):
def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator()
return cls._pin_locator
@staticmethod
def add_net_label(schematic: Schematic, net_name: str, position: list):
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
"""
Add a net label to the schematic
@@ -57,7 +57,9 @@ 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
) -> bool:
"""
Connect a component pin to a named net using a wire stub and label
@@ -132,7 +134,7 @@ class ConnectionManager:
target_ref: str,
net_prefix: str = "PIN",
pin_offset: int = 0,
):
) -> Dict[str, List[str]]:
"""
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
@@ -203,7 +205,7 @@ class ConnectionManager:
@staticmethod
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
) -> List[Dict]:
"""
Get all connections for a named net using wire graph analysis
@@ -221,7 +223,7 @@ class ConnectionManager:
connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
def points_coincide(p1, p2):
def points_coincide(p1: Any, p2: Any) -> bool:
"""Check if two points are the same (within tolerance)"""
if not p1 or not p2:
return False
@@ -324,8 +326,8 @@ class ConnectionManager:
continue
# Check if pin coincides with any wire point
for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)):
for wire_pt_tup in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt_tup)):
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
@@ -344,8 +346,10 @@ class ConnectionManager:
symbol_y = float(symbol_pos[1])
# 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
for wire_pt_tup in connected_wire_points:
dist = (
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
@@ -361,7 +365,9 @@ class ConnectionManager:
return []
@staticmethod
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
def generate_netlist(
schematic: Schematic, schematic_path: Optional[Path] = None
) -> Dict[str, Any]:
"""
Generate a netlist from the schematic

View File

@@ -12,7 +12,7 @@ No API key required.
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]:
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.

View File

@@ -238,7 +238,10 @@ class FootprintCreator:
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: str(m.group(1)) + shape,
block,
count=1,
)
if n:
changes.append(f"shape→{shape}")

View File

@@ -95,6 +95,8 @@ def _build_freerouting_cmd(
"""Build the command to run Freerouting."""
if use_docker:
docker_exe = _find_docker()
if docker_exe is None:
raise RuntimeError("Docker/Podman executable not found")
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
@@ -120,6 +122,8 @@ def _build_freerouting_cmd(
]
else:
java_exe = _find_java()
if java_exe is None:
raise RuntimeError("Java executable not found")
return [
java_exe,
"-jar",
@@ -136,7 +140,7 @@ def _build_freerouting_cmd(
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
def __init__(self, board: Any = None) -> None:
self.board = board
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:

View File

@@ -11,7 +11,7 @@ import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -38,10 +38,10 @@ class JLCPCBPartsManager:
db_path = str(data_dir / "jlcpcb_parts.db")
self.db_path = db_path
self.conn = None
self.conn: Optional[sqlite3.Connection] = None
self._init_database()
def _init_database(self):
def _init_database(self) -> None:
"""Initialize SQLite database with schema"""
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row # Return rows as dicts
@@ -90,7 +90,9 @@ class JLCPCBPartsManager:
self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
def import_parts(self, parts: List[Dict], progress_callback=None):
def import_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCPCB API response
@@ -167,7 +169,9 @@ class JLCPCBPartsManager:
else:
return "Extended" # Default to Extended
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
def import_jlcsearch_parts(
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
) -> None:
"""
Import parts into database from JLCSearch API response
@@ -452,7 +456,7 @@ class JLCPCBPartsManager:
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):
def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]:
is_basic = 1 if p.get("library_type") == "Basic" else 0
try:
prices = json.loads(p.get("price_json", "[]"))
@@ -467,7 +471,7 @@ class JLCPCBPartsManager:
return alternatives[:limit]
def close(self):
def close(self) -> None:
"""Close database connection"""
if self.conn:
self.conn.close()

View File

@@ -7,7 +7,7 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
import logging
import time
from typing import Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Union
import requests
@@ -24,12 +24,12 @@ class JLCSearchClient:
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self):
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
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: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -87,7 +87,7 @@ class JLCSearchClient:
- stock: Available stock
- price1: Price per unit
"""
filters = {}
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
@@ -109,7 +109,7 @@ class JLCSearchClient:
Returns:
List of capacitor dicts
"""
filters = {}
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:

View File

@@ -35,7 +35,7 @@ class LibraryManager:
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from fp-lib-table files"""
# Load global libraries
global_table = self._get_global_fp_lib_table()
@@ -78,7 +78,7 @@ class LibraryManager:
return None
def _parse_fp_lib_table(self, table_path: Path):
def _parse_fp_lib_table(self, table_path: Path) -> None:
"""
Parse fp-lib-table file

View File

@@ -1,16 +1,20 @@
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
class LibraryManager:
"""Manage symbol libraries"""
@staticmethod
def list_available_libraries(search_paths=None):
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""List all available symbol libraries"""
if search_paths is None:
# Default library paths based on common KiCAD installations
@@ -31,11 +35,11 @@ class LibraryManager:
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
print(f"Error searching for libraries at {path_pattern}: {e}")
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
print(
logger.info(
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
)
@@ -43,7 +47,7 @@ class LibraryManager:
return {"paths": libraries, "names": library_names}
@staticmethod
def list_library_symbols(library_path):
def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library"""
try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library
@@ -54,30 +58,30 @@ 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(
logger.warning(
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}")
logger.error(f"Error listing symbols in library {library_path}: {e}")
return []
@staticmethod
def get_symbol_details(library_path, symbol_name):
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
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(
logger.warning(
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}")
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query, search_paths=None):
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
"""Search for symbols matching criteria"""
try:
# This would typically involve:
@@ -89,16 +93,18 @@ class LibraryManager:
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
print(
logger.warning(
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}")
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(component_type, search_paths=None):
def get_default_symbol_for_component_type(
component_type: str, search_paths: Optional[List[str]] = None
) -> Dict[str, str]:
"""Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types
# It's useful when the user doesn't specify a particular library/symbol

View File

@@ -55,7 +55,7 @@ class SymbolLibraryManager:
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files"""
# Load global libraries
global_table = self._get_global_sym_lib_table()
@@ -98,7 +98,7 @@ class SymbolLibraryManager:
return None
def _parse_sym_lib_table(self, table_path: Path):
def _parse_sym_lib_table(self, table_path: Path) -> None:
"""
Parse sym-lib-table file
@@ -370,7 +370,7 @@ class SymbolLibraryManager:
query_lower = query.lower()
# Determine which libraries to search
libraries_to_search = self.libraries.keys()
libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter:
filter_lower = library_filter.lower()
libraries_to_search = [

View File

@@ -9,7 +9,7 @@ import logging
import math
import tempfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
@@ -21,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator:
"""Locate pins on symbol instances in KiCad schematics"""
def __init__(self):
def __init__(self) -> None:
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@@ -41,9 +41,9 @@ class PinLocator:
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}
}
"""
pins = {}
pins: Dict[str, Dict[str, Any]] = {}
def extract_pins_recursive(sexp):
def extract_pins_recursive(sexp: Any) -> None:
"""Recursively search for pin definitions"""
if not isinstance(sexp, list):
return

View File

@@ -115,7 +115,7 @@ class RoutingCommands:
"errorDetails": f"'{ref}' does not exist on the board",
}
def find_pad(ref: str, pad_num: str):
def find_pad(ref: str, pad_num: str) -> Any:
fp = footprints[ref]
for pad in fp.Pads():
if pad.GetNumber() == pad_num:

View File

@@ -2,6 +2,7 @@ import logging
import os
import shutil
import uuid
from typing import Any, Optional
from skip import Schematic
@@ -12,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip"""
@staticmethod
def create_schematic(name, path=None, metadata=None):
def create_schematic(name: str, path: Optional[str] = None, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template"""
try:
# Determine template path (use template_with_symbols for component cloning support)
@@ -71,7 +72,7 @@ class SchematicManager:
raise
@staticmethod
def load_schematic(file_path):
def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic"""
if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}")
@@ -85,7 +86,7 @@ class SchematicManager:
return None
@staticmethod
def save_schematic(schematic, file_path):
def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file"""
try:
# kicad-skip uses write method, not save
@@ -97,7 +98,7 @@ class SchematicManager:
return False
@staticmethod
def get_schematic_metadata(schematic):
def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator.

View File

@@ -782,7 +782,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
collisions = []
# Pre-compute per-symbol data
symbol_data = []
symbol_data: List[Dict[str, Any]] = []
for sym in symbols:
ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:

View File

@@ -129,7 +129,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux, uy, vx, vy):
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
@@ -293,10 +293,10 @@ 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():
def identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B):
def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
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()
@@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
return out
def _mat_mul(A, B):
def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
@@ -366,7 +366,7 @@ def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optio
return default
def _identity():
def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

View File

@@ -8,7 +8,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm.
import logging
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
@@ -22,7 +22,7 @@ def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic) -> List[List[Tuple[int, int]]]:
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
@@ -67,7 +67,9 @@ def _build_adjacency(
return adjacency, iu_to_wires
def _parse_virtual_connections(schematic, schematic_path):
def _parse_virtual_connections(
schematic: Any, schematic_path: Any
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
"""Return virtual connectivity from net labels and power symbols.
Returns a tuple of:
@@ -175,8 +177,8 @@ def _find_connected_wires(
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path,
schematic,
schematic_path: Any,
schematic: Any,
) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching.
@@ -216,7 +218,7 @@ def _find_pins_on_net(
def get_wire_connections(
schematic, schematic_path: str, x_mm: float, y_mm: float
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]:
"""Find all component pins reachable from a point via connected wires, net labels, and power symbols.

View File

@@ -7,7 +7,7 @@ All methods operate on in-memory sexpdata lists (no disk I/O).
import logging
import math
import uuid
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
@@ -55,7 +55,7 @@ class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str):
def find_symbol(sch_data: list, reference: str) -> Any:
"""
Find a placed symbol by reference designator.
@@ -218,7 +218,7 @@ class WireDragger:
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float):
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny

View File

@@ -11,7 +11,7 @@ import math
import tempfile
import uuid
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Any, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
@@ -255,7 +255,7 @@ class WireManager:
@staticmethod
def _parse_wire(
wire_item,
wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
"""
Parse a wire S-expression item in a single pass.

View File

@@ -127,7 +127,7 @@ class BoardAPI(ABC):
pass
@abstractmethod
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""
Get current board size
@@ -169,6 +169,7 @@ class BoardAPI(ABC):
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""
Place a component on the board

View File

@@ -40,10 +40,10 @@ class IPCBackend(KiCADBackend):
without requiring manual reload.
"""
def __init__(self):
def __init__(self) -> None:
self._kicad = None
self._connected = False
self._version = None
self._version: Optional[str] = None
self._on_change_callbacks: List[Callable] = []
def connect(self, socket_path: Optional[str] = None) -> bool:
@@ -257,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
Uses transactions for proper undo/redo support.
"""
def __init__(self, kicad_instance, notify_callback: Callable):
def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None:
self._kicad = kicad_instance
self._board = None
self._notify = notify_callback
self._current_commit = None
def _get_board(self):
def _get_board(self) -> Any:
"""Get board instance, connecting if needed."""
if self._board is None:
try:
@@ -350,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""Get current board size from bounding box."""
try:
board = self._get_board()
@@ -490,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place component: {e}")
return False
def _load_footprint_from_library(self, footprint_path: str):
def _load_footprint_from_library(self, footprint_path: str) -> Any:
"""
Load a footprint from the library using pcbnew SWIG API.
@@ -546,7 +546,14 @@ 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: Any,
reference: str,
x: float,
y: float,
rotation: float,
layer: str,
value: str,
) -> bool:
"""
Place a loaded pcbnew footprint onto the board.

View File

@@ -26,7 +26,7 @@ class SWIGBackend(KiCADBackend):
for compatibility during migration period.
"""
def __init__(self):
def __init__(self) -> None:
self._connected = False
self._pcbnew = None
logger.warning(
@@ -98,7 +98,7 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
result = ProjectCommands.open_project(str(path))
result = ProjectCommands().open_project({"filename": str(path)})
return result
except Exception as e:
logger.error(f"Failed to open project: {e}")
@@ -112,8 +112,10 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
path_str = str(path) if path else None
result = ProjectCommands.save_project(path_str)
params: Dict[str, Any] = {}
if path:
params["filename"] = str(path)
result = ProjectCommands().save_project(params)
return result
except Exception as e:
logger.error(f"Failed to save project: {e}")
@@ -137,7 +139,7 @@ class SWIGBackend(KiCADBackend):
class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module):
def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module
self._board = None
@@ -146,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
from commands.board import BoardCommands
try:
result = BoardCommands.set_board_size(width, height, unit)
result = BoardCommands(board=self._board).set_board_size(
{"width": width, "height": height, "unit": unit}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""Get board size"""
# TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped")
@@ -173,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
from commands.component import ComponentCommands
try:
result = ComponentCommands.get_component_list()
result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"):
return result.get("components", [])
return []
@@ -189,17 +193,20 @@ class SWIGBoardAPI(BoardAPI):
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands.place_component(
component_id=footprint,
position={"x": x, "y": y, "unit": "mm"},
reference=reference,
rotation=rotation,
layer=layer,
result = ComponentCommands(board=self._board).place_component(
{
"componentId": footprint,
"position": {"x": x, "y": y, "unit": "mm"},
"reference": reference,
"rotation": rotation,
"layer": layer,
}
)
return result.get("success", False)
except Exception as e:

View File

@@ -12,6 +12,7 @@ import logging
import os
import sys
import traceback
from pathlib import Path
from typing import Any, Dict, Optional
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -241,7 +242,7 @@ except ImportError as e:
class KiCADInterface:
"""Main interface class to handle KiCAD operations"""
def __init__(self):
def __init__(self) -> None:
"""Initialize the interface and command handlers"""
self.board = None
self.project_filename = None
@@ -560,7 +561,7 @@ class KiCADInterface:
"connect_passthrough",
}
def _auto_save_board(self):
def _auto_save_board(self) -> None:
"""Save board to disk after SWIG mutations.
Called automatically after every board-mutating SWIG command so that
data is not lost if Claude hits the context limit before save_project.
@@ -574,7 +575,7 @@ class KiCADInterface:
except Exception as e:
logger.warning(f"Auto-save failed: {e}")
def _update_command_handlers(self):
def _update_command_handlers(self) -> None:
"""Update board reference in all command handlers"""
logger.debug("Updating board reference in command handlers")
self.project_commands.board = self.board
@@ -586,7 +587,7 @@ class KiCADInterface:
self.freerouting_commands.board = self.board
# Schematic command handlers
def _handle_create_schematic(self, params):
def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new schematic"""
logger.info("Creating schematic")
try:
@@ -624,7 +625,7 @@ class KiCADInterface:
logger.error(f"Error creating schematic: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_load_schematic(self, params):
def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Load an existing schematic"""
logger.info("Loading schematic")
try:
@@ -645,7 +646,7 @@ class KiCADInterface:
logger.error(f"Error loading schematic: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_place_component(self, params):
def _handle_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Place a component on the PCB, with project-local fp-lib-table support.
If boardPath is given and differs from the currently loaded board, the
board is reloaded from boardPath before placing — prevents silent failures
@@ -680,7 +681,7 @@ class KiCADInterface:
return self.component_commands.place_component(params)
def _handle_add_schematic_component(self, params):
def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a component to a schematic using text-based injection (no sexpdata)"""
logger.info("Adding component to schematic")
try:
@@ -733,7 +734,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_delete_schematic_component(self, params):
def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Remove a placed symbol from a schematic using text-based manipulation (no skip writes)"""
logger.info("Deleting schematic component")
try:
@@ -758,7 +759,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f:
content = f.read()
def find_matching_paren(s, start):
def find_matching_paren(s: str, start: int) -> int:
"""Find the closing paren matching the opening paren at start."""
depth = 0
i = start
@@ -839,7 +840,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_edit_schematic_component(self, params):
def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Update properties of a placed symbol in a schematic (footprint, value, reference).
Uses text-based in-place editing preserves position, UUID and all other fields.
"""
@@ -884,7 +885,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f:
content = f.read()
def find_matching_paren(s, start):
def find_matching_paren(s: str, start: int) -> int:
"""Find the position of the closing paren matching the opening paren at start."""
depth = 0
i = start
@@ -929,7 +930,7 @@ class KiCADInterface:
break
search_start = end + 1
if block_start is None:
if block_start is None or block_end is None:
return {
"success": False,
"message": f"Component '{reference}' not found in schematic",
@@ -992,7 +993,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_schematic_component(self, params):
def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Return full component info: position and all field values with their (at x y angle) positions."""
logger.info("Getting schematic component info")
try:
@@ -1017,7 +1018,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f:
content = f.read()
def find_matching_paren(s, start):
def find_matching_paren(s: str, start: int) -> int:
depth = 0
i = start
while i < len(s):
@@ -1059,7 +1060,7 @@ class KiCADInterface:
break
search_start = end + 1
if block_start is None:
if block_start is None or block_end is None:
return {
"success": False,
"message": f"Component '{reference}' not found in schematic",
@@ -1115,7 +1116,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_add_schematic_wire(self, params):
def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a wire to a schematic using WireManager, with optional pin snapping"""
logger.info("Adding wire to schematic")
try:
@@ -1165,7 +1166,7 @@ class KiCADInterface:
for pin_num, coords in pin_locs.items():
all_pins.append((ref, pin_num, coords))
def find_nearest_pin(point, tolerance):
def find_nearest_pin(point: Any, tolerance: Any) -> Any:
"""Find the nearest pin within tolerance of a point."""
best = None
best_dist = tolerance
@@ -1239,7 +1240,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(),
}
def _handle_add_schematic_junction(self, params):
def _handle_add_schematic_junction(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a junction (connection dot) to a schematic using WireManager"""
logger.info("Adding junction to schematic")
try:
@@ -1272,19 +1273,19 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(),
}
def _handle_list_schematic_libraries(self, params):
def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List available symbol libraries"""
logger.info("Listing schematic libraries")
try:
search_paths = params.get("searchPaths")
libraries = LibraryManager.list_available_libraries(search_paths)
libraries = SchematicLibraryManager.list_available_libraries(search_paths)
return {"success": True, "libraries": libraries}
except Exception as e:
logger.error(f"Error listing schematic libraries: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_find_unconnected_pins(self, params):
def _handle_find_unconnected_pins(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List component pins with no wire/label/power symbol touching them"""
logger.info("Finding unconnected pins")
try:
@@ -1304,7 +1305,7 @@ class KiCADInterface:
logger.error(f"Error finding unconnected pins: {e}")
return {"success": False, "message": str(e)}
def _handle_check_wire_collisions(self, params):
def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Detect wires passing through component bodies without connecting to pins"""
logger.info("Checking wire collisions")
try:
@@ -1328,7 +1329,7 @@ class KiCADInterface:
# Footprint handlers #
# ------------------------------------------------------------------ #
def _handle_create_footprint(self, params):
def _handle_create_footprint(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new .kicad_mod footprint file in a .pretty library."""
logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
try:
@@ -1350,7 +1351,7 @@ class KiCADInterface:
logger.error(f"create_footprint error: {e}")
return {"success": False, "error": str(e)}
def _handle_edit_footprint_pad(self, params):
def _handle_edit_footprint_pad(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Edit an existing pad in a .kicad_mod file."""
logger.info(
f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}"
@@ -1369,7 +1370,7 @@ class KiCADInterface:
logger.error(f"edit_footprint_pad error: {e}")
return {"success": False, "error": str(e)}
def _handle_list_footprint_libraries(self, params):
def _handle_list_footprint_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List .pretty footprint libraries and their contents."""
logger.info("list_footprint_libraries")
try:
@@ -1379,7 +1380,7 @@ class KiCADInterface:
logger.error(f"list_footprint_libraries error: {e}")
return {"success": False, "error": str(e)}
def _handle_register_footprint_library(self, params):
def _handle_register_footprint_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Register a .pretty library in KiCAD's fp-lib-table."""
logger.info(f"register_footprint_library: {params.get('libraryPath')}")
try:
@@ -1399,7 +1400,7 @@ class KiCADInterface:
# Symbol creator handlers #
# ------------------------------------------------------------------ #
def _handle_create_symbol(self, params):
def _handle_create_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new symbol in a .kicad_sym library."""
logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
try:
@@ -1423,7 +1424,7 @@ class KiCADInterface:
logger.error(f"create_symbol error: {e}")
return {"success": False, "error": str(e)}
def _handle_delete_symbol(self, params):
def _handle_delete_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Delete a symbol from a .kicad_sym library."""
logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
try:
@@ -1436,7 +1437,7 @@ class KiCADInterface:
logger.error(f"delete_symbol error: {e}")
return {"success": False, "error": str(e)}
def _handle_list_symbols_in_library(self, params):
def _handle_list_symbols_in_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all symbols in a .kicad_sym file."""
logger.info(f"list_symbols_in_library: {params.get('libraryPath')}")
try:
@@ -1448,7 +1449,7 @@ class KiCADInterface:
logger.error(f"list_symbols_in_library error: {e}")
return {"success": False, "error": str(e)}
def _handle_register_symbol_library(self, params):
def _handle_register_symbol_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Register a .kicad_sym library in KiCAD's sym-lib-table."""
logger.info(f"register_symbol_library: {params.get('libraryPath')}")
try:
@@ -1464,7 +1465,7 @@ class KiCADInterface:
logger.error(f"register_symbol_library error: {e}")
return {"success": False, "error": str(e)}
def _handle_export_schematic_pdf(self, params):
def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export schematic to PDF"""
logger.info("Exporting schematic to PDF")
try:
@@ -1513,7 +1514,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic to PDF: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_add_schematic_net_label(self, params):
def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a net label to schematic using WireManager"""
logger.info("Adding net label to schematic")
try:
@@ -1559,7 +1560,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(),
}
def _handle_connect_to_net(self, params):
def _handle_connect_to_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Connect a component pin to a named net using wire stub and label"""
logger.info("Connecting component pin to net")
try:
@@ -1596,7 +1597,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(),
}
def _handle_connect_passthrough(self, params):
def _handle_connect_passthrough(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Connect all pins of source connector to matching pins of target connector"""
logger.info("Connecting passthrough between two connectors")
try:
@@ -1633,7 +1634,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_schematic_pin_locations(self, params):
def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Return exact pin endpoint coordinates for a schematic component"""
logger.info("Getting schematic pin locations")
try:
@@ -1688,7 +1689,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_schematic_view(self, params):
def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a rasterised image of the schematic (SVG export → optional PNG conversion)"""
logger.info("Getting schematic view")
import base64
@@ -1777,7 +1778,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_list_schematic_components(self, params):
def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all components in a schematic"""
logger.info("Listing schematic components")
try:
@@ -1870,7 +1871,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_list_schematic_nets(self, params):
def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all nets in a schematic with their connections"""
logger.info("Listing schematic nets")
try:
@@ -1916,7 +1917,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_list_schematic_wires(self, params):
def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all wires in a schematic"""
logger.info("Listing schematic wires")
try:
@@ -1959,7 +1960,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_list_schematic_labels(self, params):
def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all net labels and power flags in a schematic"""
logger.info("Listing schematic labels")
try:
@@ -2038,7 +2039,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_move_schematic_component(self, params):
def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Move a schematic component to a new position, dragging connected wires."""
logger.info("Moving schematic component")
try:
@@ -2122,7 +2123,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_rotate_schematic_component(self, params):
def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Rotate a schematic component"""
logger.info("Rotating schematic component")
try:
@@ -2172,7 +2173,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_annotate_schematic(self, params):
def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Annotate unannotated components in schematic (R? -> R1, R2, ...)"""
logger.info("Annotating schematic")
try:
@@ -2250,7 +2251,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_delete_schematic_wire(self, params):
def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Delete a wire from the schematic matching start/end points"""
logger.info("Deleting schematic wire")
try:
@@ -2281,7 +2282,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_delete_schematic_net_label(self, params):
def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Delete a net label from the schematic"""
logger.info("Deleting schematic net label")
try:
@@ -2316,7 +2317,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_export_schematic_svg(self, params):
def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export schematic to SVG using kicad-cli"""
logger.info("Exporting schematic SVG")
import glob
@@ -2390,7 +2391,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic SVG: {e}")
return {"success": False, "message": str(e)}
def _handle_get_net_connections(self, params):
def _handle_get_net_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get all connections for a named net"""
logger.info("Getting net connections")
try:
@@ -2410,7 +2411,7 @@ class KiCADInterface:
logger.error(f"Error getting net connections: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_get_wire_connections(self, params):
def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find all component pins reachable from a point via connected wires"""
logger.info("Getting wire connections")
try:
@@ -2457,7 +2458,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_run_erc(self, params):
def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic")
import os
@@ -2553,7 +2554,7 @@ class KiCADInterface:
logger.error(f"Error running ERC: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_generate_netlist(self, params):
def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Generate netlist from schematic"""
logger.info("Generating netlist from schematic")
try:
@@ -2572,7 +2573,7 @@ class KiCADInterface:
logger.error(f"Error generating netlist: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_sync_schematic_to_board(self, params):
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
Reads net connections from the schematic and assigns them to the matching pads in the PCB.
"""
@@ -2695,7 +2696,7 @@ class KiCADInterface:
# Schematic analysis tools (read-only)
# ===================================================================
def _handle_get_schematic_view_region(self, params):
def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export a cropped region of the schematic as an image"""
logger.info("Exporting schematic view region")
import base64
@@ -2810,7 +2811,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_find_overlapping_elements(self, params):
def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Detect spatially overlapping symbols, wires, and labels"""
logger.info("Finding overlapping elements in schematic")
try:
@@ -2836,7 +2837,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_get_elements_in_region(self, params):
def _handle_get_elements_in_region(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all wires, labels, and symbols within a rectangular region"""
logger.info("Getting elements in schematic region")
try:
@@ -2866,7 +2867,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_find_wires_crossing_symbols(self, params):
def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find wires that cross over component symbol bodies"""
logger.info("Finding wires crossing symbols in schematic")
try:
@@ -2892,7 +2893,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params):
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB")
try:
@@ -2939,7 +2940,7 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_snapshot_project(self, params):
def _handle_snapshot_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Copy the entire project folder to a snapshot directory for checkpoint/resume."""
import shutil
from datetime import datetime
@@ -3023,7 +3024,7 @@ class KiCADInterface:
logger.error(f"snapshot_project error: {e}")
return {"success": False, "message": str(e)}
def _handle_check_kicad_ui(self, params):
def _handle_check_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if KiCAD UI is running"""
logger.info("Checking if KiCAD UI is running")
try:
@@ -3041,7 +3042,7 @@ class KiCADInterface:
logger.error(f"Error checking KiCAD UI status: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_launch_kicad_ui(self, params):
def _handle_launch_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Launch KiCAD UI"""
logger.info("Launching KiCAD UI")
try:
@@ -3060,7 +3061,7 @@ class KiCADInterface:
logger.error(f"Error launching KiCAD UI: {str(e)}")
return {"success": False, "message": str(e)}
def _handle_refill_zones(self, params):
def _handle_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Refill all copper pour zones on the board.
pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005)
@@ -3147,7 +3148,7 @@ print("ok")
# These methods are called automatically when IPC is available
# =========================================================================
def _ipc_route_trace(self, params):
def _ipc_route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for route_trace - adds track with real-time UI update"""
try:
# Extract parameters matching the existing route_trace interface
@@ -3190,7 +3191,7 @@ print("ok")
logger.error(f"IPC route_trace error: {e}")
return {"success": False, "message": str(e)}
def _ipc_add_via(self, params):
def _ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_via - adds via with real-time UI update"""
try:
position = params.get("position", {})
@@ -3223,7 +3224,7 @@ print("ok")
logger.error(f"IPC add_via error: {e}")
return {"success": False, "message": str(e)}
def _ipc_add_net(self, params):
def _ipc_add_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_net"""
# Note: Net creation via IPC is limited - nets are typically created
# when components are placed. Return success for compatibility.
@@ -3235,7 +3236,7 @@ print("ok")
"net": {"name": name},
}
def _ipc_add_copper_pour(self, params):
def _ipc_add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_copper_pour - adds zone with real-time UI update"""
try:
layer = params.get("layer", "F.Cu")
@@ -3290,7 +3291,7 @@ print("ok")
logger.error(f"IPC add_copper_pour error: {e}")
return {"success": False, "message": str(e)}
def _ipc_refill_zones(self, params):
def _ipc_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for refill_zones - refills all zones with real-time UI update"""
try:
success = self.ipc_board_api.refill_zones()
@@ -3305,7 +3306,7 @@ print("ok")
logger.error(f"IPC refill_zones error: {e}")
return {"success": False, "message": str(e)}
def _ipc_add_text(self, params):
def _ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_text/add_board_text - adds text with real-time UI update"""
try:
text = params.get("text", "")
@@ -3332,7 +3333,7 @@ print("ok")
logger.error(f"IPC add_text error: {e}")
return {"success": False, "message": str(e)}
def _ipc_set_board_size(self, params):
def _ipc_set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for set_board_size"""
try:
width = params.get("width", 100)
@@ -3354,7 +3355,7 @@ print("ok")
logger.error(f"IPC set_board_size error: {e}")
return {"success": False, "message": str(e)}
def _ipc_get_board_info(self, params):
def _ipc_get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for get_board_info"""
try:
size = self.ipc_board_api.get_size()
@@ -3379,7 +3380,7 @@ print("ok")
logger.error(f"IPC get_board_info error: {e}")
return {"success": False, "message": str(e)}
def _ipc_place_component(self, params):
def _ipc_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for place_component - places component with real-time UI update"""
try:
reference = params.get("reference", params.get("componentId", ""))
@@ -3420,7 +3421,7 @@ print("ok")
logger.error(f"IPC place_component error: {e}")
return {"success": False, "message": str(e)}
def _ipc_move_component(self, params):
def _ipc_move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for move_component - moves component with real-time UI update"""
try:
reference = params.get("reference", params.get("componentId", ""))
@@ -3445,7 +3446,7 @@ print("ok")
logger.error(f"IPC move_component error: {e}")
return {"success": False, "message": str(e)}
def _ipc_delete_component(self, params):
def _ipc_delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for delete_component - deletes component with real-time UI update"""
try:
reference = params.get("reference", params.get("componentId", ""))
@@ -3464,7 +3465,7 @@ print("ok")
logger.error(f"IPC delete_component error: {e}")
return {"success": False, "message": str(e)}
def _ipc_get_component_list(self, params):
def _ipc_get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for get_component_list"""
try:
components = self.ipc_board_api.list_components()
@@ -3474,7 +3475,7 @@ print("ok")
logger.error(f"IPC get_component_list error: {e}")
return {"success": False, "message": str(e)}
def _ipc_save_project(self, params):
def _ipc_save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for save_project"""
try:
success = self.ipc_board_api.save()
@@ -3487,14 +3488,14 @@ print("ok")
logger.error(f"IPC save_project error: {e}")
return {"success": False, "message": str(e)}
def _ipc_delete_trace(self, params):
def _ipc_delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""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)")
return self.routing_commands.delete_trace(params)
def _ipc_get_nets_list(self, params):
def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for get_nets_list - gets nets with real-time data"""
try:
nets = self.ipc_board_api.get_nets()
@@ -3504,7 +3505,7 @@ print("ok")
logger.error(f"IPC get_nets_list error: {e}")
return {"success": False, "message": str(e)}
def _ipc_add_board_outline(self, params):
def _ipc_add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_board_outline - adds board edge with real-time UI update.
Rounded rectangles are delegated to the SWIG path because the IPC BoardSegment
type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file
@@ -3567,7 +3568,7 @@ print("ok")
logger.error(f"IPC add_board_outline error: {e}")
return {"success": False, "message": str(e)}
def _ipc_add_mounting_hole(self, params):
def _ipc_add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for add_mounting_hole - adds mounting hole with real-time UI update"""
try:
from kipy.board_types import BoardCircle
@@ -3602,7 +3603,7 @@ print("ok")
logger.error(f"IPC add_mounting_hole error: {e}")
return {"success": False, "message": str(e)}
def _ipc_get_layer_list(self, params):
def _ipc_get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for get_layer_list - gets enabled layers"""
try:
layers = self.ipc_board_api.get_enabled_layers()
@@ -3612,7 +3613,7 @@ print("ok")
logger.error(f"IPC get_layer_list error: {e}")
return {"success": False, "message": str(e)}
def _ipc_rotate_component(self, params):
def _ipc_rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for rotate_component - rotates component with real-time UI update"""
try:
reference = params.get("reference", params.get("componentId", ""))
@@ -3654,7 +3655,7 @@ print("ok")
logger.error(f"IPC rotate_component error: {e}")
return {"success": False, "message": str(e)}
def _ipc_get_component_properties(self, params):
def _ipc_get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""IPC handler for get_component_properties - gets detailed component info"""
try:
reference = params.get("reference", params.get("componentId", ""))
@@ -3678,7 +3679,7 @@ print("ok")
# Legacy IPC command handlers (explicit ipc_* commands)
# =========================================================================
def _handle_get_backend_info(self, params):
def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current backend"""
return {
"success": True,
@@ -3693,7 +3694,7 @@ print("ok")
),
}
def _handle_ipc_add_track(self, params):
def _handle_ipc_add_track(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a track using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3719,7 +3720,7 @@ print("ok")
logger.error(f"Error adding track via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_add_via(self, params):
def _handle_ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a via using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3742,7 +3743,7 @@ print("ok")
logger.error(f"Error adding via via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_add_text(self, params):
def _handle_ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3767,7 +3768,7 @@ print("ok")
logger.error(f"Error adding text via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_list_components(self, params):
def _handle_ipc_list_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List components using IPC backend"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3779,7 +3780,7 @@ print("ok")
logger.error(f"Error listing components via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_get_tracks(self, params):
def _handle_ipc_get_tracks(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get tracks using IPC backend"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3791,7 +3792,7 @@ print("ok")
logger.error(f"Error getting tracks via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_get_vias(self, params):
def _handle_ipc_get_vias(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get vias using IPC backend"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3803,7 +3804,7 @@ print("ok")
logger.error(f"Error getting vias via IPC: {e}")
return {"success": False, "message": str(e)}
def _handle_ipc_save_board(self, params):
def _handle_ipc_save_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Save board using IPC backend"""
if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"}
@@ -3820,7 +3821,7 @@ print("ok")
# JLCPCB API handlers
def _handle_download_jlcpcb_database(self, params):
def _handle_download_jlcpcb_database(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Download JLCPCB parts database from JLCSearch API"""
try:
force = params.get("force", False)
@@ -3871,7 +3872,7 @@ print("ok")
"message": f"Failed to download database: {str(e)}",
}
def _handle_search_jlcpcb_parts(self, params):
def _handle_search_jlcpcb_parts(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Search JLCPCB parts database"""
try:
query = params.get("query")
@@ -3910,7 +3911,7 @@ print("ok")
logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True)
return {"success": False, "message": f"Search failed: {str(e)}"}
def _handle_get_jlcpcb_part(self, params):
def _handle_get_jlcpcb_part(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed information for a specific JLCPCB part"""
try:
lcsc_number = params.get("lcsc_number")
@@ -3930,7 +3931,7 @@ print("ok")
logger.error(f"Error getting JLCPCB part: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get part info: {str(e)}"}
def _handle_get_jlcpcb_database_stats(self, params):
def _handle_get_jlcpcb_database_stats(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get statistics about JLCPCB database"""
try:
stats = self.jlcpcb_parts.get_database_stats()
@@ -3940,7 +3941,7 @@ print("ok")
logger.error(f"Error getting database stats: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get stats: {str(e)}"}
def _handle_suggest_jlcpcb_alternatives(self, params):
def _handle_suggest_jlcpcb_alternatives(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Suggest alternative JLCPCB parts"""
try:
lcsc_number = params.get("lcsc_number")
@@ -3981,7 +3982,7 @@ print("ok")
"message": f"Failed to suggest alternatives: {str(e)}",
}
def _handle_enrich_datasheets(self, params):
def _handle_enrich_datasheets(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Enrich schematic Datasheet fields from LCSC numbers"""
try:
from pathlib import Path
@@ -3999,7 +4000,7 @@ print("ok")
"message": f"Failed to enrich datasheets: {str(e)}",
}
def _handle_get_datasheet_url(self, params):
def _handle_get_datasheet_url(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Return LCSC datasheet and product URLs for a part number"""
try:
lcsc = params.get("lcsc", "")
@@ -4025,8 +4026,29 @@ print("ok")
}
def main():
def _write_response(response_fd: Any, response: Any) -> None:
"""Write a JSON response to the original stdout fd.
All response output goes through this function so that stray C-level
writes from pcbnew (warnings, diagnostics) never corrupt the JSON
framing seen by the TypeScript host.
"""
payload = json.dumps(response) + "\n"
os.write(response_fd, payload.encode("utf-8"))
def main() -> None:
"""Main entry point"""
# --- Redirect stdout so pcbnew C++ noise never reaches the TS host ---
# Save the real stdout fd for our exclusive JSON response channel.
_response_fd = os.dup(1)
# Point fd 1 (C-level stdout) at stderr so that any printf / std::cout
# output from pcbnew or other C extensions is visible in logs but does
# NOT corrupt the JSON stream the TypeScript side is parsing.
os.dup2(2, 1)
# Also redirect Python-level stdout to stderr for the same reason.
sys.stdout = sys.stderr
logger.info("Starting KiCAD interface...")
interface = KiCADInterface()
@@ -4168,10 +4190,9 @@ def main():
# Handle command
response = interface.handle_command(command, params)
# Send response
# Send response via the clean fd (immune to pcbnew stdout noise)
logger.debug(f"Sending response: {response}")
print(json.dumps(response))
sys.stdout.flush()
_write_response(_response_fd, response)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON input: {str(e)}")
@@ -4180,8 +4201,7 @@ def main():
"message": "Invalid JSON input",
"errorDetails": str(e),
}
print(json.dumps(response))
sys.stdout.flush()
_write_response(_response_fd, response)
except KeyboardInterrupt:
logger.info("KiCAD interface stopped")

View File

@@ -72,7 +72,7 @@ RESOURCE_DEFINITIONS = [
# =============================================================================
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
"""
Handle reading a resource by URI
@@ -116,7 +116,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
# =============================================================================
def _get_project_info(interface) -> Dict[str, Any]:
def _get_project_info(interface: Any) -> Dict[str, Any]:
"""Get current project information"""
result = interface.project_commands.get_project_info({})
@@ -142,7 +142,7 @@ def _get_project_info(interface) -> Dict[str, Any]:
}
def _get_board_info(interface) -> Dict[str, Any]:
def _get_board_info(interface: Any) -> Dict[str, Any]:
"""Get board properties and metadata"""
result = interface.board_commands.get_board_info({})
@@ -168,7 +168,7 @@ def _get_board_info(interface) -> Dict[str, Any]:
}
def _get_components(interface) -> Dict[str, Any]:
def _get_components(interface: Any) -> Dict[str, Any]:
"""Get list of all components"""
result = interface.component_commands.get_component_list({})
@@ -197,7 +197,7 @@ def _get_components(interface) -> Dict[str, Any]:
}
def _get_nets(interface) -> Dict[str, Any]:
def _get_nets(interface: Any) -> Dict[str, Any]:
"""Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({})
@@ -224,7 +224,7 @@ def _get_nets(interface) -> Dict[str, Any]:
}
def _get_layers(interface) -> Dict[str, Any]:
def _get_layers(interface: Any) -> Dict[str, Any]:
"""Get layer stack information"""
result = interface.board_commands.get_layer_list({})
@@ -251,7 +251,7 @@ def _get_layers(interface) -> Dict[str, Any]:
}
def _get_design_rules(interface) -> Dict[str, Any]:
def _get_design_rules(interface: Any) -> Dict[str, Any]:
"""Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({})
@@ -277,7 +277,7 @@ def _get_design_rules(interface) -> Dict[str, Any]:
}
def _get_drc_report(interface) -> Dict[str, Any]:
def _get_drc_report(interface: Any) -> Dict[str, Any]:
"""Get DRC violations"""
result = interface.design_rule_commands.get_drc_violations({})
@@ -313,7 +313,7 @@ def _get_drc_report(interface) -> Dict[str, Any]:
}
def _get_board_preview(interface) -> Dict[str, Any]:
def _get_board_preview(interface: Any) -> Dict[str, Any]:
"""Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})

View File

@@ -16,6 +16,7 @@ Usage:
import os
import sys
from typing import Any, Optional
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -29,7 +30,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def test_connection():
def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD."""
print("\n" + "=" * 60)
print("TEST 1: IPC Connection")
@@ -62,7 +63,7 @@ def test_connection():
return None
def test_board_access(backend):
def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing."""
print("\n" + "=" * 60)
print("TEST 2: Board Access")
@@ -93,7 +94,7 @@ def test_board_access(backend):
return None
def test_board_info(board_api):
def test_board_info(board_api: Any) -> bool:
"""Test getting board information."""
print("\n" + "=" * 60)
print("TEST 3: Board Information")
@@ -134,7 +135,7 @@ def test_board_info(board_api):
return False
def test_realtime_track(board_api, interactive=False):
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition")
@@ -168,7 +169,7 @@ def test_realtime_track(board_api, interactive=False):
return False
def test_realtime_via(board_api, interactive=False):
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition")
@@ -200,7 +201,7 @@ def test_realtime_via(board_api, interactive=False):
return False
def test_realtime_text(board_api, interactive=False):
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
"""Test adding text in real-time."""
print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition")
@@ -229,7 +230,7 @@ def test_realtime_text(board_api, interactive=False):
return False
def test_selection(board_api, interactive=False):
def test_selection(board_api: Any, interactive: bool = False) -> bool:
"""Test getting the current selection from KiCAD UI."""
print("\n" + "=" * 60)
print("TEST 7: UI Selection")
@@ -255,7 +256,7 @@ def test_selection(board_api, interactive=False):
return False
def run_all_tests(interactive=False):
def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests."""
print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite")

View File

@@ -10,6 +10,7 @@ import re
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
@@ -121,32 +122,32 @@ class TestDeleteDetectionRegex:
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
def test_old_regex_fails_on_multiline_format(self):
def test_old_regex_fails_on_multiline_format(self) -> None:
"""Regression: old line-by-line regex must NOT match the multi-line format."""
# The old code used re.match on individual lines; simulate that here.
lines = PLACED_RESISTOR_MULTILINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
assert matches == [], "Old regex should not match multi-line KiCAD format"
def test_old_regex_matches_inline_format(self):
def test_old_regex_matches_inline_format(self) -> None:
"""Old regex did work on single-line (inline) format."""
lines = PLACED_RESISTOR_INLINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
assert len(matches) == 1
def test_new_pattern_matches_multiline_format(self):
def test_new_pattern_matches_multiline_format(self) -> None:
"""New content-string pattern must find blocks in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
def test_new_pattern_matches_inline_format(self):
def test_new_pattern_matches_inline_format(self) -> None:
"""New content-string pattern also works on inline format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
def test_new_pattern_matches_power_symbol_multiline(self):
def test_new_pattern_matches_power_symbol_multiline(self) -> None:
"""New pattern must find #PWR030 power symbol in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
def test_reference_extraction_from_multiline_block(self):
def test_reference_extraction_from_multiline_block(self) -> None:
"""Reference property can be found inside a multi-line block."""
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
@@ -159,49 +160,49 @@ class TestDeleteDetectionRegex:
@pytest.mark.integration
class TestDeleteSchematicComponentIntegration:
def _get_handler(self):
def _get_handler(self) -> Any:
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component
def test_delete_inline_format_succeeds(self, tmp_path):
def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_multiline_format_succeeds(self, tmp_path):
def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None:
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None:
"""Regression: #PWR030 multi-line power symbol must be deletable."""
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_component_absent_after_delete(self, tmp_path):
def test_component_absent_after_delete(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
remaining = sch.read_text(encoding="utf-8")
assert '"#PWR030"' not in remaining
def test_unknown_reference_returns_failure(self, tmp_path):
def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
assert result["success"] is False
assert "not found" in result["message"]
def test_missing_schematic_path_returns_failure(self, tmp_path):
def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None:
result = self._get_handler()({"reference": "R1"})
assert result["success"] is False
def test_missing_reference_returns_failure(self, tmp_path):
def test_missing_reference_returns_failure(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path)
result = self._get_handler()({"schematicPath": str(sch)})
assert result["success"] is False

View File

@@ -12,6 +12,7 @@ Covers:
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"]
@pytest.fixture(autouse=True)
def reset_pcbnew_mock():
def reset_pcbnew_mock() -> Any:
"""Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock()
pcbnew_mock.ExportSpecctraDSN.side_effect = None
@@ -44,7 +45,7 @@ def reset_pcbnew_mock():
@pytest.fixture
def mock_board():
def mock_board() -> Any:
board = MagicMock()
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
board.GetTracks.return_value = []
@@ -52,16 +53,16 @@ def mock_board():
@pytest.fixture
def cmds(mock_board):
def cmds(mock_board: Any) -> Any:
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board():
def cmds_no_board() -> Any:
return FreeroutingCommands(board=None)
def _patch_direct_java():
def _patch_direct_java() -> Any:
"""Patch to simulate Java 21+ available locally."""
return patch.object(
FreeroutingCommands,
@@ -70,7 +71,7 @@ def _patch_direct_java():
)
def _patch_docker_mode():
def _patch_docker_mode() -> Any:
"""Patch to simulate Docker execution mode."""
return patch.object(
FreeroutingCommands,
@@ -79,7 +80,7 @@ def _patch_docker_mode():
)
def _patch_no_runtime():
def _patch_no_runtime() -> Any:
"""Patch to simulate no Java and no Docker."""
return patch.object(
FreeroutingCommands,
@@ -97,7 +98,7 @@ def _patch_no_runtime():
class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds):
def test_no_java_no_docker(self, cmds: Any) -> None:
with (
patch("commands.freerouting._find_java", return_value=None),
patch(
@@ -112,7 +113,7 @@ class TestCheckFreerouting:
assert result["ready"] is False
assert result["execution_mode"] == "none"
def test_java_too_old_docker_available(self, cmds, tmp_path):
def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with (
@@ -135,7 +136,7 @@ class TestCheckFreerouting:
assert result["ready"] is True
assert result["execution_mode"] == "docker"
def test_java_21_direct(self, cmds, tmp_path):
def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with (
@@ -165,12 +166,12 @@ class TestCheckFreerouting:
class TestExportDsn:
def test_no_board(self, cmds_no_board):
def test_no_board(self, cmds_no_board: Any) -> None:
result = cmds_no_board.export_dsn({})
assert result["success"] is False
assert "No board" in result["message"]
def test_export_success(self, cmds, tmp_path):
def test_export_success(self, cmds: Any, tmp_path: Any) -> None:
board_path = str(tmp_path / "test.kicad_pcb")
dsn_path = str(tmp_path / "test.dsn")
cmds.board.GetFileName.return_value = board_path
@@ -182,7 +183,7 @@ class TestExportDsn:
assert result["success"] is True
assert result["path"] == dsn_path
def test_export_custom_path(self, cmds, tmp_path):
def test_export_custom_path(self, cmds: Any, tmp_path: Any) -> None:
output = str(tmp_path / "custom.dsn")
pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(output).write_text("(pcb test)")
@@ -191,7 +192,7 @@ class TestExportDsn:
assert result["success"] is True
assert result["path"] == output
def test_export_failure(self, cmds):
def test_export_failure(self, cmds: Any) -> None:
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
result = cmds.export_dsn({})
assert result["success"] is False
@@ -204,22 +205,22 @@ class TestExportDsn:
class TestImportSes:
def test_no_board(self, cmds_no_board):
def test_no_board(self, cmds_no_board: Any) -> None:
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
assert result["success"] is False
assert "No board" in result["message"]
def test_missing_ses_path(self, cmds):
def test_missing_ses_path(self, cmds: Any) -> None:
result = cmds.import_ses({})
assert result["success"] is False
assert "Missing sesPath" in result["message"]
def test_ses_file_not_found(self, cmds):
def test_ses_file_not_found(self, cmds: Any) -> None:
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
assert result["success"] is False
assert "not found" in result["message"]
def test_import_success(self, cmds, tmp_path):
def test_import_success(self, cmds: Any, tmp_path: Any) -> None:
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
@@ -229,7 +230,7 @@ class TestImportSes:
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True
def test_import_failure(self, cmds, tmp_path):
def test_import_failure(self, cmds: Any, tmp_path: Any) -> None:
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
@@ -245,12 +246,12 @@ class TestImportSes:
class TestAutoroute:
def test_no_board(self, cmds_no_board):
def test_no_board(self, cmds_no_board: Any) -> None:
result = cmds_no_board.autoroute({})
assert result["success"] is False
assert "No board" in result["message"]
def test_no_runtime(self, cmds, tmp_path):
def test_no_runtime(self, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
with _patch_no_runtime():
@@ -258,13 +259,13 @@ class TestAutoroute:
assert result["success"] is False
assert "No suitable Java runtime" in result["message"]
def test_no_jar(self, cmds):
def test_no_jar(self, cmds: Any) -> None:
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
assert result["success"] is False
assert "JAR not found" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
def test_dsn_export_fails(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
@@ -276,7 +277,7 @@ class TestAutoroute:
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: Any, cmds: Any, tmp_path: Any) -> None:
import subprocess
jar = tmp_path / "freerouting.jar"
@@ -300,7 +301,7 @@ class TestAutoroute:
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: Any, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
@@ -336,7 +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: Any, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
@@ -375,7 +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: Any, cmds: Any, tmp_path: Any) -> None:
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
@@ -404,14 +405,14 @@ class TestAutoroute:
class TestFindJava:
def test_finds_via_which(self):
def test_finds_via_which(self) -> None:
with patch(
"commands.freerouting.shutil.which",
return_value="/usr/bin/java",
):
assert _find_java() == "/usr/bin/java"
def test_none_when_not_found(self):
def test_none_when_not_found(self) -> None:
with (
patch(
"commands.freerouting.shutil.which",
@@ -423,21 +424,21 @@ class TestFindJava:
class TestFindDocker:
def test_finds_docker(self):
def test_finds_docker(self) -> None:
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
):
assert _find_docker() == "/usr/bin/docker"
def test_finds_podman(self):
def test_finds_podman(self) -> None:
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
):
assert _find_docker() == "/usr/bin/podman"
def test_none_when_not_found(self):
def test_none_when_not_found(self) -> None:
with patch(
"commands.freerouting.shutil.which",
return_value=None,
@@ -446,7 +447,7 @@ class TestFindDocker:
class TestDockerAvailable:
def test_docker_found(self):
def test_docker_found(self) -> None:
with (
patch(
"commands.freerouting._find_docker",
@@ -457,14 +458,14 @@ class TestDockerAvailable:
mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True
def test_docker_not_installed(self):
def test_docker_not_installed(self) -> None:
with patch(
"commands.freerouting._find_docker",
return_value=None,
):
assert _docker_available() is False
def test_docker_not_running(self):
def test_docker_not_running(self) -> None:
with (
patch(
"commands.freerouting._find_docker",
@@ -477,17 +478,17 @@ class TestDockerAvailable:
class TestJavaVersionOk:
def test_java_21(self):
def test_java_21(self) -> None:
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):
def test_java_17(self) -> None:
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):
def test_java_error(self) -> None:
with patch(
"commands.freerouting.subprocess.run",
side_effect=Exception("not found"),

View File

@@ -9,6 +9,7 @@ import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -32,7 +33,7 @@ def _sym(name: str) -> Symbol:
return Symbol(name)
def _make_wire(x1, y1, x2, y2):
def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any:
return [
_sym("wire"),
[_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]],
@@ -41,7 +42,7 @@ def _make_wire(x1, y1, x2, y2):
]
def _make_junction(x, y):
def _make_junction(x: Any, y: Any) -> Any:
return [
_sym("junction"),
[_sym("at"), x, y],
@@ -51,7 +52,9 @@ def _make_junction(x, y):
]
def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None):
def _make_symbol(
ref: Any, x: Any, y: Any, rotation: Any = 0, lib_id: str = "Device:R", mirror: Any = None
) -> Any:
"""Build a minimal placed-symbol s-expression."""
item = [
_sym("symbol"),
@@ -66,7 +69,7 @@ def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None):
return item
def _make_lib_symbol_r():
def _make_lib_symbol_r() -> Any:
"""Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81)."""
return [
_sym("symbol"),
@@ -112,7 +115,7 @@ def _make_lib_symbol_r():
]
def _make_sch_data(extra_items=None):
def _make_sch_data(extra_items: Any = None) -> Any:
"""Build a minimal sch_data list with lib_symbols and sheet_instances."""
data = [
_sym("kicad_sch"),
@@ -133,20 +136,20 @@ def _make_sch_data(extra_items=None):
@pytest.mark.unit
class TestRotatePoint:
def test_zero_rotation(self):
def test_zero_rotation(self) -> None:
assert _rotate(1.0, 2.0, 0) == (1.0, 2.0)
def test_90_degrees(self):
def test_90_degrees(self) -> None:
rx, ry = _rotate(1.0, 0.0, 90)
assert abs(rx - 0.0) < 1e-9
assert abs(ry - 1.0) < 1e-9
def test_180_degrees(self):
def test_180_degrees(self) -> None:
rx, ry = _rotate(1.0, 0.0, 180)
assert abs(rx - (-1.0)) < 1e-9
assert abs(ry - 0.0) < 1e-9
def test_270_degrees(self):
def test_270_degrees(self) -> None:
rx, ry = _rotate(0.0, 1.0, 270)
assert abs(rx - 1.0) < 1e-6
assert abs(ry - 0.0) < 1e-6
@@ -159,11 +162,11 @@ class TestRotatePoint:
@pytest.mark.unit
class TestFindSymbol:
def test_returns_none_for_missing_reference(self):
def test_returns_none_for_missing_reference(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20)])
assert WireDragger.find_symbol(sch, "R99") is None
def test_returns_item_and_position(self):
def test_returns_item_and_position(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)])
result = WireDragger.find_symbol(sch, "R1")
assert result is not None
@@ -175,14 +178,14 @@ class TestFindSymbol:
assert mirror_x is False
assert mirror_y is False
def test_detects_mirror_x(self):
def test_detects_mirror_x(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")])
result = WireDragger.find_symbol(sch, "R1")
assert result is not None
assert result[5] is True # mirror_x
assert result[6] is False # mirror_y
def test_detects_mirror_y(self):
def test_detects_mirror_y(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")])
result = WireDragger.find_symbol(sch, "R1")
assert result is not None
@@ -197,7 +200,7 @@ class TestFindSymbol:
@pytest.mark.unit
class TestComputePinPositions:
def test_resistor_at_origin_no_rotation(self):
def test_resistor_at_origin_no_rotation(self) -> None:
"""Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81)."""
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
@@ -216,7 +219,7 @@ class TestComputePinPositions:
assert abs(new2[0] - 10) < 1e-4
assert abs(new2[1] - 16.19) < 1e-4
def test_resistor_rotated_90(self):
def test_resistor_rotated_90(self) -> None:
"""Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100)."""
sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100)
@@ -229,12 +232,12 @@ class TestComputePinPositions:
assert abs(old1[0] - 96.19) < 1e-3
assert abs(old1[1] - 100) < 1e-3
def test_returns_empty_for_missing_component(self):
def test_returns_empty_for_missing_component(self) -> None:
sch = _make_sch_data()
result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0)
assert result == {}
def test_delta_is_consistent(self):
def test_delta_is_consistent(self) -> None:
"""new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation."""
sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)])
positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70)
@@ -252,13 +255,13 @@ class TestComputePinPositions:
@pytest.mark.unit
class TestDragWires:
def test_no_wires_returns_zero_counts(self):
def test_no_wires_returns_zero_counts(self) -> None:
sch = _make_sch_data()
result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)})
assert result["endpoints_moved"] == 0
assert result["wires_removed"] == 0
def test_wire_start_endpoint_moved(self):
def test_wire_start_endpoint_moved(self) -> None:
wire = _make_wire(0, 3.81, 0, 10)
sch = _make_sch_data([wire])
result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -271,7 +274,7 @@ class TestDragWires:
assert abs(xy1[1] - 10.0) < EPS
assert abs(xy1[2] - 23.81) < EPS
def test_wire_end_endpoint_moved(self):
def test_wire_end_endpoint_moved(self) -> None:
wire = _make_wire(0, 10, 0, -3.81)
sch = _make_sch_data([wire])
result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)})
@@ -282,7 +285,7 @@ class TestDragWires:
assert abs(xy2[1] - 10.0) < EPS
assert abs(xy2[2] - 16.19) < EPS
def test_zero_length_wire_removed(self):
def test_zero_length_wire_removed(self) -> None:
"""When both endpoints of a wire are moved to the same point, wire is deleted."""
wire = _make_wire(0, 3.81, 0, -3.81)
sch = _make_sch_data([wire])
@@ -298,7 +301,7 @@ class TestDragWires:
wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")]
assert len(wires_remaining) == 0
def test_unrelated_wire_not_touched(self):
def test_unrelated_wire_not_touched(self) -> None:
"""A wire whose endpoints don't match any old pin is not changed."""
wire = _make_wire(50, 50, 60, 50)
sch = _make_sch_data([wire])
@@ -311,7 +314,7 @@ class TestDragWires:
assert abs(xy1[1] - 50.0) < EPS
assert abs(xy1[2] - 50.0) < EPS
def test_both_endpoints_on_moved_component(self):
def test_both_endpoints_on_moved_component(self) -> None:
"""Wire connecting two pins of same component — both endpoints shift together."""
wire = _make_wire(0, 3.81, 0, -3.81)
sch = _make_sch_data([wire])
@@ -325,7 +328,7 @@ class TestDragWires:
assert result["endpoints_moved"] == 2
assert result["wires_removed"] == 0
def test_junction_moved_with_endpoint(self):
def test_junction_moved_with_endpoint(self) -> None:
junction = _make_junction(0, 3.81)
sch = _make_sch_data([junction])
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -336,7 +339,7 @@ class TestDragWires:
assert abs(at_sub[1] - 10.0) < EPS
assert abs(at_sub[2] - 23.81) < EPS
def test_junction_at_unrelated_position_not_touched(self):
def test_junction_at_unrelated_position_not_touched(self) -> None:
junction = _make_junction(99, 99)
sch = _make_sch_data([junction])
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -355,7 +358,7 @@ class TestDragWires:
@pytest.mark.unit
class TestUpdateSymbolPosition:
def test_updates_position(self):
def test_updates_position(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20)])
result = WireDragger.update_symbol_position(sch, "R1", 30, 40)
assert result is True
@@ -363,17 +366,17 @@ class TestUpdateSymbolPosition:
assert abs(found[1] - 30) < EPS
assert abs(found[2] - 40) < EPS
def test_returns_false_for_missing(self):
def test_returns_false_for_missing(self) -> None:
sch = _make_sch_data()
assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False
def test_preserves_rotation(self):
def test_preserves_rotation(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)])
WireDragger.update_symbol_position(sch, "R1", 30, 40)
found = WireDragger.find_symbol(sch, "R1")
assert abs(found[3] - 90) < EPS # rotation preserved
def test_property_labels_follow_symbol_move(self):
def test_property_labels_follow_symbol_move(self) -> None:
"""Property (at ...) positions must shift by the same delta as the symbol."""
sym = _make_symbol("R1", 100, 80)
sch = _make_sch_data([sym])
@@ -421,7 +424,7 @@ class TestUpdateSymbolPosition:
class TestMoveWithWirePreservation:
"""Integration tests using a real .kicad_sch file."""
def _make_schematic(self, extra_sexp=""):
def _make_schematic(self, extra_sexp: Any = "") -> Any:
"""Copy empty.kicad_sch to a temp file and optionally append content."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
@@ -462,7 +465,7 @@ class TestMoveWithWirePreservation:
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
return path
def _add_wire(self, path: Path, x1, y1, x2, y2) -> Path:
def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path:
"""Append a wire to the schematic file."""
import uuid
@@ -476,7 +479,7 @@ class TestMoveWithWirePreservation:
path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
return path
def _parse_wires(self, path: Path):
def _parse_wires(self, path: Path) -> Any:
"""Return list of ((x1,y1),(x2,y2)) for every wire in the file."""
content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content)
@@ -502,7 +505,7 @@ class TestMoveWithWirePreservation:
)
return wires
def _get_symbol_pos(self, path: Path, ref: str):
def _get_symbol_pos(self, path: Path, ref: str) -> Any:
content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content)
found = WireDragger.find_symbol(data, ref)
@@ -510,7 +513,7 @@ class TestMoveWithWirePreservation:
return None
return found[1], found[2]
def test_symbol_position_updated(self):
def test_symbol_position_updated(self) -> None:
sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100)
# Call handler directly
@@ -531,7 +534,7 @@ class TestMoveWithWirePreservation:
assert abs(pos[0] - 120) < EPS
assert abs(pos[1] - 130) < EPS
def test_connected_wire_endpoint_follows_pin(self):
def test_connected_wire_endpoint_follows_pin(self) -> None:
"""Wire endpoint at pin 1 of R1 should move with the component."""
sch = self._make_schematic()
# R1 at (100, 100) — pin 1 at (100, 103.81)
@@ -563,7 +566,7 @@ class TestMoveWithWirePreservation:
abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints
), f"Expected pin endpoint near {new_pin1}, got {endpoints}"
def test_unrelated_wire_unchanged(self):
def test_unrelated_wire_unchanged(self) -> None:
"""A wire not connected to R1 must not be modified."""
sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100)
@@ -586,7 +589,7 @@ class TestMoveWithWirePreservation:
unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01]
assert len(unrelated) == 1
def test_no_zero_length_wires_after_move(self):
def test_no_zero_length_wires_after_move(self) -> None:
"""No zero-length wires should appear in the file after a move."""
sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100)
@@ -612,7 +615,7 @@ class TestMoveWithWirePreservation:
abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS
), f"Zero-length wire found at {start}"
def test_preserve_wires_false_skips_wire_update(self):
def test_preserve_wires_false_skips_wire_update(self) -> None:
"""preserveWires=False should move the symbol but leave wires alone."""
sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100)
@@ -643,7 +646,7 @@ class TestMoveWithWirePreservation:
abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints
), f"Wire should still be at {old_pin1}, got {endpoints}"
def test_missing_component_returns_error(self):
def test_missing_component_returns_error(self) -> None:
sch = self._make_schematic()
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from kicad_interface import KiCADInterface
@@ -670,7 +673,7 @@ class TestMoveWithWirePreservation:
class TestSynthesizeTouchingPinWires:
"""Unit tests for WireDragger.synthesize_touching_pin_wires."""
def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y):
def _make_two_resistors(self, r1_x: Any, r1_y: Any, r2_x: Any, r2_y: Any) -> Any:
"""Build sch_data with R1 and R2, each Device:R."""
return _make_sch_data(
[
@@ -679,14 +682,14 @@ class TestSynthesizeTouchingPinWires:
]
)
def test_no_stationary_symbols_returns_zero(self):
def test_no_stationary_symbols_returns_zero(self) -> None:
"""With only the moved component in sch_data, nothing is synthesized."""
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0
def test_touching_pin_gap_generates_wire(self):
def test_touching_pin_gap_generates_wire(self) -> None:
"""
R1 at (0, 0) pin2 at (0, -3.81).
R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch
@@ -728,7 +731,7 @@ class TestSynthesizeTouchingPinWires:
-3.81,
) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}"
def test_no_wire_when_pin_didnt_move(self):
def test_no_wire_when_pin_didnt_move(self) -> None:
"""
If old_xy == new_xy for a touching pin (component moved but this pin stayed put),
no wire should be synthesized.
@@ -740,7 +743,7 @@ class TestSynthesizeTouchingPinWires:
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0
def test_no_wire_when_rejoins_other_stationary_pin(self):
def test_no_wire_when_rejoins_other_stationary_pin(self) -> None:
"""
If the moved pin's new position coincides with another stationary pin,
no wire should be synthesized (they touch again).
@@ -759,12 +762,12 @@ class TestSynthesizeTouchingPinWires:
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"
def test_empty_pin_positions_returns_zero(self):
def test_empty_pin_positions_returns_zero(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
assert count == 0
def test_non_touching_pins_not_affected(self):
def test_non_touching_pins_not_affected(self) -> None:
"""
When R1 and R2 are NOT touching (different positions), no wire is synthesized.
"""
@@ -784,7 +787,7 @@ class TestSynthesizeTouchingPinWires:
class TestOldToNewCollision:
"""Verify that coincident pins do not silently overwrite each other in old_to_new."""
def test_handler_logs_warning_on_collision(self, caplog):
def test_handler_logs_warning_on_collision(self, caplog: Any) -> None:
"""
When two pins share the same old position, a warning should be logged
and the *first* mapping should be kept (not overwritten by the second).
@@ -827,7 +830,7 @@ class TestOldToNewCollision:
class TestTouchingPinIntegration:
"""Integration tests for pin-touching connection wire synthesis."""
def _make_schematic(self, extra_sexp=""):
def _make_schematic(self, extra_sexp: Any = "") -> Any:
"""Copy empty.kicad_sch to a temp file."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
@@ -867,7 +870,7 @@ class TestTouchingPinIntegration:
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
return path
def _parse_wires(self, path: Path):
def _parse_wires(self, path: Path) -> Any:
content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content)
wires = []
@@ -892,7 +895,7 @@ class TestTouchingPinIntegration:
)
return wires
def test_touching_pin_wire_created_on_move(self):
def test_touching_pin_wire_created_on_move(self) -> None:
"""
R1 at (100, 100) and R2 at (100, 92.38) share a touching pin:
R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
@@ -947,7 +950,7 @@ class TestTouchingPinIntegration:
len(bridging) >= 1
), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"
def test_no_wire_synthesized_when_no_touching_pins(self):
def test_no_wire_synthesized_when_no_touching_pins(self) -> None:
"""
Two resistors with no pin overlap should not generate any synthesized wires.
"""
@@ -970,7 +973,7 @@ class TestTouchingPinIntegration:
assert result["success"], result.get("message")
assert result.get("wiresSynthesized", 0) == 0
def test_existing_wires_still_dragged_with_touching_pins(self):
def test_existing_wires_still_dragged_with_touching_pins(self) -> None:
"""
When R1 has both an explicit wire AND a touching-pin connection,
both should be handled: the wire dragged and the touching-pin bridged.

View File

@@ -130,42 +130,42 @@ def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
class TestGeometryHelpers:
"""Test low-level geometry utilities."""
def test_point_in_rect_inside(self):
def test_point_in_rect_inside(self) -> None:
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
def test_point_in_rect_outside(self):
def test_point_in_rect_outside(self) -> None:
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
def test_point_in_rect_boundary(self):
def test_point_in_rect_boundary(self) -> None:
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
def test_distance_zero(self):
def test_distance_zero(self) -> None:
assert _distance((0, 0), (0, 0)) == 0
def test_distance_unit(self):
def test_distance_unit(self) -> None:
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
def test_aabb_intersection_crossing(self):
def test_aabb_intersection_crossing(self) -> None:
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
def test_aabb_intersection_miss(self):
def test_aabb_intersection_miss(self) -> None:
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
def test_aabb_intersection_inside(self):
def test_aabb_intersection_inside(self) -> None:
# Line entirely inside the box
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
def test_aabb_intersection_diagonal(self):
def test_aabb_intersection_diagonal(self) -> None:
# Diagonal line crossing through box
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
def test_aabb_intersection_parallel_outside(self):
def test_aabb_intersection_parallel_outside(self) -> None:
# Horizontal line above the box
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
def test_aabb_intersection_touching_edge(self):
def test_aabb_intersection_touching_edge(self) -> None:
# Line ending exactly at box edge
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
@@ -178,7 +178,7 @@ class TestGeometryHelpers:
class TestSexpParsers:
"""Test S-expression parsing functions with synthetic data."""
def test_parse_wires_basic(self):
def test_parse_wires_basic(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 10 20) (xy 30 40))
(stroke (width 0) (type default))
@@ -189,11 +189,11 @@ class TestSexpParsers:
assert wires[0]["start"] == (10.0, 20.0)
assert wires[0]["end"] == (30.0, 40.0)
def test_parse_wires_empty(self):
def test_parse_wires_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)")
assert _parse_wires(sexp) == []
def test_parse_labels_both_types(self):
def test_parse_labels_both_types(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(label "VCC" (at 10 20 0))
(global_label "GND" (at 30 40 0))
@@ -205,7 +205,7 @@ class TestSexpParsers:
assert labels[1]["name"] == "GND"
assert labels[1]["type"] == "global_label"
def test_parse_symbols(self):
def test_parse_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0)))
@@ -228,20 +228,20 @@ class TestSexpParsers:
class TestAABBOverlap:
"""Test AABB overlap helper."""
def test_overlapping_boxes(self):
def test_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True
def test_non_overlapping_boxes(self):
def test_non_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False
def test_touching_boxes_no_overlap(self):
def test_touching_boxes_no_overlap(self) -> None:
# Touching edges are not overlapping (strict inequality)
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
def test_contained_box(self):
def test_contained_box(self) -> None:
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
def test_overlap_one_axis_only(self):
def test_overlap_one_axis_only(self) -> None:
# Overlap in X but not Y
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
@@ -249,12 +249,12 @@ class TestAABBOverlap:
class TestFindOverlappingElements:
"""Test overlapping detection logic."""
def test_no_overlaps_in_empty_schematic(self):
def test_no_overlaps_in_empty_schematic(self) -> None:
tmp = _make_temp_schematic()
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_overlapping_symbols_detected(self):
def test_overlapping_symbols_detected(self) -> None:
# Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
tmp = _make_temp_schematic(extra)
@@ -262,13 +262,13 @@ class TestFindOverlappingElements:
assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self):
def test_well_separated_symbols_not_flagged(self) -> None:
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
def test_collinear_wire_overlap(self):
def test_collinear_wire_overlap(self) -> None:
extra = """
(wire (pts (xy 10 50) (xy 30 50))
(stroke (width 0) (type default))
@@ -281,7 +281,7 @@ class TestFindOverlappingElements:
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_overlapping_bodies_different_centers(self):
def test_overlapping_bodies_different_centers(self) -> None:
"""Two resistors whose bodies overlap even though centers are ~5mm apart.
Device:R pins are at y ±3.81 relative to center, so the body spans
@@ -299,7 +299,7 @@ class TestFindOverlappingElements:
)
assert len(result["overlappingSymbols"]) >= 1
def test_adjacent_resistors_no_overlap(self):
def test_adjacent_resistors_no_overlap(self) -> None:
"""Two vertical resistors side by side should not overlap.
R pins at y ±3.81, but different X positions far enough apart.
@@ -309,7 +309,7 @@ class TestFindOverlappingElements:
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
def test_resistor_and_led_overlapping_bodies(self):
def test_resistor_and_led_overlapping_bodies(self) -> None:
"""A resistor and an LED placed close enough that bodies overlap.
LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
@@ -324,7 +324,7 @@ class TestFindOverlappingElements:
class TestGetElementsInRegion:
"""Test region query logic."""
def test_elements_inside_region_found(self):
def test_elements_inside_region_found(self) -> None:
extra = """
(symbol (lib_id "Device:R") (at 50 50 0)
(property "Reference" "R1" (at 0 0 0))
@@ -340,7 +340,7 @@ class TestGetElementsInRegion:
assert result["counts"]["wires"] >= 1
assert result["counts"]["labels"] >= 1
def test_elements_outside_region_excluded(self):
def test_elements_outside_region_excluded(self) -> None:
extra = """
(symbol (lib_id "Device:R") (at 200 200 0)
(property "Reference" "R1" (at 0 0 0))
@@ -354,7 +354,7 @@ class TestGetElementsInRegion:
class TestComputeSymbolBbox:
"""Test bounding box computation."""
def test_returns_none_for_unknown_symbol(self):
def test_returns_none_for_unknown_symbol(self) -> None:
tmp = _make_temp_schematic()
from commands.pin_locator import PinLocator
@@ -372,7 +372,7 @@ class TestComputeSymbolBbox:
class TestIntegrationFindWiresCrossingSymbols:
"""Integration test for wire crossing symbol detection."""
def test_wire_not_touching_pins_is_collision(self):
def test_wire_not_touching_pins_is_collision(self) -> None:
"""A wire passing through a component bbox without pin contact → collision."""
# LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
# Vertical wire from (100, 95) to (100, 105) crosses through the body
@@ -387,7 +387,7 @@ class TestIntegrationFindWiresCrossingSymbols:
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_collisions) >= 1
def test_unannotated_duplicates_not_over_reported(self):
def test_unannotated_duplicates_not_over_reported(self) -> None:
"""
Regression: two components with the same unannotated reference ("R?") at
different positions should each produce independent bounding boxes.
@@ -417,7 +417,7 @@ class TestIntegrationFindWiresCrossingSymbols:
"likely caused by reference-lookup always returning the first 'R?'"
)
def test_wire_starting_at_pin_passing_through_body(self):
def test_wire_starting_at_pin_passing_through_body(self) -> None:
"""A wire that starts at a pin but continues through the component body
must be flagged — this is the core bug where the old suppression logic
treated any wire touching a pin as a valid connection."""
@@ -435,7 +435,7 @@ class TestIntegrationFindWiresCrossingSymbols:
len(d1_crossings) >= 1
), "Wire starting at pin but passing through body must be detected"
def test_wire_terminating_at_pin_from_outside(self):
def test_wire_terminating_at_pin_from_outside(self) -> None:
"""A wire that arrives at a pin from outside the component body
is a valid connection and must NOT be flagged."""
# LED D1 at (100,100) → pin 1 at (96.19, 100)
@@ -450,7 +450,7 @@ class TestIntegrationFindWiresCrossingSymbols:
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"
def test_wire_shorts_component_pins_detected_as_collision(self):
def test_wire_shorts_component_pins_detected_as_collision(self) -> None:
"""Regression: a wire connecting pin1→pin2 of the same component
must be reported even though both endpoints land on pins."""
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
@@ -472,7 +472,7 @@ class TestIntegrationFindWiresCrossingSymbols:
class TestIntegrationGetElementsInRegion:
"""Integration test for region query."""
def test_region_returns_pin_data(self):
def test_region_returns_pin_data(self) -> None:
"""Symbols in region should include pin position data."""
extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra)
@@ -482,7 +482,7 @@ class TestIntegrationGetElementsInRegion:
assert "pins" in sym
assert len(sym["pins"]) == 2 # Resistor has 2 pins
def test_wire_passing_through_region_included(self):
def test_wire_passing_through_region_included(self) -> None:
"""A wire that passes through a region (no endpoints inside) should be included."""
extra = """
(wire (pts (xy 0 50) (xy 100 50))
@@ -493,7 +493,7 @@ class TestIntegrationGetElementsInRegion:
result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 1
def test_wire_outside_region_excluded(self):
def test_wire_outside_region_excluded(self) -> None:
"""A wire entirely outside a region should not be included."""
extra = """
(wire (pts (xy 0 0) (xy 10 0))
@@ -513,48 +513,48 @@ class TestIntegrationGetElementsInRegion:
class TestCheckWireOverlap:
"""Test wire overlap detection for horizontal, vertical, and diagonal cases."""
def test_horizontal_overlap(self):
def test_horizontal_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (30, 50)}
w2 = {"start": (20, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_vertical_overlap(self):
def test_vertical_overlap(self) -> None:
w1 = {"start": (50, 10), "end": (50, 30)}
w2 = {"start": (50, 20), "end": (50, 40)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_diagonal_overlap(self):
def test_diagonal_overlap(self) -> None:
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (10, 10), "end": (30, 30)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None
assert result["type"] == "collinear_overlap"
def test_horizontal_no_overlap(self):
def test_horizontal_no_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (20, 50)}
w2 = {"start": (30, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_parallel_offset_no_overlap(self):
def test_parallel_offset_no_overlap(self) -> None:
"""Two parallel wires offset perpendicularly should not overlap."""
w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (0, 5), "end": (20, 25)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_non_parallel_no_overlap(self):
def test_non_parallel_no_overlap(self) -> None:
"""Two wires at different angles should not overlap."""
w1 = {"start": (0, 0), "end": (10, 10)}
w2 = {"start": (0, 0), "end": (10, 0)}
result = _check_wire_overlap(w1, w2, 0.5)
assert result is None
def test_zero_length_segment(self):
def test_zero_length_segment(self) -> None:
w1 = {"start": (10, 10), "end": (10, 10)}
w2 = {"start": (10, 10), "end": (20, 20)}
result = _check_wire_overlap(w1, w2, 0.5)
@@ -565,7 +565,7 @@ class TestCheckWireOverlap:
class TestIntegrationDiagonalWireOverlap:
"""Integration tests for diagonal collinear wire overlap detection."""
def test_diagonal_collinear_wire_overlap(self):
def test_diagonal_collinear_wire_overlap(self) -> None:
"""Two 45-degree wires that overlap should be detected."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
@@ -579,7 +579,7 @@ class TestIntegrationDiagonalWireOverlap:
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1
def test_diagonal_parallel_no_overlap(self):
def test_diagonal_parallel_no_overlap(self) -> None:
"""Two parallel 45-degree wires that are offset should not overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
@@ -593,7 +593,7 @@ class TestIntegrationDiagonalWireOverlap:
result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0
def test_diagonal_non_collinear_no_overlap(self):
def test_diagonal_non_collinear_no_overlap(self) -> None:
"""Two wires at different angles crossing should not be flagged as collinear overlap."""
extra = """
(wire (pts (xy 0 0) (xy 20 20))
@@ -616,7 +616,7 @@ class TestIntegrationDiagonalWireOverlap:
class TestExtractLibSymbols:
"""Test _extract_lib_symbols helper."""
def test_extracts_pins_from_lib_symbols(self):
def test_extracts_pins_from_lib_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
(symbol "Device:R"
@@ -636,19 +636,19 @@ class TestExtractLibSymbols:
assert "2" in pins
assert pins["1"]["y"] == pytest.approx(3.81)
def test_empty_schematic_returns_empty(self):
def test_empty_schematic_returns_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_no_lib_symbols_section(self):
def test_no_lib_symbols_section(self) -> None:
sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 0 0) (xy 10 10)))
)""")
result = _extract_lib_symbols(sexp)
assert result == {}
def test_extract_includes_graphics_points(self):
def test_extract_includes_graphics_points(self) -> None:
"""_extract_lib_symbols should return graphics_points from body shapes."""
sexp = sexpdata.loads("""(kicad_sch
(lib_symbols
@@ -688,7 +688,7 @@ class TestExtractLibSymbols:
class TestParseLibSymbolGraphics:
"""Test graphics extraction from lib_symbol definitions."""
def test_rectangle(self):
def test_rectangle(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:R"
(symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
@@ -699,7 +699,7 @@ class TestParseLibSymbolGraphics:
assert (-1.016, -2.54) in pts
assert (1.016, 2.54) in pts
def test_polyline(self):
def test_polyline(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:C"
(symbol "Device:C_0_1"
(polyline
@@ -710,7 +710,7 @@ class TestParseLibSymbolGraphics:
assert (-2.032, -0.762) in pts
assert (2.032, -0.762) in pts
def test_circle(self):
def test_circle(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Circle"
(symbol "Test:Circle_0_1"
(circle (center 0 0) (radius 5)
@@ -721,7 +721,7 @@ class TestParseLibSymbolGraphics:
assert (-5.0, -5.0) in pts
assert (5.0, 5.0) in pts
def test_arc(self):
def test_arc(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Arc"
(symbol "Test:Arc_0_1"
(arc (start 1 0) (mid 0 1) (end -1 0)
@@ -732,7 +732,7 @@ class TestParseLibSymbolGraphics:
assert (0.0, 1.0) in pts
assert (-1.0, 0.0) in pts
def test_no_graphics(self):
def test_no_graphics(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Empty"
(symbol "Test:Empty_1_1"
(pin passive line (at 0 0 0) (length 1.27)
@@ -750,24 +750,24 @@ class TestParseLibSymbolGraphics:
class TestTransformLocalPoint:
"""Test local→absolute coordinate transform."""
def test_no_transform(self):
def test_no_transform(self) -> None:
# ly is negated (lib y-up → schematic y-down)
x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
assert x == pytest.approx(101.0)
assert y == pytest.approx(198.0)
def test_mirror_x(self):
def test_mirror_x(self) -> None:
# y-negate then mirror_x cancel out → net ly unchanged
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
assert x == pytest.approx(1.0)
assert y == pytest.approx(2.0)
def test_mirror_y(self):
def test_mirror_y(self) -> None:
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
assert x == pytest.approx(-1.0)
assert y == pytest.approx(-2.0)
def test_rotation_90(self):
def test_rotation_90(self) -> None:
# ly=0 negated is still 0, then rotate lx=1 by 90°
x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
assert x == pytest.approx(0.0, abs=1e-9)
@@ -782,7 +782,7 @@ class TestTransformLocalPoint:
class TestComputeSymbolBboxWithGraphics:
"""Test that bounding box computation uses graphics points when available."""
def test_resistor_bbox_from_graphics(self):
def test_resistor_bbox_from_graphics(self) -> None:
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords.
Pins at (0, ±3.81). Placed at (100, 100) with no rotation.
Bbox should span from pin-to-pin in Y and use rectangle width in X."""
@@ -823,7 +823,7 @@ class TestComputeSymbolBboxWithGraphics:
assert min_y == pytest.approx(100 - 3.81)
assert max_y == pytest.approx(100 + 3.81)
def test_fallback_without_graphics(self):
def test_fallback_without_graphics(self) -> None:
"""Without graphics_points, should use the old degenerate expansion."""
sym = {
"x": 100.0,
@@ -858,7 +858,7 @@ class TestComputeSymbolBboxWithGraphics:
assert min_x == pytest.approx(100 - 1.5)
assert max_x == pytest.approx(100 + 1.5)
def test_rotated_symbol_graphics(self):
def test_rotated_symbol_graphics(self) -> None:
"""Graphics points should be rotated along with the symbol."""
sym = {
"x": 100.0,
@@ -902,7 +902,7 @@ class TestComputeSymbolBboxWithGraphics:
class TestIntegrationGraphicsBbox:
"""Integration tests verifying graphics-based bbox from real template data."""
def test_resistor_bbox_uses_rectangle(self):
def test_resistor_bbox_uses_rectangle(self) -> None:
"""The template's Device:R has a rectangle body.
Verify that the bbox for a placed resistor uses the actual
rectangle width rather than the degenerate 1.5mm expansion."""
@@ -925,7 +925,7 @@ class TestIntegrationGraphicsBbox:
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)
def test_led_bbox_uses_polyline(self):
def test_led_bbox_uses_polyline(self) -> None:
"""The template's Device:LED uses polylines for its body.
Verify that the bbox uses polyline extents."""
extra = _make_led_sexp("D1", 100, 100)

View File

@@ -7,6 +7,7 @@ import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
@@ -86,7 +87,7 @@ class TestGetSchematicComponentParsing:
}
return fields
def _parse_comp_pos(self, block_text: str):
def _parse_comp_pos(self, block_text: str) -> Any:
"""Mirrors the regex used to extract symbol position."""
m = re.search(
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
@@ -100,7 +101,7 @@ class TestGetSchematicComponentParsing:
}
return None
def test_parses_reference_field(self):
def test_parses_reference_field(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Reference" in fields
assert fields["Reference"]["value"] == "R1"
@@ -108,25 +109,25 @@ class TestGetSchematicComponentParsing:
assert fields["Reference"]["y"] == pytest.approx(47.46)
assert fields["Reference"]["angle"] == pytest.approx(0.0)
def test_parses_value_field(self):
def test_parses_value_field(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Value" in fields
assert fields["Value"]["value"] == "10k"
assert fields["Value"]["x"] == pytest.approx(51.27)
assert fields["Value"]["y"] == pytest.approx(52.54)
def test_parses_all_four_standard_fields(self):
def test_parses_all_four_standard_fields(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
def test_parses_component_position(self):
def test_parses_component_position(self) -> None:
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
assert pos is not None
assert pos["x"] == pytest.approx(50.0)
assert pos["y"] == pytest.approx(50.0)
assert pos["angle"] == pytest.approx(0.0)
def test_field_position_regex_replaces_correctly(self):
def test_field_position_regex_replaces_correctly(self) -> None:
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
field_name = "Reference"
new_x, new_y, new_angle = 99.0, 88.0, 0
@@ -144,7 +145,7 @@ class TestGetSchematicComponentParsing:
# Value should be unchanged
assert fields["Value"]["x"] == pytest.approx(51.27)
def test_field_position_regex_preserves_value(self):
def test_field_position_regex_preserves_value(self) -> None:
"""Replacing position must not change the field value string."""
block = PLACED_RESISTOR_BLOCK
block = re.sub(
@@ -166,16 +167,16 @@ class TestGetSchematicComponentIntegration:
"""Integration tests: write a real .kicad_sch and call the handler."""
@pytest.fixture
def sch_with_r1(self, tmp_path):
def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
def _get_interface(self) -> Any:
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_get_returns_success(self, sch_with_r1):
def test_get_returns_success(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -186,7 +187,7 @@ class TestGetSchematicComponentIntegration:
)
assert result["success"] is True
def test_get_returns_correct_reference(self, sch_with_r1):
def test_get_returns_correct_reference(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -197,7 +198,7 @@ class TestGetSchematicComponentIntegration:
)
assert result["reference"] == "R1"
def test_get_returns_component_position(self, sch_with_r1):
def test_get_returns_component_position(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -210,7 +211,7 @@ class TestGetSchematicComponentIntegration:
assert result["position"]["x"] == pytest.approx(50.0)
assert result["position"]["y"] == pytest.approx(50.0)
def test_get_returns_reference_field_position(self, sch_with_r1):
def test_get_returns_reference_field_position(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -224,7 +225,7 @@ class TestGetSchematicComponentIntegration:
assert ref_field["x"] == pytest.approx(51.27)
assert ref_field["y"] == pytest.approx(47.46)
def test_get_returns_value_field(self, sch_with_r1):
def test_get_returns_value_field(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -238,7 +239,7 @@ class TestGetSchematicComponentIntegration:
assert val_field["x"] == pytest.approx(51.27)
assert val_field["y"] == pytest.approx(52.54)
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -250,7 +251,7 @@ class TestGetSchematicComponentIntegration:
assert result["success"] is False
assert "R99" in result["message"]
def test_get_missing_path_returns_failure(self):
def test_get_missing_path_returns_failure(self) -> None:
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
@@ -266,15 +267,15 @@ class TestEditSchematicComponentFieldPositions:
"""Integration tests for the new fieldPositions parameter."""
@pytest.fixture
def sch_with_r1(self, tmp_path):
def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
def _get_interface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_reposition_reference_label(self, sch_with_r1):
def test_reposition_reference_label(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
@@ -297,7 +298,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
def test_reposition_does_not_change_value(self, sch_with_r1):
def test_reposition_does_not_change_value(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
iface.handle_command(
"edit_schematic_component",
@@ -318,7 +319,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
def test_reposition_multiple_fields(self, sch_with_r1):
def test_reposition_multiple_fields(self, sch_with_r1: Any) -> None:
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
@@ -343,7 +344,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
def test_fieldpositions_alone_is_valid(self, sch_with_r1: Any) -> None:
"""fieldPositions without value/footprint/newReference should succeed."""
iface = self._get_interface()
result = iface.handle_command(
@@ -356,7 +357,7 @@ class TestEditSchematicComponentFieldPositions:
)
assert result["success"] is True
def test_no_params_still_fails(self, sch_with_r1):
def test_no_params_still_fails(self, sch_with_r1: Any) -> None:
"""Providing no update params should return an error."""
iface = self._get_interface()
result = iface.handle_command(

View File

@@ -12,6 +12,7 @@ Covers:
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -59,22 +60,22 @@ def _write_temp_sch(content: str) -> Path:
class TestDeleteWireUnit:
"""Unit-level tests for WireManager.delete_wire."""
def setup_method(self):
def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
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):
def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
assert result is False
def test_tolerance_argument_accepted(self, tmp_path):
def test_tolerance_argument_accepted(self, tmp_path: Any) -> None:
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
@@ -91,22 +92,22 @@ class TestDeleteWireUnit:
class TestDeleteLabelUnit:
"""Unit-level tests for WireManager.delete_label."""
def setup_method(self):
def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
assert result is False
def test_missing_label_returns_false(self, tmp_path):
def test_missing_label_returns_false(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(sch, "NONEXISTENT")
assert result is False
def test_position_kwarg_accepted(self, tmp_path):
def test_position_kwarg_accepted(self, tmp_path: Any) -> None:
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)
@@ -122,12 +123,12 @@ class TestDeleteLabelUnit:
class TestDeleteWireIntegration:
"""Integration tests that read/write real .kicad_sch files."""
def setup_method(self):
def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_exact_match_deletes_wire(self, tmp_path):
def test_exact_match_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -142,7 +143,7 @@ class TestDeleteWireIntegration:
]
assert wire_items == [], "Wire should have been removed from the file"
def test_reverse_direction_match_deletes_wire(self, tmp_path):
def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -151,7 +152,7 @@ class TestDeleteWireIntegration:
assert result is True
def test_within_tolerance_deletes_wire(self, tmp_path):
def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -159,7 +160,7 @@ class TestDeleteWireIntegration:
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):
def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -171,14 +172,14 @@ class TestDeleteWireIntegration:
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
# Must parse without exception
sexpdata.loads(sch.read_text(encoding="utf-8"))
def test_label_preserved_after_wire_deletion(self, tmp_path):
def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None:
"""Deleting a wire must not remove unrelated elements."""
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -199,12 +200,12 @@ class TestDeleteWireIntegration:
@pytest.mark.integration
class TestDeleteLabelIntegration:
def setup_method(self):
def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_deletes_label_by_name(self, tmp_path):
def test_deletes_label_by_name(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -219,21 +220,21 @@ class TestDeleteLabelIntegration:
]
assert labels == [], "Label should have been removed"
def test_deletes_label_with_matching_position(self, tmp_path):
def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
assert result is True
def test_position_mismatch_no_delete(self, tmp_path):
def test_position_mismatch_no_delete(self, tmp_path: Any) -> None:
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)
assert result is False
def test_wire_preserved_after_label_deletion(self, tmp_path):
def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC")
@@ -245,7 +246,7 @@ class TestDeleteLabelIntegration:
]
assert len(wires) == 1
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC")
@@ -260,7 +261,7 @@ class TestDeleteLabelIntegration:
# Each handler is extracted as a standalone function for testing.
def _make_handler_under_test(handler_name: str):
def _make_handler_under_test(handler_name: str) -> None:
"""
Return the unbound handler method from kicad_interface by importing only
that method's source via exec, bypassing module-level side effects.
@@ -300,7 +301,7 @@ class TestHandlerParamValidation:
that satisfy the dependency chain up to the first parameter-check branch.
"""
def _make_iface_stub(self):
def _make_iface_stub(self) -> Any:
"""Return a stub that exposes only the handler methods under test."""
import importlib
import types
@@ -316,7 +317,7 @@ class TestHandlerParamValidation:
# --- delete_schematic_wire ---
def test_delete_wire_missing_schematic_path(self):
def test_delete_wire_missing_schematic_path(self) -> None:
from commands.wire_manager import WireManager
with patch.object(WireManager, "delete_wire", return_value=False):
@@ -325,7 +326,7 @@ class TestHandlerParamValidation:
schematic_path = params.get("schematicPath")
assert schematic_path is None
# Handler should short-circuit before calling WireManager
result = (
result: dict[str, Any] = (
{"success": False, "message": "schematicPath is required"}
if not schematic_path
else {}
@@ -335,7 +336,7 @@ class TestHandlerParamValidation:
# --- delete_schematic_net_label ---
def test_delete_label_missing_net_name(self):
def test_delete_label_missing_net_name(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"}
net_name = params.get("netName")
result = (
@@ -348,7 +349,7 @@ class TestHandlerParamValidation:
)
assert result["success"] is False
def test_delete_label_missing_schematic_path(self):
def test_delete_label_missing_schematic_path(self) -> None:
params = {"netName": "VCC"}
schematic_path = params.get("schematicPath")
result = (
@@ -363,7 +364,7 @@ class TestHandlerParamValidation:
# --- list_schematic_components ---
def test_list_components_missing_path(self):
def test_list_components_missing_path(self) -> None:
params = {}
schematic_path = params.get("schematicPath")
result = (
@@ -373,7 +374,7 @@ class TestHandlerParamValidation:
# --- list_schematic_nets ---
def test_list_nets_missing_path(self):
def test_list_nets_missing_path(self) -> None:
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
@@ -384,7 +385,7 @@ class TestHandlerParamValidation:
# --- list_schematic_wires ---
def test_list_wires_missing_path(self):
def test_list_wires_missing_path(self) -> None:
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
@@ -395,7 +396,7 @@ class TestHandlerParamValidation:
# --- list_schematic_labels ---
def test_list_labels_missing_path(self):
def test_list_labels_missing_path(self) -> None:
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
@@ -406,7 +407,7 @@ class TestHandlerParamValidation:
# --- move_schematic_component ---
def test_move_component_missing_reference(self):
def test_move_component_missing_reference(self) -> None:
params = {
"schematicPath": "/some/file.kicad_sch",
"position": {"x": 10, "y": 20},
@@ -421,7 +422,7 @@ class TestHandlerParamValidation:
)
assert result["success"] is False
def test_move_component_missing_position(self):
def test_move_component_missing_position(self) -> None:
params = {
"schematicPath": "/some/file.kicad_sch",
"reference": "R1",
@@ -438,7 +439,7 @@ class TestHandlerParamValidation:
# --- rotate_schematic_component ---
def test_rotate_component_missing_reference(self):
def test_rotate_component_missing_reference(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"}
result = (
{
@@ -452,7 +453,7 @@ class TestHandlerParamValidation:
# --- annotate_schematic ---
def test_annotate_missing_path(self):
def test_annotate_missing_path(self) -> None:
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
@@ -463,7 +464,7 @@ class TestHandlerParamValidation:
# --- export_schematic_svg ---
def test_export_svg_missing_output_path(self):
def test_export_svg_missing_output_path(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"}
result = (
{

View File

@@ -48,7 +48,7 @@ def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
return wire
def _make_schematic(*wires) -> MagicMock:
def _make_schematic(*wires: Any) -> MagicMock:
sch = MagicMock()
sch.wire = list(wires)
# No net labels, no symbols by default
@@ -66,12 +66,12 @@ def _make_schematic(*wires) -> MagicMock:
class TestSchema:
"""Verify the get_wire_connections tool schema is present and well-formed."""
def test_schema_registered(self):
def test_schema_registered(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
assert "get_wire_connections" in TOOL_SCHEMAS
def test_schema_required_fields(self):
def test_schema_required_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"]
@@ -80,7 +80,7 @@ class TestSchema:
assert "x" in required
assert "y" in required
def test_schema_has_title_and_description(self):
def test_schema_has_title_and_description(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"]
@@ -97,7 +97,7 @@ class TestSchema:
class TestHandlerDispatch:
"""Verify the handler is wired into KiCadInterface.command_routes."""
def test_get_wire_connections_in_routes(self):
def test_get_wire_connections_in_routes(self) -> None:
# Import lazily to avoid heavy side-effects at collection time
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
@@ -116,7 +116,7 @@ class TestHandlerDispatch:
# Build routes only (avoid full __init__ side-effects)
# The routes dict is built in __init__; we call it directly.
iface.__init__()
KiCADInterface.__init__(iface)
assert "get_wire_connections" in iface.command_routes
assert callable(iface.command_routes["get_wire_connections"])
@@ -131,7 +131,7 @@ class TestHandlerDispatch:
class TestHandlerParamValidation:
"""Handler returns error responses for bad or missing parameters."""
def _make_handler(self):
def _make_handler(self) -> Any:
"""Return a bound _handle_get_wire_connections without full init."""
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
@@ -139,29 +139,29 @@ class TestHandlerParamValidation:
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_get_wire_connections
def test_missing_schematic_path(self):
def test_missing_schematic_path(self) -> None:
handler = self._make_handler()
result = handler({"x": 1.0, "y": 2.0})
assert result["success"] is False
assert "schematicPath" in result["message"] or "Missing" in result["message"]
def test_missing_x(self):
def test_missing_x(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
assert result["success"] is False
def test_missing_y(self):
def test_missing_y(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
assert result["success"] is False
def test_non_numeric_x(self):
def test_non_numeric_x(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
assert result["success"] is False
assert "numeric" in result["message"].lower() or "x" in result["message"]
def test_non_numeric_y(self):
def test_non_numeric_y(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
assert result["success"] is False
@@ -180,39 +180,39 @@ class TestCoreLogic:
# --- _to_iu ---
def test_to_iu_integer_mm(self):
def test_to_iu_integer_mm(self) -> None:
assert _to_iu(1.0, 2.0) == (10_000, 20_000)
def test_to_iu_fractional_mm(self):
def test_to_iu_fractional_mm(self) -> None:
assert _to_iu(0.5, 0.25) == (5_000, 2_500)
def test_to_iu_zero(self):
def test_to_iu_zero(self) -> None:
assert _to_iu(0.0, 0.0) == (0, 0)
def test_to_iu_negative(self):
def test_to_iu_negative(self) -> None:
assert _to_iu(-1.0, -2.0) == (-10_000, -20_000)
# --- _parse_wires ---
def test_parse_wires_single_wire(self):
def test_parse_wires_single_wire(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = _parse_wires(sch)
assert len(result) == 1
assert result[0] == [(0, 0), (10_000, 0)]
def test_parse_wires_empty_schematic(self):
def test_parse_wires_empty_schematic(self) -> None:
sch = MagicMock()
sch.wire = []
assert _parse_wires(sch) == []
def test_parse_wires_multiple_wires(self):
def test_parse_wires_multiple_wires(self) -> None:
sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0),
)
assert len(_parse_wires(sch)) == 2
def test_parse_wires_skips_wire_without_pts(self):
def test_parse_wires_skips_wire_without_pts(self) -> None:
bad_wire = MagicMock(spec=[]) # no `pts` attribute
sch = MagicMock()
sch.wire = [bad_wire]
@@ -220,7 +220,7 @@ class TestCoreLogic:
# --- _build_adjacency ---
def test_build_adjacency_two_connected_wires(self):
def test_build_adjacency_two_connected_wires(self) -> None:
# wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0)
wires = [
[(0, 0), (10_000, 0)],
@@ -230,7 +230,7 @@ class TestCoreLogic:
assert 1 in adjacency[0]
assert 0 in adjacency[1]
def test_build_adjacency_two_disconnected_wires(self):
def test_build_adjacency_two_disconnected_wires(self) -> None:
wires = [
[(0, 0), (10_000, 0)],
[(20_000, 0), (30_000, 0)],
@@ -239,7 +239,7 @@ class TestCoreLogic:
assert adjacency[0] == set()
assert adjacency[1] == set()
def test_build_adjacency_iu_to_wires_maps_correctly(self):
def test_build_adjacency_iu_to_wires_maps_correctly(self) -> None:
wires = [
[(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)],
@@ -248,7 +248,7 @@ class TestCoreLogic:
assert iu_to_wires[(10_000, 0)] == {0, 1}
assert iu_to_wires[(0, 0)] == {0}
def test_build_adjacency_three_wires_at_junction(self):
def test_build_adjacency_three_wires_at_junction(self) -> None:
# All three wires meet at (10,000, 0)
wires = [
[(0, 0), (10_000, 0)],
@@ -262,14 +262,14 @@ class TestCoreLogic:
# --- _find_connected_wires ---
def test_find_connected_wires_no_wire_at_point(self):
def test_find_connected_wires_no_wire_at_point(self) -> None:
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)
assert visited is None
assert net_points is None
def test_find_connected_wires_single_wire(self):
def test_find_connected_wires_single_wire(self) -> None:
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)
@@ -277,7 +277,7 @@ class TestCoreLogic:
assert (0, 0) in net_points
assert (10_000, 0) in net_points
def test_find_connected_wires_flood_fills_chain(self):
def test_find_connected_wires_flood_fills_chain(self) -> None:
# Three wires in a chain: A-B-C-D
wires = [
[(0, 0), (10_000, 0)],
@@ -288,7 +288,7 @@ class TestCoreLogic:
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):
def test_find_connected_wires_does_not_cross_gap(self) -> None:
# Two disconnected segments; query on segment 0 should not reach segment 1
wires = [
[(0, 0), (10_000, 0)],
@@ -300,18 +300,18 @@ class TestCoreLogic:
# --- get_wire_connections (integration of internal functions) ---
def test_get_wire_connections_no_wires(self):
def test_get_wire_connections_no_wires(self) -> None:
sch = MagicMock()
sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result == {"pins": [], "wires": []}
def test_get_wire_connections_no_wire_at_point_returns_none(self):
def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
assert result is None
def test_get_wire_connections_returns_wire_data(self):
def test_get_wire_connections_returns_wire_data(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
# Prevent _find_pins_on_net from iterating symbols
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
@@ -322,7 +322,7 @@ class TestCoreLogic:
assert wire["start"] == {"x": 0.0, "y": 0.0}
assert wire["end"] == {"x": 1.0, "y": 0.0}
def test_get_wire_connections_chain_returns_all_wires(self):
def test_get_wire_connections_chain_returns_all_wires(self) -> None:
sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0),

View File

@@ -10,6 +10,7 @@ import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
@@ -29,20 +30,20 @@ EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# ---------------------------------------------------------------------------
def _make_temp_sch():
def _make_temp_sch() -> Any:
"""Copy the empty schematic template to a temp file and return the Path."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(EMPTY_SCH, tmp)
return tmp
def _parse_sch(path: Path):
def _parse_sch(path: Path) -> Any:
"""Parse a .kicad_sch file and return the S-expression list."""
with open(path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read())
def _find_elements(sch_data, tag: str):
def _find_elements(sch_data: Any, tag: str) -> Any:
"""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)]
@@ -56,22 +57,22 @@ class TestSchemas:
"""Verify tool_schemas.py reflects the new API."""
@pytest.fixture(autouse=True)
def load_schemas(self):
def load_schemas(self) -> Any:
from schemas.tool_schemas import SCHEMATIC_TOOLS
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
def test_add_schematic_wire_has_waypoints(self):
def test_add_schematic_wire_has_waypoints(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "waypoints" in schema["properties"], "waypoints must be a property"
assert "waypoints" in schema["required"]
def test_add_schematic_wire_has_schematic_path(self):
def test_add_schematic_wire_has_schematic_path(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "schematicPath" in schema["properties"]
assert "schematicPath" in schema["required"]
def test_add_schematic_wire_has_snap_params(self):
def test_add_schematic_wire_has_snap_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"]
assert "snapToPins" in props
@@ -79,28 +80,28 @@ class TestSchemas:
assert "snapTolerance" in props
assert props["snapTolerance"]["type"] == "number"
def test_add_schematic_wire_no_old_point_params(self):
def test_add_schematic_wire_no_old_point_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"]
assert "startPoint" not in props, "startPoint should be removed"
assert "endPoint" not in props, "endPoint should be removed"
def test_add_schematic_connection_removed(self):
def test_add_schematic_connection_removed(self) -> None:
assert (
"add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
def test_add_schematic_junction_present(self):
def test_add_schematic_junction_present(self) -> None:
assert "add_schematic_junction" in self.tools
def test_add_schematic_junction_schema(self):
def test_add_schematic_junction_schema(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"]
props = schema["properties"]
assert "schematicPath" in props
assert "position" in props
assert set(schema["required"]) >= {"schematicPath", "position"}
def test_add_schematic_junction_position_is_array(self):
def test_add_schematic_junction_position_is_array(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"]
pos = schema["properties"]["position"]
assert pos["type"] == "array"
@@ -117,7 +118,7 @@ class TestHandlerDispatch:
"""Verify KiCADInterface registers the right tool handlers."""
@pytest.fixture(autouse=True)
def load_handler_map(self):
def load_handler_map(self) -> Any:
# Import only the dispatch table without initialising KiCAD connections
import importlib
import types
@@ -141,18 +142,18 @@ class TestHandlerDispatch:
}
self.handlers = obj._tool_handlers
def test_add_schematic_wire_registered(self):
def test_add_schematic_wire_registered(self) -> None:
from kicad_interface import KiCADInterface
# Just verify the class has the handler method
assert hasattr(KiCADInterface, "_handle_add_schematic_wire")
def test_add_schematic_junction_registered(self):
def test_add_schematic_junction_registered(self) -> None:
from kicad_interface import KiCADInterface
assert hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self):
def test_add_schematic_connection_not_present(self) -> None:
from kicad_interface import KiCADInterface
assert not hasattr(
@@ -169,7 +170,7 @@ class TestHandleAddSchematicWireValidation:
"""Unit tests for _handle_add_schematic_wire validation paths (no disk I/O)."""
@pytest.fixture(autouse=True)
def handler(self):
def handler(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
@@ -179,17 +180,17 @@ class TestHandleAddSchematicWireValidation:
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self):
def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_waypoints(self):
def test_missing_waypoints(self) -> None:
result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "waypoint" in result["message"].lower()
def test_single_waypoint_rejected(self):
def test_single_waypoint_rejected(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": "/tmp/x.kicad_sch",
@@ -209,7 +210,7 @@ class TestHandleAddSchematicWireRouting:
"""Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping."""
@pytest.fixture(autouse=True)
def setup(self):
def setup(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
@@ -224,7 +225,7 @@ class TestHandleAddSchematicWireRouting:
shutil.rmtree(self.sch_path.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_two_waypoints_calls_add_wire(self, mock_add_wire):
def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
@@ -239,7 +240,7 @@ class TestHandleAddSchematicWireRouting:
assert args[2] == [30.0, 20.0]
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly):
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
@@ -250,7 +251,7 @@ class TestHandleAddSchematicWireRouting:
assert result["success"] is True
mock_poly.assert_called_once()
def test_points_key_without_waypoints_is_rejected(self):
def test_points_key_without_waypoints_is_rejected(self) -> None:
"""'points' key alone (without 'waypoints') is rejected — no fallback."""
result = self.iface._handle_add_schematic_wire(
{
@@ -263,7 +264,7 @@ class TestHandleAddSchematicWireRouting:
assert "waypoint" in result["message"].lower()
@patch("commands.wire_manager.WireManager.add_wire", return_value=False)
def test_failure_response(self, _):
def test_failure_response(self, _: Any) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch_path),
@@ -283,18 +284,18 @@ class TestPinSnapping:
"""Verify pin snapping logic snaps endpoints correctly."""
@pytest.fixture(autouse=True)
def setup(self):
def setup(self) -> Any:
import types
# Provide a minimal skip.Schematic stub so the handler can import it
skip_mod = types.ModuleType("skip")
class FakeSchematic:
def __init__(self, path):
def __init__(self, path: Any) -> None:
pass
@property
def symbol(self):
def symbol(self) -> list[Any]:
return [] # no symbols → no pins in snapping loop
skip_mod.Schematic = FakeSchematic
@@ -312,7 +313,7 @@ class TestPinSnapping:
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
@patch("commands.pin_locator.PinLocator.get_all_symbol_pins")
def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire):
def test_start_point_snapped_within_tolerance(self, mock_pins: Any, mock_wire: Any) -> None:
"""First waypoint within tolerance of a pin should be snapped to pin coords."""
# get_all_symbol_pins won't be called because symbol list is empty in fixture.
# Instead we patch find_nearest_pin indirectly by providing all_pins via the
@@ -326,7 +327,7 @@ class TestPinSnapping:
class Reference:
value = "R1"
def __init__(self):
def __init__(self) -> None:
pass
skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
@@ -361,7 +362,7 @@ class TestPinSnapping:
], f"Start should snap to [10.0, 20.0], got {called_start}"
# If it failed due to stub issues, just verify no exception
def test_snap_disabled_passes_original_coords(self):
def test_snap_disabled_passes_original_coords(self) -> None:
"""With snapToPins=False the handler should not load PinLocator at all."""
with (
patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
@@ -380,7 +381,7 @@ class TestPinSnapping:
assert called_start == [1.0, 2.0]
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_snap_miss_leaves_coords_unchanged(self, mock_wire):
def test_snap_miss_leaves_coords_unchanged(self, mock_wire: Any) -> None:
"""Point beyond tolerance should not be snapped."""
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
result = self.iface._handle_add_schematic_wire(
@@ -397,7 +398,7 @@ class TestPinSnapping:
assert "snapped" not in result.get("message", "")
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_intermediate_waypoints_not_snapped(self, mock_poly):
def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None:
"""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:
@@ -424,7 +425,7 @@ class TestPinSnapping:
class TestHandleAddSchematicJunction:
@pytest.fixture(autouse=True)
def setup(self):
def setup(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
@@ -434,18 +435,18 @@ class TestHandleAddSchematicJunction:
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self):
def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]})
assert result["success"] is False
assert "Schematic path" in result["message"]
def test_missing_position(self):
def test_missing_position(self) -> None:
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False
assert "Position" in result["message"]
@patch("commands.wire_manager.WireManager.add_junction", return_value=True)
def test_success(self, mock_jct):
def test_success(self, mock_jct: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
@@ -461,7 +462,7 @@ class TestHandleAddSchematicJunction:
shutil.rmtree(sch.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_junction", return_value=False)
def test_failure(self, _):
def test_failure(self, _: Any) -> None:
sch = _make_temp_sch()
try:
result = self.iface._handle_add_schematic_junction(
@@ -483,21 +484,21 @@ class TestHandleAddSchematicJunction:
class TestConnectionManagerOrphanedMethodsRemoved:
def test_add_wire_removed(self):
def test_add_wire_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
ConnectionManager, "add_wire"
), "ConnectionManager.add_wire should have been removed"
def test_add_connection_removed(self):
def test_add_connection_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
ConnectionManager, "add_connection"
), "ConnectionManager.add_connection should have been removed"
def test_get_pin_location_removed(self):
def test_get_pin_location_removed(self) -> None:
from commands.connection_schematic import ConnectionManager
assert not hasattr(
@@ -515,12 +516,12 @@ class TestIntegrationWireManager:
"""Integration tests using real schematic files and WireManager."""
@pytest.fixture(autouse=True)
def sch(self):
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_writes_wire_element(self, sch):
def test_add_wire_writes_wire_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager
ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0])
@@ -529,7 +530,7 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_add_polyline_wire_creates_segments(self, sch):
def test_add_polyline_wire_creates_segments(self, sch: Any) -> None:
"""N waypoints should produce N-1 individual 2-point wire segments."""
from commands.wire_manager import WireManager
@@ -540,7 +541,7 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_add_junction_writes_junction_element(self, sch):
def test_add_junction_writes_junction_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager
ok = WireManager.add_junction(sch, [25.4, 25.4])
@@ -553,7 +554,7 @@ class TestIntegrationWireManager:
assert at[1] == 25.4
assert at[2] == 25.4
def test_wire_endpoint_coordinates_match(self, sch):
def test_wire_endpoint_coordinates_match(self, sch: Any) -> None:
from commands.wire_manager import WireManager
WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5])
@@ -576,7 +577,7 @@ class TestIntegrationHandlerEndToEnd:
"""Integration tests for KiCADInterface handlers writing to real schematic files."""
@pytest.fixture(autouse=True)
def setup(self):
def setup(self) -> Any:
import types
for mod in ["pcbnew", "skip"]:
@@ -589,7 +590,7 @@ class TestIntegrationHandlerEndToEnd:
yield
shutil.rmtree(self.sch.parent, ignore_errors=True)
def test_junction_handler_writes_junction(self):
def test_junction_handler_writes_junction(self) -> None:
result = self.iface._handle_add_schematic_junction(
{
"schematicPath": str(self.sch),
@@ -601,7 +602,7 @@ class TestIntegrationHandlerEndToEnd:
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_wire_handler_two_points_writes_wire(self):
def test_wire_handler_two_points_writes_wire(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch),
@@ -614,7 +615,7 @@ class TestIntegrationHandlerEndToEnd:
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_wire_handler_four_points_creates_three_segments(self):
def test_wire_handler_four_points_creates_three_segments(self) -> None:
result = self.iface._handle_add_schematic_wire(
{
"schematicPath": str(self.sch),
@@ -638,64 +639,64 @@ class TestPointStrictlyOnWire:
"""Unit tests for WireManager._point_strictly_on_wire geometry helper."""
@staticmethod
def _fn(px, py, x1, y1, x2, y2, eps=1e-6):
def _fn(px: Any, py: Any, x1: Any, y1: Any, x2: Any, y2: Any, eps: Any = 1e-6) -> Any:
from commands.wire_manager import WireManager
return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps)
def test_horizontal_midpoint(self):
def test_horizontal_midpoint(self) -> None:
assert self._fn(5, 0, 0, 0, 10, 0) is True
def test_vertical_midpoint(self):
def test_vertical_midpoint(self) -> None:
assert self._fn(0, 5, 0, 0, 0, 10) is True
def test_horizontal_at_start_endpoint(self):
def test_horizontal_at_start_endpoint(self) -> None:
"""Point at wire start should NOT be strictly on wire."""
assert self._fn(0, 0, 0, 0, 10, 0) is False
def test_horizontal_at_end_endpoint(self):
def test_horizontal_at_end_endpoint(self) -> None:
"""Point at wire end should NOT be strictly on wire."""
assert self._fn(10, 0, 0, 0, 10, 0) is False
def test_vertical_at_start_endpoint(self):
def test_vertical_at_start_endpoint(self) -> None:
assert self._fn(0, 0, 0, 0, 0, 10) is False
def test_vertical_at_end_endpoint(self):
def test_vertical_at_end_endpoint(self) -> None:
assert self._fn(0, 10, 0, 0, 0, 10) is False
def test_point_off_horizontal_wire(self):
def test_point_off_horizontal_wire(self) -> None:
"""Point above a horizontal wire."""
assert self._fn(5, 1, 0, 0, 10, 0) is False
def test_point_off_vertical_wire(self):
def test_point_off_vertical_wire(self) -> None:
"""Point to the right of a vertical wire."""
assert self._fn(1, 5, 0, 0, 0, 10) is False
def test_point_beyond_horizontal_wire(self):
def test_point_beyond_horizontal_wire(self) -> None:
"""Point collinear but past the end of a horizontal wire."""
assert self._fn(15, 0, 0, 0, 10, 0) is False
def test_point_beyond_vertical_wire(self):
def test_point_beyond_vertical_wire(self) -> None:
"""Point collinear but past the end of a vertical wire."""
assert self._fn(0, 15, 0, 0, 0, 10) is False
def test_diagonal_wire_always_false(self):
def test_diagonal_wire_always_false(self) -> None:
"""Only horizontal/vertical wires are handled; diagonal → False."""
assert self._fn(5, 5, 0, 0, 10, 10) is False
def test_reversed_horizontal_endpoints(self):
def test_reversed_horizontal_endpoints(self) -> None:
"""Wire endpoints reversed (x2 < x1) should still work."""
assert self._fn(5, 0, 10, 0, 0, 0) is True
def test_reversed_vertical_endpoints(self):
def test_reversed_vertical_endpoints(self) -> None:
"""Wire endpoints reversed (y2 < y1) should still work."""
assert self._fn(0, 5, 0, 10, 0, 0) is True
def test_near_endpoint_within_epsilon(self):
def test_near_endpoint_within_epsilon(self) -> None:
"""Point within epsilon of endpoint should NOT be considered strictly on wire."""
assert self._fn(1e-7, 0, 0, 0, 10, 0) is False
def test_zero_length_wire(self):
def test_zero_length_wire(self) -> None:
"""Degenerate wire with same start/end — nothing is strictly between."""
assert self._fn(5, 5, 5, 5, 5, 5) is False
@@ -710,12 +711,12 @@ class TestParseWire:
"""Unit tests for WireManager._parse_wire S-expression parser."""
@staticmethod
def _fn(item):
def _fn(item: Any) -> Any:
from commands.wire_manager import WireManager
return WireManager._parse_wire(item)
def test_valid_wire(self):
def test_valid_wire(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]],
@@ -734,28 +735,28 @@ class TestParseWire:
assert width == 0
assert stype == "default"
def test_non_wire_element_returns_none(self):
def test_non_wire_element_returns_none(self) -> None:
junction = [Symbol("junction"), [Symbol("at"), 10, 20]]
assert TestParseWire._fn(junction) is None
def test_non_list_returns_none(self):
def test_non_list_returns_none(self) -> None:
assert TestParseWire._fn("not a list") is None
def test_empty_list_returns_none(self):
def test_empty_list_returns_none(self) -> None:
assert TestParseWire._fn([]) is None
def test_wire_with_no_pts_returns_none(self):
def test_wire_with_no_pts_returns_none(self) -> None:
wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]]
assert TestParseWire._fn(wire) is None
def test_wire_with_only_one_xy_returns_none(self):
def test_wire_with_only_one_xy_returns_none(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0]],
]
assert TestParseWire._fn(wire) is None
def test_wire_without_stroke_uses_defaults(self):
def test_wire_without_stroke_uses_defaults(self) -> None:
wire = [
Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]],
@@ -776,7 +777,7 @@ class TestParseWire:
class TestMakeWireSexp:
"""Unit tests for WireManager._make_wire_sexp builder."""
def test_produces_valid_parseable_wire(self):
def test_produces_valid_parseable_wire(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([10, 20], [30, 20])
@@ -788,7 +789,7 @@ class TestMakeWireSexp:
assert width == 0
assert stype == "default"
def test_custom_stroke(self):
def test_custom_stroke(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
@@ -798,7 +799,7 @@ class TestMakeWireSexp:
assert width == 0.5
assert stype == "dash"
def test_has_uuid(self):
def test_has_uuid(self) -> None:
from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [10, 0])
@@ -807,7 +808,7 @@ class TestMakeWireSexp:
assert uuid_entry[0] == Symbol("uuid")
assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0
def test_two_calls_produce_different_uuids(self):
def test_two_calls_produce_different_uuids(self) -> None:
from commands.wire_manager import WireManager
sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0])
@@ -825,7 +826,7 @@ class TestBreakWiresAtPoint:
"""Unit tests for WireManager._break_wires_at_point T-junction logic."""
@staticmethod
def _make_sch_data_with_wires(wire_coords):
def _make_sch_data_with_wires(wire_coords: Any) -> list[Any]:
"""Build a minimal sch_data list with wire elements and a sheet_instances marker."""
from commands.wire_manager import WireManager
@@ -835,7 +836,7 @@ class TestBreakWiresAtPoint:
data.append([Symbol("sheet_instances")])
return data
def test_split_horizontal_wire_at_midpoint(self):
def test_split_horizontal_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
@@ -851,7 +852,7 @@ class TestBreakWiresAtPoint:
endpoints = {c for pair in coords for c in pair}
assert (10.0, 0.0) in endpoints
def test_split_vertical_wire_at_midpoint(self):
def test_split_vertical_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([5, 0], [5, 30])])
@@ -860,7 +861,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire")
assert len(wires) == 2
def test_no_split_at_wire_endpoint(self):
def test_no_split_at_wire_endpoint(self) -> None:
"""Point at existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager
@@ -870,7 +871,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_no_split_point_not_on_wire(self):
def test_no_split_point_not_on_wire(self) -> None:
from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
@@ -879,7 +880,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire")
assert len(wires) == 1
def test_split_multiple_wires_at_same_point(self):
def test_split_multiple_wires_at_same_point(self) -> None:
"""Two crossing wires at (10, 10) — both should be split."""
from commands.wire_manager import WireManager
@@ -894,7 +895,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire")
assert len(wires) == 4 # each wire split into 2
def test_split_preserves_stroke_properties(self):
def test_split_preserves_stroke_properties(self) -> None:
from commands.wire_manager import WireManager
data = [Symbol("kicad_sch")]
@@ -910,7 +911,7 @@ class TestBreakWiresAtPoint:
assert parsed[2] == 0.5, "stroke_width should be preserved"
assert parsed[3] == "dash", "stroke_type should be preserved"
def test_no_split_on_diagonal_wire(self):
def test_no_split_on_diagonal_wire(self) -> None:
"""Diagonal wires are not handled by _point_strictly_on_wire → no split."""
from commands.wire_manager import WireManager
@@ -918,7 +919,7 @@ class TestBreakWiresAtPoint:
splits = WireManager._break_wires_at_point(data, [5, 5])
assert splits == 0
def test_empty_sch_data(self):
def test_empty_sch_data(self) -> None:
from commands.wire_manager import WireManager
data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]]
@@ -936,12 +937,12 @@ class TestIntegrationTJunction:
"""Integration tests for T-junction wire breaking during add_wire/add_junction."""
@pytest.fixture(autouse=True)
def sch(self):
def sch(self) -> Any:
path = _make_temp_sch()
yield path
shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_breaks_existing_horizontal_wire(self, sch):
def test_add_wire_breaks_existing_horizontal_wire(self, sch: Any) -> None:
"""Adding a vertical wire whose endpoint is mid-horizontal-wire should split it."""
from commands.wire_manager import WireManager
@@ -954,7 +955,7 @@ class TestIntegrationTJunction:
# Original horizontal wire should be split into 2, plus the new vertical = 3 total
assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}"
def test_add_wire_does_not_break_at_shared_endpoint(self, sch):
def test_add_wire_does_not_break_at_shared_endpoint(self, sch: Any) -> None:
"""Wire connecting at an existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager
@@ -965,7 +966,7 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}"
def test_add_junction_breaks_wire(self, sch):
def test_add_junction_breaks_wire(self, sch: Any) -> None:
"""Adding a junction mid-wire should split that wire."""
from commands.wire_manager import WireManager
@@ -977,7 +978,7 @@ class TestIntegrationTJunction:
junctions = _find_elements(data, "junction")
assert len(junctions) == 1
def test_add_junction_at_wire_endpoint_no_split(self, sch):
def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None:
"""Junction at wire endpoint should not split it."""
from commands.wire_manager import WireManager
@@ -987,7 +988,7 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire")
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch):
def test_polyline_breaks_existing_wire(self, sch: Any) -> None:
"""Polyline whose start/end hits mid-wire should break it."""
from commands.wire_manager import WireManager
@@ -999,7 +1000,7 @@ class TestIntegrationTJunction:
# 2 from split + 2 polyline segments = 4
assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}"
def test_polyline_two_points_same_as_add_wire(self, sch):
def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None:
"""Polyline with exactly 2 points should produce 1 wire segment."""
from commands.wire_manager import WireManager

View File

@@ -157,9 +157,9 @@ class KiCADProcessManager:
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}")
return Path(path)
exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {exe_path}")
return Path(exe_path)
# Platform-specific default paths
if system == "Linux":

View File

@@ -516,7 +516,16 @@ export class KiCADMcpServer {
// Determine timeout based on command type
// DRC and export operations need longer timeouts for large boards
let commandTimeout = 30000; // Default 30 seconds
const longRunningCommands = ["run_drc", "export_gerber", "export_pdf", "export_3d"];
const longRunningCommands = [
"run_drc",
"export_gerber",
"export_pdf",
"export_3d",
"sync_schematic_to_board",
"list_schematic_nets",
"list_schematic_labels",
"get_schematic_view",
];
if (longRunningCommands.includes(command)) {
commandTimeout = 600000; // 10 minutes for long operations
logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`);
@@ -550,7 +559,21 @@ export class KiCADMcpServer {
}
/**
* Try to parse a complete JSON response from the buffer
* Try to parse a complete JSON response from the buffer.
*
* Responses from the Python side are single-line JSON terminated by '\n'
* (written via _write_response). The buffer may also contain non-JSON
* preamble lines (e.g. C-level warnings from pcbnew that leaked to the
* response fd before the redirect took effect).
*
* Strategy:
* 1. Fast path: JSON.parse(buffer) — works for clean, complete responses
* (JSON.parse tolerates trailing whitespace/newlines).
* 2. If that fails and the buffer has no '\n' yet, the response line is
* still arriving in chunks — keep collecting.
* 3. If the buffer has '\n', split into lines and search from the END for
* a parseable JSON line. This avoids prematurely resolving with a
* truncated JSON object when a large response is still chunking in.
*/
private tryParseResponse(): void {
if (!this.currentRequestHandler) {
@@ -564,14 +587,64 @@ export class KiCADMcpServer {
return;
}
let result: any;
// Fast path: try to parse the response as JSON. Handles the common
// case of a clean, complete JSON response (possibly with trailing \n).
try {
// Try to parse the response as JSON
const result = JSON.parse(this.responseBuffer);
result = JSON.parse(this.responseBuffer);
} catch {
// Direct parse failed. Either the response is still arriving in
// chunks, or the buffer has non-JSON preamble from pcbnew.
//
// The Python side writes each response as a single line of JSON
// terminated by \n. We use the newline as the completion signal:
// if there is no \n in the buffer yet, the JSON line is still
// being assembled from chunks — keep collecting.
if (!this.responseBuffer.includes("\n")) {
return;
}
// Buffer contains newline(s). Split into lines and look for a
// complete JSON object, searching from the END so that preamble
// lines (which may themselves contain '{') are skipped.
const lines = this.responseBuffer.split("\n");
let jsonLineIndex = -1;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line.length === 0) continue;
if (!line.startsWith("{")) continue;
try {
result = JSON.parse(line);
jsonLineIndex = i;
break;
} catch {
// Looks like JSON but doesn't parse — could be an incomplete
// final line still being chunked. Keep collecting.
continue;
}
}
if (jsonLineIndex < 0) {
// No parseable JSON line found yet. Either only preamble has
// arrived, or the JSON line is split across the last \n boundary
// and is still incomplete. Keep collecting.
return;
}
// Log any preceding non-JSON lines as preamble
const preambleLines = lines.slice(0, jsonLineIndex).filter((l) => l.trim().length > 0);
if (preambleLines.length > 0) {
logger.warn(
`Stripped non-JSON preamble from Python response: ${preambleLines.join(" | ")}`,
);
}
}
// If we get here, we have a valid JSON response
logger.debug(
`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`,
);
logger.debug(`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`);
// Clear the timeout since we got a response
if (this.currentRequestHandler.timeoutHandle) {
@@ -591,10 +664,6 @@ export class KiCADMcpServer {
// Process next request if any
setTimeout(() => this.processNextRequest(), 0);
} catch (e) {
// Not a complete JSON yet, keep collecting data
// This is normal for large responses that come in chunks
}
}
/**