chore: enable strict mypy checks and fix pre-commit mypy hook

Add type annotations to all previously untyped functions and remove 9
suppressed error codes (call-arg, assignment, return-value, operator,
has-type, dict-item, misc, list-item, annotation-unchecked) by fixing
the underlying type issues.

Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific
modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit
mypy hook passes in its isolated venv. Add types-requests and pytest to
additional_dependencies in .pre-commit-config.yaml.

Also fixes several real bugs uncovered by stricter checks: incorrect static
calls to instance methods in swig_backend, wrong return type on get_size,
missing value param in BoardAPI.place_component, variable shadowing in
kicad_process.py, unqualified LibraryManager reference in kicad_interface,
and missing top-level Path import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-05 23:48:35 +01:00
parent 5a2b481db3
commit 9b1024a8f3
35 changed files with 4665 additions and 4610 deletions

View File

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

View File

@@ -23,15 +23,20 @@ disable_error_code = [
"arg-type", "arg-type",
"attr-defined", "attr-defined",
"union-attr", "union-attr",
"call-arg",
"assignment",
"var-annotated", "var-annotated",
"annotation-unchecked",
"index", "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 os
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic from skip import Schematic
@@ -25,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None _dynamic_loader = None
@classmethod @classmethod
def get_dynamic_loader(cls): def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance""" """Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE: if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader() cls._dynamic_loader = DynamicSymbolLoader()
@@ -86,7 +86,7 @@ class ComponentManager:
""" """
# Helper function to check if template exists in schematic # 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)""" """Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol: for symbol in schematic.symbol:
if ( if (
@@ -165,7 +165,7 @@ class ComponentManager:
@staticmethod @staticmethod
def add_component( def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
): ) -> Any:
""" """
Add a component to the schematic by cloning from template Add a component to the schematic by cloning from template
@@ -265,7 +265,7 @@ class ComponentManager:
raise raise
@staticmethod @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""" """Remove a component from the schematic by reference designator"""
try: try:
# kicad-skip doesn't have a direct remove_symbol method by reference. # kicad-skip doesn't have a direct remove_symbol method by reference.
@@ -288,7 +288,7 @@ class ComponentManager:
return False return False
@staticmethod @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""" """Update component properties by reference designator"""
try: try:
symbol_to_update = None symbol_to_update = None
@@ -313,7 +313,7 @@ class ComponentManager:
return False return False
@staticmethod @staticmethod
def get_component(schematic: Schematic, component_ref: str): def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator""" """Get a component by reference designator"""
for symbol in schematic.symbol: for symbol in schematic.symbol:
if symbol.reference == component_ref: if symbol.reference == component_ref:
@@ -323,7 +323,7 @@ class ComponentManager:
return None return None
@staticmethod @staticmethod
def search_components(schematic: Schematic, query: str): def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)""" """Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic # This is a basic search, could be expanded to use regex or more complex logic
matching_components = [] matching_components = []
@@ -342,7 +342,7 @@ class ComponentManager:
return matching_components return matching_components
@staticmethod @staticmethod
def get_all_components(schematic: Schematic): def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic""" """Get all components in schematic"""
logger.debug(f"Retrieving all {len(schematic.symbol)} components.") logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol) return list(schematic.symbol)

View File

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

View File

@@ -12,7 +12,7 @@ No API key required.
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None return None
@staticmethod @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. Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found. 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}") changes.append(f"drill (inserted)→{new_drill}")
if shape: if shape:
block, n = re.subn( 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: if n:
changes.append(f"shape→{shape}") changes.append(f"shape→{shape}")

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import logging
# Symbol class might not be directly importable in the current version # Symbol class might not be directly importable in the current version
import os import os
from typing import Any, Dict, List, Optional
from skip import Schematic from skip import Schematic
@@ -13,7 +14,7 @@ class LibraryManager:
"""Manage symbol libraries""" """Manage symbol libraries"""
@staticmethod @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""" """List all available symbol libraries"""
if search_paths is None: if search_paths is None:
# Default library paths based on common KiCAD installations # Default library paths based on common KiCAD installations
@@ -46,7 +47,7 @@ class LibraryManager:
return {"paths": libraries, "names": library_names} return {"paths": libraries, "names": library_names}
@staticmethod @staticmethod
def list_library_symbols(library_path): def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library""" """List all symbols in a library"""
try: try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library # kicad-skip doesn't provide a direct way to simply list symbols in a library
@@ -66,7 +67,7 @@ class LibraryManager:
return [] return []
@staticmethod @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""" """Get detailed information about a symbol"""
try: try:
# Similar to list_library_symbols, this might require a more direct approach # Similar to list_library_symbols, this might require a more direct approach
@@ -80,7 +81,7 @@ class LibraryManager:
return {} return {}
@staticmethod @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""" """Search for symbols matching criteria"""
try: try:
# This would typically involve: # This would typically involve:
@@ -101,7 +102,9 @@ class LibraryManager:
return [] return []
@staticmethod @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""" """Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types # 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 # 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.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries() self._load_libraries()
def _load_libraries(self): def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files""" """Load libraries from sym-lib-table files"""
# Load global libraries # Load global libraries
global_table = self._get_global_sym_lib_table() global_table = self._get_global_sym_lib_table()
@@ -98,7 +98,7 @@ class SymbolLibraryManager:
return None 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 Parse sym-lib-table file
@@ -370,7 +370,7 @@ class SymbolLibraryManager:
query_lower = query.lower() query_lower = query.lower()
# Determine which libraries to search # Determine which libraries to search
libraries_to_search = self.libraries.keys() libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter: if library_filter:
filter_lower = library_filter.lower() filter_lower = library_filter.lower()
libraries_to_search = [ libraries_to_search = [

View File

@@ -9,7 +9,7 @@ import logging
import math import math
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -21,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator: class PinLocator:
"""Locate pins on symbol instances in KiCad schematics""" """Locate pins on symbol instances in KiCad schematics"""
def __init__(self): def __init__(self) -> None:
"""Initialize pin locator with empty cache""" """Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic 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"} "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""" """Recursively search for pin definitions"""
if not isinstance(sexp, list): if not isinstance(sexp, list):
return return

View File

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

View File

@@ -2,6 +2,7 @@ import logging
import os import os
import shutil import shutil
import uuid import uuid
from typing import Any, Optional
from skip import Schematic from skip import Schematic
@@ -12,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip""" """Core schematic operations using kicad-skip"""
@staticmethod @staticmethod
def create_schematic(name, metadata=None): def create_schematic(name: str, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template""" """Create a new empty schematic from template"""
try: try:
# Determine template path (use template_with_symbols for component cloning support) # Determine template path (use template_with_symbols for component cloning support)
@@ -70,7 +71,7 @@ class SchematicManager:
raise raise
@staticmethod @staticmethod
def load_schematic(file_path): def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic""" """Load an existing schematic"""
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}") logger.error(f"Schematic file not found at {file_path}")
@@ -84,7 +85,7 @@ class SchematicManager:
return None return None
@staticmethod @staticmethod
def save_schematic(schematic, file_path): def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file""" """Save a schematic to file"""
try: try:
# kicad-skip uses write method, not save # kicad-skip uses write method, not save
@@ -96,7 +97,7 @@ class SchematicManager:
return False return False
@staticmethod @staticmethod
def get_schematic_metadata(schematic): def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic""" """Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic. # kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator. # 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 = [] collisions = []
# Pre-compute per-symbol data # Pre-compute per-symbol data
symbol_data = [] symbol_data: List[Dict[str, Any]] = []
for sym in symbols: for sym in symbols:
ref = sym["reference"] ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: 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 cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 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( a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))) 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]]: 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].""" """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]] 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)] 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() result = identity()
@@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
return out 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)] 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 return default
def _identity(): def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] 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 import logging
from pathlib import Path 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 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)) 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.""" """Extract wire endpoints from a schematic object as IU tuples."""
all_wires = [] all_wires = []
for wire in schematic.wire: for wire in schematic.wire:
@@ -67,7 +67,9 @@ def _build_adjacency(
return adjacency, iu_to_wires 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. """Return virtual connectivity from net labels and power symbols.
Returns a tuple of: Returns a tuple of:
@@ -175,8 +177,8 @@ def _find_connected_wires(
def _find_pins_on_net( def _find_pins_on_net(
net_points: Set[Tuple[int, int]], net_points: Set[Tuple[int, int]],
schematic_path, schematic_path: Any,
schematic, schematic: Any,
) -> List[Dict]: ) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching. """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( 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]: ) -> Optional[Dict]:
"""Find all component pins reachable from a point via connected wires, net labels, and power symbols. """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 logging
import math import math
import uuid import uuid
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -55,7 +55,7 @@ class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves.""" """Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod @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. Find a placed symbol by reference designator.
@@ -218,7 +218,7 @@ class WireDragger:
junction_k = _K["junction"] junction_k = _K["junction"]
at_k = _K["at"] 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(): for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps): if _coords_match(x, y, ox, oy, eps):
return nx, ny return nx, ny

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import logging
import os import os
import sys import sys
import traceback import traceback
from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -241,7 +242,7 @@ except ImportError as e:
class KiCADInterface: class KiCADInterface:
"""Main interface class to handle KiCAD operations""" """Main interface class to handle KiCAD operations"""
def __init__(self): def __init__(self) -> None:
"""Initialize the interface and command handlers""" """Initialize the interface and command handlers"""
self.board = None self.board = None
self.project_filename = None self.project_filename = None
@@ -560,7 +561,7 @@ class KiCADInterface:
"connect_passthrough", "connect_passthrough",
} }
def _auto_save_board(self): def _auto_save_board(self) -> None:
"""Save board to disk after SWIG mutations. """Save board to disk after SWIG mutations.
Called automatically after every board-mutating SWIG command so that Called automatically after every board-mutating SWIG command so that
data is not lost if Claude hits the context limit before save_project. data is not lost if Claude hits the context limit before save_project.
@@ -574,7 +575,7 @@ class KiCADInterface:
except Exception as e: except Exception as e:
logger.warning(f"Auto-save failed: {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""" """Update board reference in all command handlers"""
logger.debug("Updating board reference in command handlers") logger.debug("Updating board reference in command handlers")
self.project_commands.board = self.board self.project_commands.board = self.board
@@ -586,7 +587,7 @@ class KiCADInterface:
self.freerouting_commands.board = self.board self.freerouting_commands.board = self.board
# Schematic command handlers # 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""" """Create a new schematic"""
logger.info("Creating schematic") logger.info("Creating schematic")
try: try:
@@ -623,7 +624,7 @@ class KiCADInterface:
logger.error(f"Error creating schematic: {str(e)}") logger.error(f"Error creating schematic: {str(e)}")
return {"success": False, "message": 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""" """Load an existing schematic"""
logger.info("Loading schematic") logger.info("Loading schematic")
try: try:
@@ -644,7 +645,7 @@ class KiCADInterface:
logger.error(f"Error loading schematic: {str(e)}") logger.error(f"Error loading schematic: {str(e)}")
return {"success": False, "message": 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. """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 If boardPath is given and differs from the currently loaded board, the
board is reloaded from boardPath before placing — prevents silent failures board is reloaded from boardPath before placing — prevents silent failures
@@ -679,7 +680,7 @@ class KiCADInterface:
return self.component_commands.place_component(params) 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)""" """Add a component to a schematic using text-based injection (no sexpdata)"""
logger.info("Adding component to schematic") logger.info("Adding component to schematic")
try: try:
@@ -732,7 +733,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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)""" """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)"""
logger.info("Deleting schematic component") logger.info("Deleting schematic component")
try: try:
@@ -757,7 +758,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() 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.""" """Find the closing paren matching the opening paren at start."""
depth = 0 depth = 0
i = start i = start
@@ -838,7 +839,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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). """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. Uses text-based in-place editing preserves position, UUID and all other fields.
""" """
@@ -883,7 +884,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() 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.""" """Find the position of the closing paren matching the opening paren at start."""
depth = 0 depth = 0
i = start i = start
@@ -928,7 +929,7 @@ class KiCADInterface:
break break
search_start = end + 1 search_start = end + 1
if block_start is None: if block_start is None or block_end is None:
return { return {
"success": False, "success": False,
"message": f"Component '{reference}' not found in schematic", "message": f"Component '{reference}' not found in schematic",
@@ -991,7 +992,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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.""" """Return full component info: position and all field values with their (at x y angle) positions."""
logger.info("Getting schematic component info") logger.info("Getting schematic component info")
try: try:
@@ -1016,7 +1017,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
def find_matching_paren(s, start): def find_matching_paren(s: str, start: int) -> int:
depth = 0 depth = 0
i = start i = start
while i < len(s): while i < len(s):
@@ -1058,7 +1059,7 @@ class KiCADInterface:
break break
search_start = end + 1 search_start = end + 1
if block_start is None: if block_start is None or block_end is None:
return { return {
"success": False, "success": False,
"message": f"Component '{reference}' not found in schematic", "message": f"Component '{reference}' not found in schematic",
@@ -1114,7 +1115,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Add a wire to a schematic using WireManager, with optional pin snapping"""
logger.info("Adding wire to schematic") logger.info("Adding wire to schematic")
try: try:
@@ -1164,7 +1165,7 @@ class KiCADInterface:
for pin_num, coords in pin_locs.items(): for pin_num, coords in pin_locs.items():
all_pins.append((ref, pin_num, coords)) 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.""" """Find the nearest pin within tolerance of a point."""
best = None best = None
best_dist = tolerance best_dist = tolerance
@@ -1238,7 +1239,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Add a junction (connection dot) to a schematic using WireManager"""
logger.info("Adding junction to schematic") logger.info("Adding junction to schematic")
try: try:
@@ -1271,19 +1272,19 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """List available symbol libraries"""
logger.info("Listing schematic libraries") logger.info("Listing schematic libraries")
try: try:
search_paths = params.get("searchPaths") search_paths = params.get("searchPaths")
libraries = LibraryManager.list_available_libraries(search_paths) libraries = SchematicLibraryManager.list_available_libraries(search_paths)
return {"success": True, "libraries": libraries} return {"success": True, "libraries": libraries}
except Exception as e: except Exception as e:
logger.error(f"Error listing schematic libraries: {str(e)}") logger.error(f"Error listing schematic libraries: {str(e)}")
return {"success": False, "message": 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""" """List component pins with no wire/label/power symbol touching them"""
logger.info("Finding unconnected pins") logger.info("Finding unconnected pins")
try: try:
@@ -1303,7 +1304,7 @@ class KiCADInterface:
logger.error(f"Error finding unconnected pins: {e}") logger.error(f"Error finding unconnected pins: {e}")
return {"success": False, "message": str(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""" """Detect wires passing through component bodies without connecting to pins"""
logger.info("Checking wire collisions") logger.info("Checking wire collisions")
try: try:
@@ -1327,7 +1328,7 @@ class KiCADInterface:
# Footprint handlers # # 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.""" """Create a new .kicad_mod footprint file in a .pretty library."""
logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
try: try:
@@ -1349,7 +1350,7 @@ class KiCADInterface:
logger.error(f"create_footprint error: {e}") logger.error(f"create_footprint error: {e}")
return {"success": False, "error": str(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.""" """Edit an existing pad in a .kicad_mod file."""
logger.info( logger.info(
f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}" f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}"
@@ -1368,7 +1369,7 @@ class KiCADInterface:
logger.error(f"edit_footprint_pad error: {e}") logger.error(f"edit_footprint_pad error: {e}")
return {"success": False, "error": str(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.""" """List .pretty footprint libraries and their contents."""
logger.info("list_footprint_libraries") logger.info("list_footprint_libraries")
try: try:
@@ -1378,7 +1379,7 @@ class KiCADInterface:
logger.error(f"list_footprint_libraries error: {e}") logger.error(f"list_footprint_libraries error: {e}")
return {"success": False, "error": str(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.""" """Register a .pretty library in KiCAD's fp-lib-table."""
logger.info(f"register_footprint_library: {params.get('libraryPath')}") logger.info(f"register_footprint_library: {params.get('libraryPath')}")
try: try:
@@ -1398,7 +1399,7 @@ class KiCADInterface:
# Symbol creator handlers # # 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.""" """Create a new symbol in a .kicad_sym library."""
logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
try: try:
@@ -1422,7 +1423,7 @@ class KiCADInterface:
logger.error(f"create_symbol error: {e}") logger.error(f"create_symbol error: {e}")
return {"success": False, "error": str(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.""" """Delete a symbol from a .kicad_sym library."""
logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
try: try:
@@ -1435,7 +1436,7 @@ class KiCADInterface:
logger.error(f"delete_symbol error: {e}") logger.error(f"delete_symbol error: {e}")
return {"success": False, "error": str(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.""" """List all symbols in a .kicad_sym file."""
logger.info(f"list_symbols_in_library: {params.get('libraryPath')}") logger.info(f"list_symbols_in_library: {params.get('libraryPath')}")
try: try:
@@ -1447,7 +1448,7 @@ class KiCADInterface:
logger.error(f"list_symbols_in_library error: {e}") logger.error(f"list_symbols_in_library error: {e}")
return {"success": False, "error": str(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.""" """Register a .kicad_sym library in KiCAD's sym-lib-table."""
logger.info(f"register_symbol_library: {params.get('libraryPath')}") logger.info(f"register_symbol_library: {params.get('libraryPath')}")
try: try:
@@ -1463,7 +1464,7 @@ class KiCADInterface:
logger.error(f"register_symbol_library error: {e}") logger.error(f"register_symbol_library error: {e}")
return {"success": False, "error": str(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""" """Export schematic to PDF"""
logger.info("Exporting schematic to PDF") logger.info("Exporting schematic to PDF")
try: try:
@@ -1512,7 +1513,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic to PDF: {str(e)}") logger.error(f"Error exporting schematic to PDF: {str(e)}")
return {"success": False, "message": 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""" """Add a net label to schematic using WireManager"""
logger.info("Adding net label to schematic") logger.info("Adding net label to schematic")
try: try:
@@ -1558,7 +1559,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Connect a component pin to a named net using wire stub and label"""
logger.info("Connecting component pin to net") logger.info("Connecting component pin to net")
try: try:
@@ -1595,7 +1596,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Connect all pins of source connector to matching pins of target connector"""
logger.info("Connecting passthrough between two connectors") logger.info("Connecting passthrough between two connectors")
try: try:
@@ -1632,7 +1633,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Return exact pin endpoint coordinates for a schematic component"""
logger.info("Getting schematic pin locations") logger.info("Getting schematic pin locations")
try: try:
@@ -1687,7 +1688,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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)""" """Get a rasterised image of the schematic (SVG export → optional PNG conversion)"""
logger.info("Getting schematic view") logger.info("Getting schematic view")
import base64 import base64
@@ -1776,7 +1777,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """List all components in a schematic"""
logger.info("Listing schematic components") logger.info("Listing schematic components")
try: try:
@@ -1869,7 +1870,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """List all nets in a schematic with their connections"""
logger.info("Listing schematic nets") logger.info("Listing schematic nets")
try: try:
@@ -1915,7 +1916,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """List all wires in a schematic"""
logger.info("Listing schematic wires") logger.info("Listing schematic wires")
try: try:
@@ -1958,7 +1959,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """List all net labels and power flags in a schematic"""
logger.info("Listing schematic labels") logger.info("Listing schematic labels")
try: try:
@@ -2037,7 +2038,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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.""" """Move a schematic component to a new position, dragging connected wires."""
logger.info("Moving schematic component") logger.info("Moving schematic component")
try: try:
@@ -2121,7 +2122,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Rotate a schematic component"""
logger.info("Rotating schematic component") logger.info("Rotating schematic component")
try: try:
@@ -2171,7 +2172,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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, ...)""" """Annotate unannotated components in schematic (R? -> R1, R2, ...)"""
logger.info("Annotating schematic") logger.info("Annotating schematic")
try: try:
@@ -2249,7 +2250,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Delete a wire from the schematic matching start/end points"""
logger.info("Deleting schematic wire") logger.info("Deleting schematic wire")
try: try:
@@ -2280,7 +2281,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Delete a net label from the schematic"""
logger.info("Deleting schematic net label") logger.info("Deleting schematic net label")
try: try:
@@ -2315,7 +2316,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Export schematic to SVG using kicad-cli"""
logger.info("Exporting schematic SVG") logger.info("Exporting schematic SVG")
import glob import glob
@@ -2389,7 +2390,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic SVG: {e}") logger.error(f"Error exporting schematic SVG: {e}")
return {"success": False, "message": str(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""" """Get all connections for a named net"""
logger.info("Getting net connections") logger.info("Getting net connections")
try: try:
@@ -2409,7 +2410,7 @@ class KiCADInterface:
logger.error(f"Error getting net connections: {str(e)}") logger.error(f"Error getting net connections: {str(e)}")
return {"success": False, "message": 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""" """Find all component pins reachable from a point via connected wires"""
logger.info("Getting wire connections") logger.info("Getting wire connections")
try: try:
@@ -2456,7 +2457,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic") logger.info("Running ERC on schematic")
import os import os
@@ -2552,7 +2553,7 @@ class KiCADInterface:
logger.error(f"Error running ERC: {str(e)}") logger.error(f"Error running ERC: {str(e)}")
return {"success": False, "message": 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""" """Generate netlist from schematic"""
logger.info("Generating netlist from schematic") logger.info("Generating netlist from schematic")
try: try:
@@ -2571,7 +2572,7 @@ class KiCADInterface:
logger.error(f"Error generating netlist: {str(e)}") logger.error(f"Error generating netlist: {str(e)}")
return {"success": False, "message": 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'). """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
Reads net connections from the schematic and assigns them to the matching pads in the PCB. Reads net connections from the schematic and assigns them to the matching pads in the PCB.
""" """
@@ -2694,7 +2695,7 @@ class KiCADInterface:
# Schematic analysis tools (read-only) # 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""" """Export a cropped region of the schematic as an image"""
logger.info("Exporting schematic view region") logger.info("Exporting schematic view region")
import base64 import base64
@@ -2809,7 +2810,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Detect spatially overlapping symbols, wires, and labels"""
logger.info("Finding overlapping elements in schematic") logger.info("Finding overlapping elements in schematic")
try: try:
@@ -2835,7 +2836,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """List all wires, labels, and symbols within a rectangular region"""
logger.info("Getting elements in schematic region") logger.info("Getting elements in schematic region")
try: try:
@@ -2865,7 +2866,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Find wires that cross over component symbol bodies"""
logger.info("Finding wires crossing symbols in schematic") logger.info("Finding wires crossing symbols in schematic")
try: try:
@@ -2891,7 +2892,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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""" """Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB") logger.info("Importing SVG logo into PCB")
try: try:
@@ -2938,7 +2939,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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.""" """Copy the entire project folder to a snapshot directory for checkpoint/resume."""
import shutil import shutil
from datetime import datetime from datetime import datetime
@@ -3022,7 +3023,7 @@ class KiCADInterface:
logger.error(f"snapshot_project error: {e}") logger.error(f"snapshot_project error: {e}")
return {"success": False, "message": str(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""" """Check if KiCAD UI is running"""
logger.info("Checking if KiCAD UI is running") logger.info("Checking if KiCAD UI is running")
try: try:
@@ -3040,7 +3041,7 @@ class KiCADInterface:
logger.error(f"Error checking KiCAD UI status: {str(e)}") logger.error(f"Error checking KiCAD UI status: {str(e)}")
return {"success": False, "message": 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""" """Launch KiCAD UI"""
logger.info("Launching KiCAD UI") logger.info("Launching KiCAD UI")
try: try:
@@ -3059,7 +3060,7 @@ class KiCADInterface:
logger.error(f"Error launching KiCAD UI: {str(e)}") logger.error(f"Error launching KiCAD UI: {str(e)}")
return {"success": False, "message": 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. """Refill all copper pour zones on the board.
pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005) pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005)
@@ -3146,7 +3147,7 @@ print("ok")
# These methods are called automatically when IPC is available # 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""" """IPC handler for route_trace - adds track with real-time UI update"""
try: try:
# Extract parameters matching the existing route_trace interface # Extract parameters matching the existing route_trace interface
@@ -3189,7 +3190,7 @@ print("ok")
logger.error(f"IPC route_trace error: {e}") logger.error(f"IPC route_trace error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_via - adds via with real-time UI update"""
try: try:
position = params.get("position", {}) position = params.get("position", {})
@@ -3222,7 +3223,7 @@ print("ok")
logger.error(f"IPC add_via error: {e}") logger.error(f"IPC add_via error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_net"""
# Note: Net creation via IPC is limited - nets are typically created # Note: Net creation via IPC is limited - nets are typically created
# when components are placed. Return success for compatibility. # when components are placed. Return success for compatibility.
@@ -3234,7 +3235,7 @@ print("ok")
"net": {"name": name}, "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""" """IPC handler for add_copper_pour - adds zone with real-time UI update"""
try: try:
layer = params.get("layer", "F.Cu") layer = params.get("layer", "F.Cu")
@@ -3289,7 +3290,7 @@ print("ok")
logger.error(f"IPC add_copper_pour error: {e}") logger.error(f"IPC add_copper_pour error: {e}")
return {"success": False, "message": str(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""" """IPC handler for refill_zones - refills all zones with real-time UI update"""
try: try:
success = self.ipc_board_api.refill_zones() success = self.ipc_board_api.refill_zones()
@@ -3304,7 +3305,7 @@ print("ok")
logger.error(f"IPC refill_zones error: {e}") logger.error(f"IPC refill_zones error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_text/add_board_text - adds text with real-time UI update"""
try: try:
text = params.get("text", "") text = params.get("text", "")
@@ -3331,7 +3332,7 @@ print("ok")
logger.error(f"IPC add_text error: {e}") logger.error(f"IPC add_text error: {e}")
return {"success": False, "message": str(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""" """IPC handler for set_board_size"""
try: try:
width = params.get("width", 100) width = params.get("width", 100)
@@ -3353,7 +3354,7 @@ print("ok")
logger.error(f"IPC set_board_size error: {e}") logger.error(f"IPC set_board_size error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_board_info"""
try: try:
size = self.ipc_board_api.get_size() size = self.ipc_board_api.get_size()
@@ -3378,7 +3379,7 @@ print("ok")
logger.error(f"IPC get_board_info error: {e}") logger.error(f"IPC get_board_info error: {e}")
return {"success": False, "message": str(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""" """IPC handler for place_component - places component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3419,7 +3420,7 @@ print("ok")
logger.error(f"IPC place_component error: {e}") logger.error(f"IPC place_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for move_component - moves component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3444,7 +3445,7 @@ print("ok")
logger.error(f"IPC move_component error: {e}") logger.error(f"IPC move_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for delete_component - deletes component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3463,7 +3464,7 @@ print("ok")
logger.error(f"IPC delete_component error: {e}") logger.error(f"IPC delete_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_component_list"""
try: try:
components = self.ipc_board_api.list_components() components = self.ipc_board_api.list_components()
@@ -3473,7 +3474,7 @@ print("ok")
logger.error(f"IPC get_component_list error: {e}") logger.error(f"IPC get_component_list error: {e}")
return {"success": False, "message": str(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""" """IPC handler for save_project"""
try: try:
success = self.ipc_board_api.save() success = self.ipc_board_api.save()
@@ -3486,14 +3487,14 @@ print("ok")
logger.error(f"IPC save_project error: {e}") logger.error(f"IPC save_project error: {e}")
return {"success": False, "message": str(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 handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
# IPC API doesn't have a direct delete track method # IPC API doesn't have a direct delete track method
# Fall back to SWIG for this operation # Fall back to SWIG for this operation
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
return self.routing_commands.delete_trace(params) 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""" """IPC handler for get_nets_list - gets nets with real-time data"""
try: try:
nets = self.ipc_board_api.get_nets() nets = self.ipc_board_api.get_nets()
@@ -3503,7 +3504,7 @@ print("ok")
logger.error(f"IPC get_nets_list error: {e}") logger.error(f"IPC get_nets_list error: {e}")
return {"success": False, "message": str(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. """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 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 type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file
@@ -3566,7 +3567,7 @@ print("ok")
logger.error(f"IPC add_board_outline error: {e}") logger.error(f"IPC add_board_outline error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_mounting_hole - adds mounting hole with real-time UI update"""
try: try:
from kipy.board_types import BoardCircle from kipy.board_types import BoardCircle
@@ -3601,7 +3602,7 @@ print("ok")
logger.error(f"IPC add_mounting_hole error: {e}") logger.error(f"IPC add_mounting_hole error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_layer_list - gets enabled layers"""
try: try:
layers = self.ipc_board_api.get_enabled_layers() layers = self.ipc_board_api.get_enabled_layers()
@@ -3611,7 +3612,7 @@ print("ok")
logger.error(f"IPC get_layer_list error: {e}") logger.error(f"IPC get_layer_list error: {e}")
return {"success": False, "message": str(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""" """IPC handler for rotate_component - rotates component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3653,7 +3654,7 @@ print("ok")
logger.error(f"IPC rotate_component error: {e}") logger.error(f"IPC rotate_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_component_properties - gets detailed component info"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3677,7 +3678,7 @@ print("ok")
# Legacy IPC command handlers (explicit ipc_* commands) # 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""" """Get information about the current backend"""
return { return {
"success": True, "success": True,
@@ -3692,7 +3693,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)""" """Add a track using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3718,7 +3719,7 @@ print("ok")
logger.error(f"Error adding track via IPC: {e}") logger.error(f"Error adding track via IPC: {e}")
return {"success": False, "message": str(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)""" """Add a via using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3741,7 +3742,7 @@ print("ok")
logger.error(f"Error adding via via IPC: {e}") logger.error(f"Error adding via via IPC: {e}")
return {"success": False, "message": str(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)""" """Add text using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3766,7 +3767,7 @@ print("ok")
logger.error(f"Error adding text via IPC: {e}") logger.error(f"Error adding text via IPC: {e}")
return {"success": False, "message": str(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""" """List components using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3778,7 +3779,7 @@ print("ok")
logger.error(f"Error listing components via IPC: {e}") logger.error(f"Error listing components via IPC: {e}")
return {"success": False, "message": str(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""" """Get tracks using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3790,7 +3791,7 @@ print("ok")
logger.error(f"Error getting tracks via IPC: {e}") logger.error(f"Error getting tracks via IPC: {e}")
return {"success": False, "message": str(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""" """Get vias using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3802,7 +3803,7 @@ print("ok")
logger.error(f"Error getting vias via IPC: {e}") logger.error(f"Error getting vias via IPC: {e}")
return {"success": False, "message": str(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""" """Save board using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3819,7 +3820,7 @@ print("ok")
# JLCPCB API handlers # 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""" """Download JLCPCB parts database from JLCSearch API"""
try: try:
force = params.get("force", False) force = params.get("force", False)
@@ -3870,7 +3871,7 @@ print("ok")
"message": f"Failed to download database: {str(e)}", "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""" """Search JLCPCB parts database"""
try: try:
query = params.get("query") query = params.get("query")
@@ -3909,7 +3910,7 @@ print("ok")
logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True) logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True)
return {"success": False, "message": f"Search failed: {str(e)}"} 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""" """Get detailed information for a specific JLCPCB part"""
try: try:
lcsc_number = params.get("lcsc_number") lcsc_number = params.get("lcsc_number")
@@ -3929,7 +3930,7 @@ print("ok")
logger.error(f"Error getting JLCPCB part: {e}", exc_info=True) logger.error(f"Error getting JLCPCB part: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get part info: {str(e)}"} 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""" """Get statistics about JLCPCB database"""
try: try:
stats = self.jlcpcb_parts.get_database_stats() stats = self.jlcpcb_parts.get_database_stats()
@@ -3939,7 +3940,7 @@ print("ok")
logger.error(f"Error getting database stats: {e}", exc_info=True) logger.error(f"Error getting database stats: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get stats: {str(e)}"} 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""" """Suggest alternative JLCPCB parts"""
try: try:
lcsc_number = params.get("lcsc_number") lcsc_number = params.get("lcsc_number")
@@ -3980,7 +3981,7 @@ print("ok")
"message": f"Failed to suggest alternatives: {str(e)}", "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""" """Enrich schematic Datasheet fields from LCSC numbers"""
try: try:
from pathlib import Path from pathlib import Path
@@ -3998,7 +3999,7 @@ print("ok")
"message": f"Failed to enrich datasheets: {str(e)}", "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""" """Return LCSC datasheet and product URLs for a part number"""
try: try:
lcsc = params.get("lcsc", "") lcsc = params.get("lcsc", "")
@@ -4024,7 +4025,7 @@ print("ok")
} }
def _write_response(response_fd, response): def _write_response(response_fd: Any, response: Any) -> None:
"""Write a JSON response to the original stdout fd. """Write a JSON response to the original stdout fd.
All response output goes through this function so that stray C-level All response output goes through this function so that stray C-level
@@ -4035,7 +4036,7 @@ def _write_response(response_fd, response):
os.write(response_fd, payload.encode("utf-8")) os.write(response_fd, payload.encode("utf-8"))
def main(): def main() -> None:
"""Main entry point""" """Main entry point"""
# --- Redirect stdout so pcbnew C++ noise never reaches the TS host --- # --- Redirect stdout so pcbnew C++ noise never reaches the TS host ---
# Save the real stdout fd for our exclusive JSON response channel. # Save the real stdout fd for our exclusive JSON response channel.

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 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""" """Get current project information"""
result = interface.project_commands.get_project_info({}) 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""" """Get board properties and metadata"""
result = interface.board_commands.get_board_info({}) 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""" """Get list of all components"""
result = interface.component_commands.get_component_list({}) 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""" """Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({}) 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""" """Get layer stack information"""
result = interface.board_commands.get_layer_list({}) 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""" """Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({}) 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""" """Get DRC violations"""
result = interface.design_rule_commands.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""" """Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})

View File

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

View File

@@ -1,207 +1,208 @@
""" """
Regression tests for delete_schematic_component. Regression tests for delete_schematic_component.
Key regression: the handler previously used a line-by-line regex that required Key regression: the handler previously used a line-by-line regex that required
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts `(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
them on *separate* lines, so every real-world delete returned "not found". them on *separate* lines, so every real-world delete returned "not found".
""" """
import re import re
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import pytest
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
# Inline format (single line) matches what tests previously used
PLACED_RESISTOR_INLINE = """\ # Inline format (single line) matches what tests previously used
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1) PLACED_RESISTOR_INLINE = """\
(in_bom yes) (on_board yes) (dnp no) (symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") (in_bom yes) (on_board yes) (dnp no)
(property "Reference" "R1" (at 51.27 47.46 0) (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
(effects (font (size 1.27 1.27))) (property "Reference" "R1" (at 51.27 47.46 0)
) (effects (font (size 1.27 1.27)))
(property "Value" "10k" (at 51.27 52.54 0) )
(effects (font (size 1.27 1.27))) (property "Value" "10k" (at 51.27 52.54 0)
) (effects (font (size 1.27 1.27)))
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) )
(effects (font (size 1.27 1.27)) hide) (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
) (effects (font (size 1.27 1.27)) hide)
(property "Datasheet" "~" (at 50 50 0) )
(effects (font (size 1.27 1.27)) hide) (property "Datasheet" "~" (at 50 50 0)
) (effects (font (size 1.27 1.27)) hide)
) )
""" )
"""
# Multi-line format as KiCAD's own file writer produces it.
# (symbol and (lib_id are on separate lines, which broke the old regex. # Multi-line format as KiCAD's own file writer produces it.
PLACED_RESISTOR_MULTILINE = """\ # (symbol and (lib_id are on separate lines, which broke the old regex.
\t(symbol PLACED_RESISTOR_MULTILINE = """\
\t\t(lib_id "Device:R") \t(symbol
\t\t(at 50 50 0) \t\t(lib_id "Device:R")
\t\t(unit 1) \t\t(at 50 50 0)
\t\t(in_bom yes) \t\t(unit 1)
\t\t(on_board yes) \t\t(in_bom yes)
\t\t(dnp no) \t\t(on_board yes)
\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff") \t\t(dnp no)
\t\t(property "Reference" "R2" \t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
\t\t\t(at 51.27 47.46 0) \t\t(property "Reference" "R2"
\t\t\t(effects \t\t\t(at 51.27 47.46 0)
\t\t\t\t(font \t\t\t(effects
\t\t\t\t\t(size 1.27 1.27) \t\t\t\t(font
\t\t\t\t) \t\t\t\t\t(size 1.27 1.27)
\t\t\t) \t\t\t\t)
\t\t) \t\t\t)
\t\t(property "Value" "4.7k" \t\t)
\t\t\t(at 51.27 52.54 0) \t\t(property "Value" "4.7k"
\t\t\t(effects \t\t\t(at 51.27 52.54 0)
\t\t\t\t(font \t\t\t(effects
\t\t\t\t\t(size 1.27 1.27) \t\t\t\t(font
\t\t\t\t) \t\t\t\t\t(size 1.27 1.27)
\t\t\t) \t\t\t\t)
\t\t) \t\t\t)
\t) \t\t)
""" \t)
"""
# Multi-line power symbol the exact scenario that was reported as broken.
PLACED_POWER_SYMBOL_MULTILINE = """\ # Multi-line power symbol the exact scenario that was reported as broken.
\t(symbol PLACED_POWER_SYMBOL_MULTILINE = """\
\t\t(lib_id "power:VCC") \t(symbol
\t\t(at 365.6 38.1 0) \t\t(lib_id "power:VCC")
\t\t(unit 1) \t\t(at 365.6 38.1 0)
\t\t(in_bom yes) \t\t(unit 1)
\t\t(on_board yes) \t\t(in_bom yes)
\t\t(dnp no) \t\t(on_board yes)
\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030") \t\t(dnp no)
\t\t(property "Reference" "#PWR030" \t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030")
\t\t\t(at 365.6 41.91 0) \t\t(property "Reference" "#PWR030"
\t\t\t(effects \t\t\t(at 365.6 41.91 0)
\t\t\t\t(font \t\t\t(effects
\t\t\t\t\t(size 1.27 1.27) \t\t\t\t(font
\t\t\t\t) \t\t\t\t\t(size 1.27 1.27)
\t\t\t\t(hide yes) \t\t\t\t)
\t\t\t) \t\t\t\t(hide yes)
\t\t) \t\t\t)
\t\t(property "Value" "VCC" \t\t)
\t\t\t(at 365.6 35.56 0) \t\t(property "Value" "VCC"
\t\t\t(effects \t\t\t(at 365.6 35.56 0)
\t\t\t\t(font \t\t\t(effects
\t\t\t\t\t(size 1.27 1.27) \t\t\t\t(font
\t\t\t\t) \t\t\t\t\t(size 1.27 1.27)
\t\t\t) \t\t\t\t)
\t\t) \t\t\t)
\t) \t\t)
""" \t)
"""
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
dest = tmp_path / "test.kicad_sch" def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
src_content = TEMPLATE_SCH.read_text(encoding="utf-8") dest = tmp_path / "test.kicad_sch"
if extra_block: src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
src_content = src_content.rstrip() if extra_block:
if src_content.endswith(")"): src_content = src_content.rstrip()
src_content = src_content[:-1] + "\n" + extra_block + ")\n" if src_content.endswith(")"):
dest.write_text(src_content, encoding="utf-8") src_content = src_content[:-1] + "\n" + extra_block + ")\n"
return dest dest.write_text(src_content, encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# Unit tests regression proof for the old regex vs the new approach # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Unit tests regression proof for the old regex vs the new approach
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteDetectionRegex: @pytest.mark.unit
"""Verify that the new content-string pattern finds blocks in both formats.""" class TestDeleteDetectionRegex:
"""Verify that the new content-string pattern finds blocks in both formats."""
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') 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):
"""Regression: old line-by-line regex must NOT match the multi-line format.""" def test_old_regex_fails_on_multiline_format(self) -> None:
# The old code used re.match on individual lines; simulate that here. """Regression: old line-by-line regex must NOT match the multi-line format."""
lines = PLACED_RESISTOR_MULTILINE.split("\n") # The old code used re.match on individual lines; simulate that here.
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] lines = PLACED_RESISTOR_MULTILINE.split("\n")
assert matches == [], "Old regex should not match multi-line KiCAD format" 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):
"""Old regex did work on single-line (inline) format.""" def test_old_regex_matches_inline_format(self) -> None:
lines = PLACED_RESISTOR_INLINE.split("\n") """Old regex did work on single-line (inline) format."""
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] lines = PLACED_RESISTOR_INLINE.split("\n")
assert len(matches) == 1 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):
"""New content-string pattern must find blocks in multi-line format.""" def test_new_pattern_matches_multiline_format(self) -> None:
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not 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):
"""New content-string pattern also works on inline format.""" def test_new_pattern_matches_inline_format(self) -> None:
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not 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):
"""New pattern must find #PWR030 power symbol in multi-line format.""" def test_new_pattern_matches_power_symbol_multiline(self) -> None:
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not 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):
"""Reference property can be found inside a multi-line block.""" def test_reference_extraction_from_multiline_block(self) -> None:
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') """Reference property can be found inside a multi-line block."""
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
# ---------------------------------------------------------------------------
# Integration tests real file I/O using the handler # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Integration tests real file I/O using the handler
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteSchematicComponentIntegration: @pytest.mark.integration
def _get_handler(self): class TestDeleteSchematicComponentIntegration:
from kicad_interface import KiCADInterface def _get_handler(self) -> Any:
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component
def test_delete_inline_format_succeeds(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None:
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
assert result["success"] is True result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
assert result["deleted_count"] == 1 assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_multiline_format_succeeds(self, tmp_path):
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
assert result["success"] is True result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
assert result["deleted_count"] == 1 assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
"""Regression: #PWR030 multi-line power symbol must be deletable.""" def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) """Regression: #PWR030 multi-line power symbol must be deletable."""
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
assert result["success"] is True result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
assert result["deleted_count"] == 1 assert result["success"] is True
assert result["deleted_count"] == 1
def test_component_absent_after_delete(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) def test_component_absent_after_delete(self, tmp_path: Any) -> None:
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
remaining = sch.read_text(encoding="utf-8") self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
assert '"#PWR030"' not in remaining remaining = sch.read_text(encoding="utf-8")
assert '"#PWR030"' not in remaining
def test_unknown_reference_returns_failure(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None:
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
assert result["success"] is False result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
assert "not found" in result["message"] assert result["success"] is False
assert "not found" in result["message"]
def test_missing_schematic_path_returns_failure(self, tmp_path):
result = self._get_handler()({"reference": "R1"}) def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None:
assert result["success"] is False result = self._get_handler()({"reference": "R1"})
assert result["success"] is False
def test_missing_reference_returns_failure(self, tmp_path):
sch = _make_test_schematic(tmp_path) def test_missing_reference_returns_failure(self, tmp_path: Any) -> None:
result = self._get_handler()({"schematicPath": str(sch)}) sch = _make_test_schematic(tmp_path)
assert result["success"] is False result = self._get_handler()({"schematicPath": str(sch)})
assert result["success"] is False

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,369 +1,370 @@
""" """
Tests for get_schematic_component and edit_schematic_component fieldPositions support. Tests for get_schematic_component and edit_schematic_component fieldPositions support.
""" """
import re import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import pytest
import pytest
# Ensure python/ directory is on path so kicad_interface can be imported
sys.path.insert(0, str(Path(__file__).parent.parent)) # Ensure python/ directory is on path so kicad_interface can be imported
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
# ---------------------------------------------------------------------------
# Helpers shared across tests # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Helpers shared across tests
# ---------------------------------------------------------------------------
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
# Minimal placed-symbol block we can embed into a schematic for testing
PLACED_RESISTOR_BLOCK = """\ # Minimal placed-symbol block we can embed into a schematic for testing
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1) PLACED_RESISTOR_BLOCK = """\
(in_bom yes) (on_board yes) (dnp no) (symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") (in_bom yes) (on_board yes) (dnp no)
(property "Reference" "R1" (at 51.27 47.46 0) (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
(effects (font (size 1.27 1.27))) (property "Reference" "R1" (at 51.27 47.46 0)
) (effects (font (size 1.27 1.27)))
(property "Value" "10k" (at 51.27 52.54 0) )
(effects (font (size 1.27 1.27))) (property "Value" "10k" (at 51.27 52.54 0)
) (effects (font (size 1.27 1.27)))
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) )
(effects (font (size 1.27 1.27)) hide) (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
) (effects (font (size 1.27 1.27)) hide)
(property "Datasheet" "~" (at 50 50 0) )
(effects (font (size 1.27 1.27)) hide) (property "Datasheet" "~" (at 50 50 0)
) (effects (font (size 1.27 1.27)) hide)
) )
""" )
"""
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
dest = tmp_dir / "test.kicad_sch" """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
src_content = TEMPLATE_SCH.read_text(encoding="utf-8") dest = tmp_dir / "test.kicad_sch"
# Insert placed symbol block before the closing paren of the top-level form src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
if extra_block: # Insert placed symbol block before the closing paren of the top-level form
src_content = src_content.rstrip() if extra_block:
if src_content.endswith(")"): src_content = src_content.rstrip()
src_content = src_content[:-1] + "\n" + extra_block + ")\n" if src_content.endswith(")"):
dest.write_text(src_content, encoding="utf-8") src_content = src_content[:-1] + "\n" + extra_block + ")\n"
return dest dest.write_text(src_content, encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# Unit tests regex / parsing logic only (no file I/O, no KiCAD imports) # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Unit tests regex / parsing logic only (no file I/O, no KiCAD imports)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetSchematicComponentParsing: @pytest.mark.unit
"""Unit tests for the regex logic used by _handle_get_schematic_component.""" class TestGetSchematicComponentParsing:
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
def _parse_fields(self, block_text: str) -> dict:
"""Mirrors the regex used in _handle_get_schematic_component.""" def _parse_fields(self, block_text: str) -> dict:
prop_pattern = re.compile( """Mirrors the regex used in _handle_get_schematic_component."""
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' prop_pattern = re.compile(
) r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
fields = {} )
for m in prop_pattern.finditer(block_text): fields = {}
name, value, x, y, angle = ( for m in prop_pattern.finditer(block_text):
m.group(1), name, value, x, y, angle = (
m.group(2), m.group(1),
m.group(3), m.group(2),
m.group(4), m.group(3),
m.group(5), m.group(4),
) m.group(5),
fields[name] = { )
"value": value, fields[name] = {
"x": float(x), "value": value,
"y": float(y), "x": float(x),
"angle": float(angle), "y": float(y),
} "angle": float(angle),
return fields }
return fields
def _parse_comp_pos(self, block_text: str):
"""Mirrors the regex used to extract symbol position.""" def _parse_comp_pos(self, block_text: str) -> Any:
m = re.search( """Mirrors the regex used to extract symbol position."""
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', m = re.search(
block_text, r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
) block_text,
if m: )
return { if m:
"x": float(m.group(1)), return {
"y": float(m.group(2)), "x": float(m.group(1)),
"angle": float(m.group(3)), "y": float(m.group(2)),
} "angle": float(m.group(3)),
return None }
return None
def test_parses_reference_field(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) def test_parses_reference_field(self) -> None:
assert "Reference" in fields fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert fields["Reference"]["value"] == "R1" assert "Reference" in fields
assert fields["Reference"]["x"] == pytest.approx(51.27) assert fields["Reference"]["value"] == "R1"
assert fields["Reference"]["y"] == pytest.approx(47.46) assert fields["Reference"]["x"] == pytest.approx(51.27)
assert fields["Reference"]["angle"] == pytest.approx(0.0) assert fields["Reference"]["y"] == pytest.approx(47.46)
assert fields["Reference"]["angle"] == pytest.approx(0.0)
def test_parses_value_field(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) def test_parses_value_field(self) -> None:
assert "Value" in fields fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert fields["Value"]["value"] == "10k" assert "Value" in fields
assert fields["Value"]["x"] == pytest.approx(51.27) assert fields["Value"]["value"] == "10k"
assert fields["Value"]["y"] == pytest.approx(52.54) assert fields["Value"]["x"] == pytest.approx(51.27)
assert fields["Value"]["y"] == pytest.approx(52.54)
def test_parses_all_four_standard_fields(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) def test_parses_all_four_standard_fields(self) -> None:
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
def test_parses_component_position(self):
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) def test_parses_component_position(self) -> None:
assert pos is not None pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
assert pos["x"] == pytest.approx(50.0) assert pos is not None
assert pos["y"] == pytest.approx(50.0) assert pos["x"] == pytest.approx(50.0)
assert pos["angle"] == pytest.approx(0.0) assert pos["y"] == pytest.approx(50.0)
assert pos["angle"] == pytest.approx(0.0)
def test_field_position_regex_replaces_correctly(self):
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" def test_field_position_regex_replaces_correctly(self) -> None:
field_name = "Reference" """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
new_x, new_y, new_angle = 99.0, 88.0, 0 field_name = "Reference"
block = PLACED_RESISTOR_BLOCK new_x, new_y, new_angle = 99.0, 88.0, 0
block = re.sub( block = PLACED_RESISTOR_BLOCK
r'(\(property\s+"' block = re.sub(
+ re.escape(field_name) r'(\(property\s+"'
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + re.escape(field_name)
rf"\1(at {new_x} {new_y} {new_angle})", + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
block, rf"\1(at {new_x} {new_y} {new_angle})",
) block,
fields = self._parse_fields(block) )
assert fields["Reference"]["x"] == pytest.approx(99.0) fields = self._parse_fields(block)
assert fields["Reference"]["y"] == pytest.approx(88.0) assert fields["Reference"]["x"] == pytest.approx(99.0)
# Value should be unchanged assert fields["Reference"]["y"] == pytest.approx(88.0)
assert fields["Value"]["x"] == pytest.approx(51.27) # Value should be unchanged
assert fields["Value"]["x"] == pytest.approx(51.27)
def test_field_position_regex_preserves_value(self):
"""Replacing position must not change the field value string.""" def test_field_position_regex_preserves_value(self) -> None:
block = PLACED_RESISTOR_BLOCK """Replacing position must not change the field value string."""
block = re.sub( block = PLACED_RESISTOR_BLOCK
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', block = re.sub(
r"\1(at 0.0 0.0 0)", r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
block, r"\1(at 0.0 0.0 0)",
) block,
fields = self._parse_fields(block) )
assert fields["Value"]["value"] == "10k" fields = self._parse_fields(block)
assert fields["Value"]["value"] == "10k"
# ---------------------------------------------------------------------------
# Integration tests real file I/O using the empty.kicad_sch template # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Integration tests real file I/O using the empty.kicad_sch template
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestGetSchematicComponentIntegration: @pytest.mark.integration
"""Integration tests: write a real .kicad_sch and call the handler.""" class TestGetSchematicComponentIntegration:
"""Integration tests: write a real .kicad_sch and call the handler."""
@pytest.fixture
def sch_with_r1(self, tmp_path): @pytest.fixture
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
"""Lazily import KiCADInterface to avoid pcbnew import at collection time.""" def _get_interface(self) -> Any:
from kicad_interface import KiCADInterface """Lazily import KiCADInterface to avoid pcbnew import at collection time."""
from kicad_interface import KiCADInterface
return KiCADInterface()
return KiCADInterface()
def test_get_returns_success(self, sch_with_r1):
iface = self._get_interface() def test_get_returns_success(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert result["success"] is True )
assert result["success"] is True
def test_get_returns_correct_reference(self, sch_with_r1):
iface = self._get_interface() def test_get_returns_correct_reference(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert result["reference"] == "R1" )
assert result["reference"] == "R1"
def test_get_returns_component_position(self, sch_with_r1):
iface = self._get_interface() def test_get_returns_component_position(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert result["position"] is not None )
assert result["position"]["x"] == pytest.approx(50.0) assert result["position"] is not None
assert result["position"]["y"] == pytest.approx(50.0) assert result["position"]["x"] == pytest.approx(50.0)
assert result["position"]["y"] == pytest.approx(50.0)
def test_get_returns_reference_field_position(self, sch_with_r1):
iface = self._get_interface() def test_get_returns_reference_field_position(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
ref_field = result["fields"]["Reference"] )
assert ref_field["value"] == "R1" ref_field = result["fields"]["Reference"]
assert ref_field["x"] == pytest.approx(51.27) assert ref_field["value"] == "R1"
assert ref_field["y"] == pytest.approx(47.46) assert ref_field["x"] == pytest.approx(51.27)
assert ref_field["y"] == pytest.approx(47.46)
def test_get_returns_value_field(self, sch_with_r1):
iface = self._get_interface() def test_get_returns_value_field(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
val_field = result["fields"]["Value"] )
assert val_field["value"] == "10k" val_field = result["fields"]["Value"]
assert val_field["x"] == pytest.approx(51.27) assert val_field["value"] == "10k"
assert val_field["y"] == pytest.approx(52.54) assert val_field["x"] == pytest.approx(51.27)
assert val_field["y"] == pytest.approx(52.54)
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
iface = self._get_interface() def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R99", "schematicPath": str(sch_with_r1),
}, "reference": "R99",
) },
assert result["success"] is False )
assert "R99" in result["message"] assert result["success"] is False
assert "R99" in result["message"]
def test_get_missing_path_returns_failure(self):
iface = self._get_interface() def test_get_missing_path_returns_failure(self) -> None:
result = iface.handle_command( iface = self._get_interface()
"get_schematic_component", result = iface.handle_command(
{ "get_schematic_component",
"reference": "R1", {
}, "reference": "R1",
) },
assert result["success"] is False )
assert result["success"] is False
@pytest.mark.integration
class TestEditSchematicComponentFieldPositions: @pytest.mark.integration
"""Integration tests for the new fieldPositions parameter.""" class TestEditSchematicComponentFieldPositions:
"""Integration tests for the new fieldPositions parameter."""
@pytest.fixture
def sch_with_r1(self, tmp_path): @pytest.fixture
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
from kicad_interface import KiCADInterface def _get_interface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
return KiCADInterface()
def test_reposition_reference_label(self, sch_with_r1):
iface = self._get_interface() def test_reposition_reference_label(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"edit_schematic_component", result = iface.handle_command(
{ "edit_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, "reference": "R1",
}, "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
) },
assert result["success"] is True )
assert result["success"] is True
# Verify the position was actually written
get_result = iface.handle_command( # Verify the position was actually written
"get_schematic_component", get_result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) )
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
def test_reposition_does_not_change_value(self, sch_with_r1):
iface = self._get_interface() def test_reposition_does_not_change_value(self, sch_with_r1: Any) -> None:
iface.handle_command( iface = self._get_interface()
"edit_schematic_component", iface.handle_command(
{ "edit_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, "reference": "R1",
}, "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
) },
get_result = iface.handle_command( )
"get_schematic_component", get_result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
# Value field position must be unchanged )
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) # Value field position must be unchanged
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
def test_reposition_multiple_fields(self, sch_with_r1):
iface = self._get_interface() def test_reposition_multiple_fields(self, sch_with_r1: Any) -> None:
result = iface.handle_command( iface = self._get_interface()
"edit_schematic_component", result = iface.handle_command(
{ "edit_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
"fieldPositions": { "reference": "R1",
"Reference": {"x": 10.0, "y": 20.0, "angle": 0}, "fieldPositions": {
"Value": {"x": 10.0, "y": 30.0, "angle": 0}, "Reference": {"x": 10.0, "y": 20.0, "angle": 0},
}, "Value": {"x": 10.0, "y": 30.0, "angle": 0},
}, },
) },
assert result["success"] is True )
assert result["success"] is True
get_result = iface.handle_command(
"get_schematic_component", get_result = iface.handle_command(
{ "get_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) )
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
"""fieldPositions without value/footprint/newReference should succeed.""" def test_fieldpositions_alone_is_valid(self, sch_with_r1: Any) -> None:
iface = self._get_interface() """fieldPositions without value/footprint/newReference should succeed."""
result = iface.handle_command( iface = self._get_interface()
"edit_schematic_component", result = iface.handle_command(
{ "edit_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, "reference": "R1",
}, "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
) },
assert result["success"] is True )
assert result["success"] is True
def test_no_params_still_fails(self, sch_with_r1):
"""Providing no update params should return an error.""" def test_no_params_still_fails(self, sch_with_r1: Any) -> None:
iface = self._get_interface() """Providing no update params should return an error."""
result = iface.handle_command( iface = self._get_interface()
"edit_schematic_component", result = iface.handle_command(
{ "edit_schematic_component",
"schematicPath": str(sch_with_r1), {
"reference": "R1", "schematicPath": str(sch_with_r1),
}, "reference": "R1",
) },
assert result["success"] is False )
assert result["success"] is False

View File

@@ -1,476 +1,477 @@
""" """
Tests for schematic inspection and editing tools added in the schematic_tools branch. Tests for schematic inspection and editing tools added in the schematic_tools branch.
Covers: Covers:
- WireManager.delete_wire (unit + integration) - WireManager.delete_wire (unit + integration)
- WireManager.delete_label (unit + integration) - WireManager.delete_label (unit + integration)
- Handler-level parameter validation for the 11 new KiCADInterface handlers - Handler-level parameter validation for the 11 new KiCADInterface handlers
(tested by calling _handle_* methods on a lightweight stub that avoids (tested by calling _handle_* methods on a lightweight stub that avoids
importing the full kicad_interface module). importing the full kicad_interface module).
""" """
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import sexpdata import pytest
import sexpdata
# ---------------------------------------------------------------------------
# Helpers # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Helpers
# ---------------------------------------------------------------------------
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# Minimal schematic content used by integration tests
_WIRE_SCH = """\ # Minimal schematic content used by integration tests
(kicad_sch (version 20250114) (generator "test") _WIRE_SCH = """\
(uuid aaaaaaaa-0000-0000-0000-000000000000) (kicad_sch (version 20250114) (generator "test")
(paper "A4") (uuid aaaaaaaa-0000-0000-0000-000000000000)
(wire (pts (xy 10 20) (xy 30 20)) (paper "A4")
(stroke (width 0) (type default)) (wire (pts (xy 10 20) (xy 30 20))
(uuid bbbbbbbb-0000-0000-0000-000000000001) (stroke (width 0) (type default))
) (uuid bbbbbbbb-0000-0000-0000-000000000001)
(label "VCC" (at 50 50 0) )
(effects (font (size 1.27 1.27)) (justify left bottom)) (label "VCC" (at 50 50 0)
(uuid cccccccc-0000-0000-0000-000000000002) (effects (font (size 1.27 1.27)) (justify left bottom))
) (uuid cccccccc-0000-0000-0000-000000000002)
(sheet_instances (path "/" (page "1"))) )
) (sheet_instances (path "/" (page "1")))
""" )
"""
def _write_temp_sch(content: str) -> Path:
"""Write *content* to a temp file and return its Path.""" def _write_temp_sch(content: str) -> Path:
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") """Write *content* to a temp file and return its Path."""
tmp.write(content) tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
tmp.close() tmp.write(content)
return Path(tmp.name) tmp.close()
return Path(tmp.name)
# ---------------------------------------------------------------------------
# Unit tests WireManager.delete_wire # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Unit tests WireManager.delete_wire
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteWireUnit: @pytest.mark.unit
"""Unit-level tests for WireManager.delete_wire.""" class TestDeleteWireUnit:
"""Unit-level tests for WireManager.delete_wire."""
def setup_method(self):
from commands.wire_manager import WireManager def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
assert result is False 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):
sch = tmp_path / "test.kicad_sch" def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None:
shutil.copy(EMPTY_SCH, sch) sch = tmp_path / "test.kicad_sch"
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) shutil.copy(EMPTY_SCH, sch)
assert result is False result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
assert result is False
def test_tolerance_argument_accepted(self, tmp_path):
"""Ensure the tolerance kwarg doesn't raise a TypeError.""" def test_tolerance_argument_accepted(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" """Ensure the tolerance kwarg doesn't raise a TypeError."""
shutil.copy(EMPTY_SCH, sch) sch = tmp_path / "test.kicad_sch"
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1) shutil.copy(EMPTY_SCH, sch)
assert result is False # no wire in empty sch result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
assert result is False # no wire in empty sch
# ---------------------------------------------------------------------------
# Unit tests WireManager.delete_label # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Unit tests WireManager.delete_label
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteLabelUnit: @pytest.mark.unit
"""Unit-level tests for WireManager.delete_label.""" class TestDeleteLabelUnit:
"""Unit-level tests for WireManager.delete_label."""
def setup_method(self):
from commands.wire_manager import WireManager def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
assert result is False result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
assert result is False
def test_missing_label_returns_false(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_missing_label_returns_false(self, tmp_path: Any) -> None:
shutil.copy(EMPTY_SCH, sch) sch = tmp_path / "test.kicad_sch"
result = self.WireManager.delete_label(sch, "NONEXISTENT") shutil.copy(EMPTY_SCH, sch)
assert result is False result = self.WireManager.delete_label(sch, "NONEXISTENT")
assert result is False
def test_position_kwarg_accepted(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_position_kwarg_accepted(self, tmp_path: Any) -> None:
shutil.copy(EMPTY_SCH, sch) sch = tmp_path / "test.kicad_sch"
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) shutil.copy(EMPTY_SCH, sch)
assert result is False result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
assert result is False
# ---------------------------------------------------------------------------
# Integration tests WireManager.delete_wire # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Integration tests WireManager.delete_wire
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteWireIntegration: @pytest.mark.integration
"""Integration tests that read/write real .kicad_sch files.""" class TestDeleteWireIntegration:
"""Integration tests that read/write real .kicad_sch files."""
def setup_method(self):
from commands.wire_manager import WireManager def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
self.WireManager = WireManager
def test_exact_match_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_exact_match_deletes_wire(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
assert result is True
data = sexpdata.loads(sch.read_text(encoding="utf-8")) assert result is True
wire_items = [ data = sexpdata.loads(sch.read_text(encoding="utf-8"))
item wire_items = [
for item in data item
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") for item in data
] if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
assert wire_items == [], "Wire should have been removed from the file" ]
assert wire_items == [], "Wire should have been removed from the file"
def test_reverse_direction_match_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
# Pass end/start swapped should still match
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0]) # Pass end/start swapped should still match
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
assert result is True
assert result is True
def test_within_tolerance_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) # Coordinates differ by 0.3 mm — within default tolerance of 0.5
assert result is True result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
assert result is True
def test_outside_tolerance_no_delete(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
# tolerance=0.0 means exact float equality — may still match on most result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
# platforms, but the key thing is that a *distant* miss is rejected # tolerance=0.0 means exact float equality — may still match on most
sch2 = tmp_path / "test2.kicad_sch" # platforms, but the key thing is that a *distant* miss is rejected
sch2.write_text(_WIRE_SCH, encoding="utf-8") sch2 = tmp_path / "test2.kicad_sch"
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) sch2.write_text(_WIRE_SCH, encoding="utf-8")
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance" 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):
sch = tmp_path / "test.kicad_sch" def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) sch.write_text(_WIRE_SCH, encoding="utf-8")
# Must parse without exception self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
sexpdata.loads(sch.read_text(encoding="utf-8")) # Must parse without exception
sexpdata.loads(sch.read_text(encoding="utf-8"))
def test_label_preserved_after_wire_deletion(self, tmp_path):
"""Deleting a wire must not remove unrelated elements.""" def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" """Deleting a wire must not remove unrelated elements."""
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) sch.write_text(_WIRE_SCH, encoding="utf-8")
data = sexpdata.loads(sch.read_text(encoding="utf-8")) self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
labels = [ data = sexpdata.loads(sch.read_text(encoding="utf-8"))
item labels = [
for item in data item
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") for item in data
] if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
assert len(labels) == 1 ]
assert len(labels) == 1
# ---------------------------------------------------------------------------
# Integration tests WireManager.delete_label # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Integration tests WireManager.delete_label
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteLabelIntegration: @pytest.mark.integration
def setup_method(self): class TestDeleteLabelIntegration:
from commands.wire_manager import WireManager def setup_method(self) -> None:
from commands.wire_manager import WireManager
self.WireManager = WireManager
self.WireManager = WireManager
def test_deletes_label_by_name(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_deletes_label_by_name(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC")
result = self.WireManager.delete_label(sch, "VCC")
assert result is True
data = sexpdata.loads(sch.read_text(encoding="utf-8")) assert result is True
labels = [ data = sexpdata.loads(sch.read_text(encoding="utf-8"))
item labels = [
for item in data item
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") for item in data
] if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
assert labels == [], "Label should have been removed" ]
assert labels == [], "Label should have been removed"
def test_deletes_label_with_matching_position(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") 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 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):
sch = tmp_path / "test.kicad_sch" def test_position_mismatch_no_delete(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") 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 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):
sch = tmp_path / "test.kicad_sch" def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
self.WireManager.delete_label(sch, "VCC") sch.write_text(_WIRE_SCH, encoding="utf-8")
data = sexpdata.loads(sch.read_text(encoding="utf-8")) self.WireManager.delete_label(sch, "VCC")
wires = [ data = sexpdata.loads(sch.read_text(encoding="utf-8"))
item wires = [
for item in data item
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") for item in data
] if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
assert len(wires) == 1 ]
assert len(wires) == 1
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
sch = tmp_path / "test.kicad_sch" def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch = tmp_path / "test.kicad_sch"
self.WireManager.delete_label(sch, "VCC") sch.write_text(_WIRE_SCH, encoding="utf-8")
sexpdata.loads(sch.read_text(encoding="utf-8")) self.WireManager.delete_label(sch, "VCC")
sexpdata.loads(sch.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Unit tests handler parameter validation (via lightweight handler stubs) # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # Unit tests handler parameter validation (via lightweight handler stubs)
# We test the validation logic of the new _handle_* methods without importing # ---------------------------------------------------------------------------
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit). # We test the validation logic of the new _handle_* methods without importing
# Each handler is extracted as a standalone function for testing. # the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
# 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. Return the unbound handler method from kicad_interface by importing only
that method's source via exec, bypassing module-level side effects.
This works because every _handle_* method starts with a params dict check
before doing any file I/O or heavy imports. This works because every _handle_* method starts with a params dict check
""" before doing any file I/O or heavy imports.
import importlib.util """
import types import importlib.util
import types
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
stubs = {} # We monkey-patch sys.modules to avoid pcbnew/skip side effects
for mod in ("pcbnew", "skip", "commands.schematic"): stubs = {}
stubs[mod] = types.ModuleType(mod) for mod in ("pcbnew", "skip", "commands.schematic"):
stubs[mod] = types.ModuleType(mod)
# Provide a minimal SchematicManager stub so attribute lookups don't fail
schema_stub = types.ModuleType("commands.schematic") # Provide a minimal SchematicManager stub so attribute lookups don't fail
schema_stub.SchematicManager = MagicMock() schema_stub = types.ModuleType("commands.schematic")
stubs["commands.schematic"] = schema_stub schema_stub.SchematicManager = MagicMock()
stubs["commands.schematic"] = schema_stub
with patch.dict("sys.modules", stubs):
# Import just the handlers module in isolation isn't feasible for with patch.dict("sys.modules", stubs):
# kicad_interface.py (module-level sys.exit). Instead, we directly # Import just the handlers module in isolation isn't feasible for
# call the method on a MagicMock instance, binding the real function. # kicad_interface.py (module-level sys.exit). Instead, we directly
pass # call the method on a MagicMock instance, binding the real function.
pass
return None # Not used; see TestHandlerParamValidation below
return None # Not used; see TestHandlerParamValidation below
@pytest.mark.unit
class TestHandlerParamValidation: @pytest.mark.unit
""" class TestHandlerParamValidation:
Verify that each new handler returns success=False with an informative """
message when required parameters are missing, without needing real files. Verify that each new handler returns success=False with an informative
message when required parameters are missing, without needing real files.
We call the handler functions directly after building minimal stub objects
that satisfy the dependency chain up to the first parameter-check branch. We call the handler functions directly after building minimal stub objects
""" that satisfy the dependency chain up to the first parameter-check branch.
"""
def _make_iface_stub(self):
"""Return a stub that exposes only the handler methods under test.""" def _make_iface_stub(self) -> Any:
import importlib """Return a stub that exposes only the handler methods under test."""
import types import importlib
import types
# Build a minimal namespace that satisfies the imports inside each handler
stub_mod = types.ModuleType("_handler_stubs") # Build a minimal namespace that satisfies the imports inside each handler
stub_mod.os = __import__("os") stub_mod = types.ModuleType("_handler_stubs")
stub_mod.os = __import__("os")
class _Stub:
pass class _Stub:
pass
return _Stub()
return _Stub()
# --- delete_schematic_wire ---
# --- delete_schematic_wire ---
def test_delete_wire_missing_schematic_path(self):
from commands.wire_manager import WireManager def test_delete_wire_missing_schematic_path(self) -> None:
from commands.wire_manager import WireManager
with patch.object(WireManager, "delete_wire", return_value=False):
# Simulate the handler logic inline with patch.object(WireManager, "delete_wire", return_value=False):
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}} # Simulate the handler logic inline
schematic_path = params.get("schematicPath") params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
assert schematic_path is None schematic_path = params.get("schematicPath")
# Handler should short-circuit before calling WireManager assert schematic_path is None
result = ( # Handler should short-circuit before calling WireManager
{"success": False, "message": "schematicPath is required"} result: dict[str, Any] = (
if not schematic_path {"success": False, "message": "schematicPath is required"}
else {} if not schematic_path
) else {}
assert result["success"] is False )
assert "schematicPath" in result["message"] assert result["success"] is False
assert "schematicPath" in result["message"]
# --- delete_schematic_net_label ---
# --- delete_schematic_net_label ---
def test_delete_label_missing_net_name(self):
params = {"schematicPath": "/some/file.kicad_sch"} def test_delete_label_missing_net_name(self) -> None:
net_name = params.get("netName") params = {"schematicPath": "/some/file.kicad_sch"}
result = ( net_name = params.get("netName")
{ result = (
"success": False, {
"message": "schematicPath and netName are required", "success": False,
} "message": "schematicPath and netName are required",
if not net_name }
else {} if not net_name
) else {}
assert result["success"] is False )
assert result["success"] is False
def test_delete_label_missing_schematic_path(self):
params = {"netName": "VCC"} def test_delete_label_missing_schematic_path(self) -> None:
schematic_path = params.get("schematicPath") params = {"netName": "VCC"}
result = ( schematic_path = params.get("schematicPath")
{ result = (
"success": False, {
"message": "schematicPath and netName are required", "success": False,
} "message": "schematicPath and netName are required",
if not schematic_path }
else {} if not schematic_path
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- list_schematic_components ---
# --- list_schematic_components ---
def test_list_components_missing_path(self):
params = {} def test_list_components_missing_path(self) -> None:
schematic_path = params.get("schematicPath") params = {}
result = ( schematic_path = params.get("schematicPath")
{"success": False, "message": "schematicPath is required"} if not schematic_path else {} result = (
) {"success": False, "message": "schematicPath is required"} if not schematic_path else {}
assert result["success"] is False )
assert result["success"] is False
# --- list_schematic_nets ---
# --- list_schematic_nets ---
def test_list_nets_missing_path(self):
params = {} def test_list_nets_missing_path(self) -> None:
result = ( params = {}
{"success": False, "message": "schematicPath is required"} result = (
if not params.get("schematicPath") {"success": False, "message": "schematicPath is required"}
else {} if not params.get("schematicPath")
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- list_schematic_wires ---
# --- list_schematic_wires ---
def test_list_wires_missing_path(self):
params = {} def test_list_wires_missing_path(self) -> None:
result = ( params = {}
{"success": False, "message": "schematicPath is required"} result = (
if not params.get("schematicPath") {"success": False, "message": "schematicPath is required"}
else {} if not params.get("schematicPath")
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- list_schematic_labels ---
# --- list_schematic_labels ---
def test_list_labels_missing_path(self):
params = {} def test_list_labels_missing_path(self) -> None:
result = ( params = {}
{"success": False, "message": "schematicPath is required"} result = (
if not params.get("schematicPath") {"success": False, "message": "schematicPath is required"}
else {} if not params.get("schematicPath")
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- move_schematic_component ---
# --- move_schematic_component ---
def test_move_component_missing_reference(self):
params = { def test_move_component_missing_reference(self) -> None:
"schematicPath": "/some/file.kicad_sch", params = {
"position": {"x": 10, "y": 20}, "schematicPath": "/some/file.kicad_sch",
} "position": {"x": 10, "y": 20},
result = ( }
{ result = (
"success": False, {
"message": "schematicPath and reference are required", "success": False,
} "message": "schematicPath and reference are required",
if not params.get("reference") }
else {} if not params.get("reference")
) else {}
assert result["success"] is False )
assert result["success"] is False
def test_move_component_missing_position(self):
params = { def test_move_component_missing_position(self) -> None:
"schematicPath": "/some/file.kicad_sch", params = {
"reference": "R1", "schematicPath": "/some/file.kicad_sch",
"position": {}, "reference": "R1",
} "position": {},
new_x = params["position"].get("x") }
new_y = params["position"].get("y") new_x = params["position"].get("x")
result = ( new_y = params["position"].get("y")
{"success": False, "message": "position with x and y is required"} result = (
if new_x is None or new_y is None {"success": False, "message": "position with x and y is required"}
else {} if new_x is None or new_y is None
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- rotate_schematic_component ---
# --- rotate_schematic_component ---
def test_rotate_component_missing_reference(self):
params = {"schematicPath": "/some/file.kicad_sch"} def test_rotate_component_missing_reference(self) -> None:
result = ( params = {"schematicPath": "/some/file.kicad_sch"}
{ result = (
"success": False, {
"message": "schematicPath and reference are required", "success": False,
} "message": "schematicPath and reference are required",
if not params.get("reference") }
else {} if not params.get("reference")
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- annotate_schematic ---
# --- annotate_schematic ---
def test_annotate_missing_path(self):
params = {} def test_annotate_missing_path(self) -> None:
result = ( params = {}
{"success": False, "message": "schematicPath is required"} result = (
if not params.get("schematicPath") {"success": False, "message": "schematicPath is required"}
else {} if not params.get("schematicPath")
) else {}
assert result["success"] is False )
assert result["success"] is False
# --- export_schematic_svg ---
# --- export_schematic_svg ---
def test_export_svg_missing_output_path(self):
params = {"schematicPath": "/some/file.kicad_sch"} def test_export_svg_missing_output_path(self) -> None:
result = ( params = {"schematicPath": "/some/file.kicad_sch"}
{ result = (
"success": False, {
"message": "schematicPath and outputPath are required", "success": False,
} "message": "schematicPath and outputPath are required",
if not params.get("outputPath") }
else {} if not params.get("outputPath")
) else {}
assert result["success"] is False )
assert result["success"] is False

View File

@@ -1,332 +1,332 @@
""" """
Tests for the wire_connectivity module and the get_wire_connections handler. Tests for the wire_connectivity module and the get_wire_connections handler.
Covers: Covers:
- Schema shape (TestSchema) - Schema shape (TestSchema)
- Handler dispatch registration (TestHandlerDispatch) - Handler dispatch registration (TestHandlerDispatch)
- Parameter validation in the handler (TestHandlerParamValidation) - Parameter validation in the handler (TestHandlerParamValidation)
- Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires,
get_wire_connections (TestCoreLogic) get_wire_connections (TestCoreLogic)
""" """
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
# Ensure the python package root is importable # Ensure the python package root is importable
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Module under test # Module under test
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from commands.wire_connectivity import ( from commands.wire_connectivity import (
_build_adjacency, _build_adjacency,
_find_connected_wires, _find_connected_wires,
_parse_wires, _parse_wires,
_to_iu, _to_iu,
get_wire_connections, get_wire_connections,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers to build minimal mock schematic objects # Helpers to build minimal mock schematic objects
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_point(x: float, y: float) -> MagicMock: def _make_point(x: float, y: float) -> MagicMock:
pt = MagicMock() pt = MagicMock()
pt.value = [x, y] pt.value = [x, y]
return pt return pt
def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
wire = MagicMock() wire = MagicMock()
wire.pts = MagicMock() wire.pts = MagicMock()
wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)]
return wire return wire
def _make_schematic(*wires) -> MagicMock: def _make_schematic(*wires: Any) -> MagicMock:
sch = MagicMock() sch = MagicMock()
sch.wire = list(wires) sch.wire = list(wires)
# No net labels, no symbols by default # No net labels, no symbols by default
del sch.label # make hasattr(..., "label") return False del sch.label # make hasattr(..., "label") return False
del sch.symbol # make hasattr(..., "symbol") return False del sch.symbol # make hasattr(..., "symbol") return False
return sch return sch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestSchema # TestSchema
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.unit @pytest.mark.unit
class TestSchema: class TestSchema:
"""Verify the get_wire_connections tool schema is present and well-formed.""" """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 from schemas.tool_schemas import TOOL_SCHEMAS
assert "get_wire_connections" in 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 from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"] schema = TOOL_SCHEMAS["get_wire_connections"]
required = schema["inputSchema"]["required"] required = schema["inputSchema"]["required"]
assert "schematicPath" in required assert "schematicPath" in required
assert "x" in required assert "x" in required
assert "y" 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 from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"] schema = TOOL_SCHEMAS["get_wire_connections"]
assert schema.get("title") assert schema.get("title")
assert schema.get("description") assert schema.get("description")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestHandlerDispatch # TestHandlerDispatch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.unit @pytest.mark.unit
class TestHandlerDispatch: class TestHandlerDispatch:
"""Verify the handler is wired into KiCadInterface.command_routes.""" """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 # Import lazily to avoid heavy side-effects at collection time
with patch("kicad_interface.USE_IPC_BACKEND", False): with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
iface.board = None iface.board = None
iface.project_filename = None iface.project_filename = None
iface.use_ipc = False iface.use_ipc = False
iface.ipc_backend = MagicMock() iface.ipc_backend = MagicMock()
iface.ipc_board_api = None iface.ipc_board_api = None
iface.footprint_library = MagicMock() iface.footprint_library = MagicMock()
iface.project_commands = MagicMock() iface.project_commands = MagicMock()
iface.board_commands = MagicMock() iface.board_commands = MagicMock()
iface.component_commands = MagicMock() iface.component_commands = MagicMock()
iface.routing_commands = MagicMock() iface.routing_commands = MagicMock()
# Build routes only (avoid full __init__ side-effects) # Build routes only (avoid full __init__ side-effects)
# The routes dict is built in __init__; we call it directly. # 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 "get_wire_connections" in iface.command_routes
assert callable(iface.command_routes["get_wire_connections"]) assert callable(iface.command_routes["get_wire_connections"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestHandlerParamValidation # TestHandlerParamValidation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.unit @pytest.mark.unit
class TestHandlerParamValidation: class TestHandlerParamValidation:
"""Handler returns error responses for bad or missing parameters.""" """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.""" """Return a bound _handle_get_wire_connections without full init."""
with patch("kicad_interface.USE_IPC_BACKEND", False): with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_get_wire_connections return iface._handle_get_wire_connections
def test_missing_schematic_path(self): def test_missing_schematic_path(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"x": 1.0, "y": 2.0}) result = handler({"x": 1.0, "y": 2.0})
assert result["success"] is False assert result["success"] is False
assert "schematicPath" in result["message"] or "Missing" in result["message"] 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() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
assert result["success"] is False assert result["success"] is False
def test_missing_y(self): def test_missing_y(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
assert result["success"] is False assert result["success"] is False
def test_non_numeric_x(self): def test_non_numeric_x(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
assert result["success"] is False assert result["success"] is False
assert "numeric" in result["message"].lower() or "x" in result["message"] 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() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
assert result["success"] is False assert result["success"] is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestCoreLogic # TestCoreLogic
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_IU = 10_000 # IU per mm _IU = 10_000 # IU per mm
@pytest.mark.unit @pytest.mark.unit
class TestCoreLogic: class TestCoreLogic:
"""Unit tests for the pure-logic functions in wire_connectivity.""" """Unit tests for the pure-logic functions in wire_connectivity."""
# --- _to_iu --- # --- _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) 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) 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) 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) assert _to_iu(-1.0, -2.0) == (-10_000, -20_000)
# --- _parse_wires --- # --- _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)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = _parse_wires(sch) result = _parse_wires(sch)
assert len(result) == 1 assert len(result) == 1
assert result[0] == [(0, 0), (10_000, 0)] 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 = MagicMock()
sch.wire = [] sch.wire = []
assert _parse_wires(sch) == [] assert _parse_wires(sch) == []
def test_parse_wires_multiple_wires(self): def test_parse_wires_multiple_wires(self) -> None:
sch = _make_schematic( sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0), _make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0), _make_wire(1.0, 0.0, 2.0, 0.0),
) )
assert len(_parse_wires(sch)) == 2 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 bad_wire = MagicMock(spec=[]) # no `pts` attribute
sch = MagicMock() sch = MagicMock()
sch.wire = [bad_wire] sch.wire = [bad_wire]
assert _parse_wires(sch) == [] assert _parse_wires(sch) == []
# --- _build_adjacency --- # --- _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) # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0)
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)], [(10_000, 0), (20_000, 0)],
] ]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
assert 1 in adjacency[0] assert 1 in adjacency[0]
assert 0 in adjacency[1] assert 0 in adjacency[1]
def test_build_adjacency_two_disconnected_wires(self): def test_build_adjacency_two_disconnected_wires(self) -> None:
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(20_000, 0), (30_000, 0)], [(20_000, 0), (30_000, 0)],
] ]
adjacency, _ = _build_adjacency(wires) adjacency, _ = _build_adjacency(wires)
assert adjacency[0] == set() assert adjacency[0] == set()
assert adjacency[1] == 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 = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)], [(10_000, 0), (20_000, 0)],
] ]
_, iu_to_wires = _build_adjacency(wires) _, iu_to_wires = _build_adjacency(wires)
assert iu_to_wires[(10_000, 0)] == {0, 1} assert iu_to_wires[(10_000, 0)] == {0, 1}
assert iu_to_wires[(0, 0)] == {0} 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) # All three wires meet at (10,000, 0)
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)], [(10_000, 0), (20_000, 0)],
[(10_000, 0), (10_000, 10_000)], [(10_000, 0), (10_000, 10_000)],
] ]
adjacency, _ = _build_adjacency(wires) adjacency, _ = _build_adjacency(wires)
assert adjacency[0] == {1, 2} assert adjacency[0] == {1, 2}
assert adjacency[1] == {0, 2} assert adjacency[1] == {0, 2}
assert adjacency[2] == {0, 1} assert adjacency[2] == {0, 1}
# --- _find_connected_wires --- # --- _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)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
assert visited is None assert visited is None
assert net_points 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)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
assert visited == {0} assert visited == {0}
assert (0, 0) in net_points assert (0, 0) in net_points
assert (10_000, 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 # Three wires in a chain: A-B-C-D
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)], [(10_000, 0), (20_000, 0)],
[(20_000, 0), (30_000, 0)], [(20_000, 0), (30_000, 0)],
] ]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
assert visited == {0, 1, 2} 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 # Two disconnected segments; query on segment 0 should not reach segment 1
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(20_000, 0), (30_000, 0)], [(20_000, 0), (30_000, 0)],
] ]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
assert visited == {0} assert visited == {0}
# --- get_wire_connections (integration of internal functions) --- # --- 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 = MagicMock()
sch.wire = [] sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result == {"pins": [], "wires": []} 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)) 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) result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
assert result is None 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)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
# Prevent _find_pins_on_net from iterating symbols # Prevent _find_pins_on_net from iterating symbols
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None assert result is not None
assert result["pins"] == [] assert result["pins"] == []
assert len(result["wires"]) == 1 assert len(result["wires"]) == 1
wire = result["wires"][0] wire = result["wires"][0]
assert wire["start"] == {"x": 0.0, "y": 0.0} assert wire["start"] == {"x": 0.0, "y": 0.0}
assert wire["end"] == {"x": 1.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( sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0), _make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0), _make_wire(1.0, 0.0, 2.0, 0.0),
) )
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None assert result is not None
assert len(result["wires"]) == 2 assert len(result["wires"]) == 2

File diff suppressed because it is too large Load Diff

View File

@@ -157,9 +157,9 @@ class KiCADProcessManager:
timeout=5 if system == "Windows" else None, timeout=5 if system == "Windows" else None,
) )
if result.returncode == 0: if result.returncode == 0:
path = result.stdout.strip().split("\n")[0] exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {path}") logger.info(f"Found KiCAD executable: {exe_path}")
return Path(path) return Path(exe_path)
# Platform-specific default paths # Platform-specific default paths
if system == "Linux": if system == "Linux":