diff --git a/kicad_mcp/__init__.py b/kicad_mcp/__init__.py index e174ef5..eaa828d 100644 --- a/kicad_mcp/__init__.py +++ b/kicad_mcp/__init__.py @@ -4,9 +4,9 @@ KiCad MCP Server. A Model Context Protocol (MCP) server for KiCad electronic design automation (EDA) files. """ -from .server import * from .config import * from .context import * +from .server import * __version__ = "0.1.0" __author__ = "Lama Al Rajih" diff --git a/kicad_mcp/context.py b/kicad_mcp/context.py index 4795661..d15ff46 100644 --- a/kicad_mcp/context.py +++ b/kicad_mcp/context.py @@ -2,11 +2,11 @@ Lifespan context management for KiCad MCP Server. """ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import AsyncIterator, Dict, Any import logging # Import logging -import os # Added for PID +from typing import Any from mcp.server.fastmcp import FastMCP @@ -21,7 +21,7 @@ class KiCadAppContext: kicad_modules_available: bool # Optional cache for expensive operations - cache: Dict[str, Any] + cache: dict[str, Any] @asynccontextmanager @@ -42,7 +42,7 @@ async def kicad_lifespan( Yields: KiCadAppContext: A typed context object shared across all handlers """ - logging.info(f"Starting KiCad MCP server initialization") + logging.info("Starting KiCad MCP server initialization") # Resources initialization - Python path setup removed # print("Setting up KiCad Python modules") @@ -52,7 +52,7 @@ async def kicad_lifespan( ) # Create in-memory cache for expensive operations - cache: Dict[str, Any] = {} + cache: dict[str, Any] = {} # Initialize any other resources that need cleanup later created_temp_dirs = [] # Assuming this is managed elsewhere or not needed for now @@ -67,14 +67,14 @@ async def kicad_lifespan( # print(f"Failed to preload some KiCad modules: {str(e)}") # Yield the context to the server - server runs during this time - logging.info(f"KiCad MCP server initialization complete") + logging.info("KiCad MCP server initialization complete") yield KiCadAppContext( kicad_modules_available=kicad_modules_available, # Pass the flag through cache=cache, ) finally: # Clean up resources when server shuts down - logging.info(f"Shutting down KiCad MCP server") + logging.info("Shutting down KiCad MCP server") # Clear the cache if cache: @@ -91,4 +91,4 @@ async def kicad_lifespan( except Exception as e: logging.error(f"Error cleaning up temporary directory {temp_dir}: {str(e)}") - logging.info(f"KiCad MCP server shutdown complete") + logging.info("KiCad MCP server shutdown complete") diff --git a/kicad_mcp/resources/bom_resources.py b/kicad_mcp/resources/bom_resources.py index 22fad6f..6fd5639 100644 --- a/kicad_mcp/resources/bom_resources.py +++ b/kicad_mcp/resources/bom_resources.py @@ -2,17 +2,15 @@ Bill of Materials (BOM) resources for KiCad projects. """ -import os -import csv import json -import pandas as pd -from typing import Dict, List, Any, Optional -from mcp.server.fastmcp import FastMCP +import os -from kicad_mcp.utils.file_utils import get_project_files +from mcp.server.fastmcp import FastMCP +import pandas as pd # Import the helper functions from bom_tools.py to avoid code duplication -from kicad_mcp.tools.bom_tools import parse_bom_file, analyze_bom_data +from kicad_mcp.tools.bom_tools import analyze_bom_data, parse_bom_file +from kicad_mcp.utils.file_utils import get_project_files def register_bom_resources(mcp: FastMCP) -> None: @@ -211,7 +209,7 @@ def register_bom_resources(mcp: FastMCP) -> None: try: # If it's already a CSV, just return its contents if file_path.lower().endswith(".csv"): - with open(file_path, "r", encoding="utf-8-sig") as f: + with open(file_path, encoding="utf-8-sig") as f: return f.read() # Otherwise, try to parse and convert to CSV @@ -264,7 +262,7 @@ def register_bom_resources(mcp: FastMCP) -> None: for file_type, file_path in bom_files.items(): # If it's already JSON, parse it directly if file_path.lower().endswith(".json"): - with open(file_path, "r") as f: + with open(file_path) as f: try: result["bom_files"][file_type] = json.load(f) continue diff --git a/kicad_mcp/resources/drc_resources.py b/kicad_mcp/resources/drc_resources.py index 0aeff6d..12e38e0 100644 --- a/kicad_mcp/resources/drc_resources.py +++ b/kicad_mcp/resources/drc_resources.py @@ -6,9 +6,9 @@ import os from mcp.server.fastmcp import FastMCP -from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.drc_history import get_drc_history from kicad_mcp.tools.drc_impl.cli_drc import run_drc_via_cli +from kicad_mcp.utils.drc_history import get_drc_history +from kicad_mcp.utils.file_utils import get_project_files def register_drc_resources(mcp: FastMCP) -> None: @@ -178,7 +178,7 @@ def register_drc_resources(mcp: FastMCP) -> None: # Add summary total_violations = drc_results.get("total_violations", 0) - report += f"## Summary\n\n" + report += "## Summary\n\n" if total_violations == 0: report += "✅ **No DRC violations found**\n\n" diff --git a/kicad_mcp/resources/files.py b/kicad_mcp/resources/files.py index a5d9e9a..695ae4d 100644 --- a/kicad_mcp/resources/files.py +++ b/kicad_mcp/resources/files.py @@ -3,6 +3,7 @@ File content resources for KiCad files. """ import os + from mcp.server.fastmcp import FastMCP @@ -22,7 +23,7 @@ def register_file_resources(mcp: FastMCP) -> None: # KiCad schematic files are in S-expression format (not JSON) # This is a basic extraction of text-based information try: - with open(schematic_path, "r") as f: + with open(schematic_path) as f: content = f.read() # Basic extraction of components diff --git a/kicad_mcp/resources/netlist_resources.py b/kicad_mcp/resources/netlist_resources.py index cbff8af..f618383 100644 --- a/kicad_mcp/resources/netlist_resources.py +++ b/kicad_mcp/resources/netlist_resources.py @@ -3,10 +3,11 @@ Netlist resources for KiCad schematics. """ import os + from mcp.server.fastmcp import FastMCP from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.netlist_parser import extract_netlist, analyze_netlist +from kicad_mcp.utils.netlist_parser import analyze_netlist, extract_netlist def register_netlist_resources(mcp: FastMCP) -> None: diff --git a/kicad_mcp/resources/pattern_resources.py b/kicad_mcp/resources/pattern_resources.py index 0090437..ccd65df 100644 --- a/kicad_mcp/resources/pattern_resources.py +++ b/kicad_mcp/resources/pattern_resources.py @@ -3,17 +3,18 @@ Circuit pattern recognition resources for KiCad schematics. """ import os + from mcp.server.fastmcp import FastMCP from kicad_mcp.utils.file_utils import get_project_files from kicad_mcp.utils.netlist_parser import extract_netlist from kicad_mcp.utils.pattern_recognition import ( - identify_power_supplies, identify_amplifiers, - identify_filters, - identify_oscillators, identify_digital_interfaces, + identify_filters, identify_microcontrollers, + identify_oscillators, + identify_power_supplies, identify_sensor_interfaces, ) @@ -73,7 +74,7 @@ def register_pattern_resources(mcp: FastMCP) -> None: + len(sensor_interfaces) ) - report += f"## Summary\n\n" + report += "## Summary\n\n" report += f"- **Total Components**: {netlist_data['component_count']}\n" report += f"- **Total Circuit Patterns Identified**: {total_patterns}\n\n" @@ -96,13 +97,13 @@ def register_pattern_resources(mcp: FastMCP) -> None: report += f"### Power Supply {i}: {ps_subtype.upper() if ps_subtype else ps_type.title()}\n\n" if ps_type == "linear_regulator": - report += f"- **Type**: Linear Voltage Regulator\n" + report += "- **Type**: Linear Voltage Regulator\n" report += f"- **Subtype**: {ps_subtype}\n" report += f"- **Main Component**: {ps.get('main_component', 'Unknown')}\n" report += f"- **Value**: {ps.get('value', 'Unknown')}\n" report += f"- **Output Voltage**: {ps.get('output_voltage', 'Unknown')}\n" elif ps_type == "switching_regulator": - report += f"- **Type**: Switching Voltage Regulator\n" + report += "- **Type**: Switching Voltage Regulator\n" report += ( f"- **Topology**: {ps_subtype.title() if ps_subtype else 'Unknown'}\n" ) @@ -121,17 +122,17 @@ def register_pattern_resources(mcp: FastMCP) -> None: report += f"### Amplifier {i}: {amp_subtype.upper() if amp_subtype else amp_type.title()}\n\n" if amp_type == "operational_amplifier": - report += f"- **Type**: Operational Amplifier\n" + report += "- **Type**: Operational Amplifier\n" report += f"- **Subtype**: {amp_subtype.replace('_', ' ').title() if amp_subtype else 'General Purpose'}\n" report += f"- **Component**: {amp.get('component', 'Unknown')}\n" report += f"- **Value**: {amp.get('value', 'Unknown')}\n" elif amp_type == "transistor_amplifier": - report += f"- **Type**: Transistor Amplifier\n" + report += "- **Type**: Transistor Amplifier\n" report += f"- **Transistor Type**: {amp_subtype}\n" report += f"- **Component**: {amp.get('component', 'Unknown')}\n" report += f"- **Value**: {amp.get('value', 'Unknown')}\n" elif amp_type == "audio_amplifier_ic": - report += f"- **Type**: Audio Amplifier IC\n" + report += "- **Type**: Audio Amplifier IC\n" report += f"- **Component**: {amp.get('component', 'Unknown')}\n" report += f"- **Value**: {amp.get('value', 'Unknown')}\n" @@ -146,19 +147,19 @@ def register_pattern_resources(mcp: FastMCP) -> None: report += f"### Filter {i}: {filt_subtype.upper() if filt_subtype else filt_type.title()}\n\n" if filt_type == "passive_filter": - report += f"- **Type**: Passive Filter\n" + report += "- **Type**: Passive Filter\n" report += f"- **Topology**: {filt_subtype.replace('_', ' ').upper() if filt_subtype else 'Unknown'}\n" report += f"- **Components**: {', '.join(filt.get('components', []))}\n" elif filt_type == "active_filter": - report += f"- **Type**: Active Filter\n" + report += "- **Type**: Active Filter\n" report += f"- **Main Component**: {filt.get('main_component', 'Unknown')}\n" report += f"- **Value**: {filt.get('value', 'Unknown')}\n" elif filt_type == "crystal_filter": - report += f"- **Type**: Crystal Filter\n" + report += "- **Type**: Crystal Filter\n" report += f"- **Component**: {filt.get('component', 'Unknown')}\n" report += f"- **Value**: {filt.get('value', 'Unknown')}\n" elif filt_type == "ceramic_filter": - report += f"- **Type**: Ceramic Filter\n" + report += "- **Type**: Ceramic Filter\n" report += f"- **Component**: {filt.get('component', 'Unknown')}\n" report += f"- **Value**: {filt.get('value', 'Unknown')}\n" @@ -173,18 +174,18 @@ def register_pattern_resources(mcp: FastMCP) -> None: report += f"### Oscillator {i}: {osc_subtype.upper() if osc_subtype else osc_type.title()}\n\n" if osc_type == "crystal_oscillator": - report += f"- **Type**: Crystal Oscillator\n" + report += "- **Type**: Crystal Oscillator\n" report += f"- **Component**: {osc.get('component', 'Unknown')}\n" report += f"- **Value**: {osc.get('value', 'Unknown')}\n" report += f"- **Frequency**: {osc.get('frequency', 'Unknown')}\n" report += f"- **Has Load Capacitors**: {'Yes' if osc.get('has_load_capacitors', False) else 'No'}\n" elif osc_type == "oscillator_ic": - report += f"- **Type**: Oscillator IC\n" + report += "- **Type**: Oscillator IC\n" report += f"- **Component**: {osc.get('component', 'Unknown')}\n" report += f"- **Value**: {osc.get('value', 'Unknown')}\n" report += f"- **Frequency**: {osc.get('frequency', 'Unknown')}\n" elif osc_type == "rc_oscillator": - report += f"- **Type**: RC Oscillator\n" + report += "- **Type**: RC Oscillator\n" report += f"- **Subtype**: {osc_subtype.replace('_', ' ').title() if osc_subtype else 'Unknown'}\n" report += f"- **Component**: {osc.get('component', 'Unknown')}\n" report += f"- **Value**: {osc.get('value', 'Unknown')}\n" @@ -212,7 +213,7 @@ def register_pattern_resources(mcp: FastMCP) -> None: if mcu_type == "microcontroller": report += f"### Microcontroller {i}: {mcu.get('model', mcu.get('family', 'Unknown'))}\n\n" - report += f"- **Type**: Microcontroller\n" + report += "- **Type**: Microcontroller\n" report += f"- **Family**: {mcu.get('family', 'Unknown')}\n" if "model" in mcu: report += f"- **Model**: {mcu['model']}\n" @@ -225,7 +226,7 @@ def register_pattern_resources(mcp: FastMCP) -> None: report += ( f"### Development Board {i}: {mcu.get('board_type', 'Unknown')}\n\n" ) - report += f"- **Type**: Development Board\n" + report += "- **Type**: Development Board\n" report += f"- **Board Type**: {mcu.get('board_type', 'Unknown')}\n" report += f"- **Component**: {mcu.get('component', 'Unknown')}\n" report += f"- **Value**: {mcu.get('value', 'Unknown')}\n" diff --git a/kicad_mcp/resources/projects.py b/kicad_mcp/resources/projects.py index 57bfaff..8660ebf 100644 --- a/kicad_mcp/resources/projects.py +++ b/kicad_mcp/resources/projects.py @@ -3,9 +3,9 @@ Project listing and information resources. """ import os + from mcp.server.fastmcp import FastMCP -from kicad_mcp.utils.kicad_utils import find_kicad_projects from kicad_mcp.utils.file_utils import get_project_files, load_project_json diff --git a/kicad_mcp/server.py b/kicad_mcp/server.py index 3c4f00e..f314537 100644 --- a/kicad_mcp/server.py +++ b/kicad_mcp/server.py @@ -3,43 +3,44 @@ MCP server creation and configuration. """ import atexit +from collections.abc import Callable +import functools +import logging import os import signal -import logging -import functools -from typing import Callable + from fastmcp import FastMCP -# Import resource handlers -from kicad_mcp.resources.projects import register_project_resources -from kicad_mcp.resources.files import register_file_resources -from kicad_mcp.resources.drc_resources import register_drc_resources -from kicad_mcp.resources.bom_resources import register_bom_resources -from kicad_mcp.resources.netlist_resources import register_netlist_resources -from kicad_mcp.resources.pattern_resources import register_pattern_resources - - -# Import tool handlers -from kicad_mcp.tools.project_tools import register_project_tools -from kicad_mcp.tools.analysis_tools import register_analysis_tools -from kicad_mcp.tools.export_tools import register_export_tools -from kicad_mcp.tools.drc_tools import register_drc_tools -from kicad_mcp.tools.bom_tools import register_bom_tools -from kicad_mcp.tools.netlist_tools import register_netlist_tools -from kicad_mcp.tools.pattern_tools import register_pattern_tools -from kicad_mcp.tools.model3d_tools import register_model3d_tools -from kicad_mcp.tools.advanced_drc_tools import register_advanced_drc_tools -from kicad_mcp.tools.symbol_tools import register_symbol_tools -from kicad_mcp.tools.layer_tools import register_layer_tools - -# Import prompt handlers -from kicad_mcp.prompts.templates import register_prompts -from kicad_mcp.prompts.drc_prompt import register_drc_prompts -from kicad_mcp.prompts.bom_prompts import register_bom_prompts -from kicad_mcp.prompts.pattern_prompts import register_pattern_prompts - # Import context management from kicad_mcp.context import kicad_lifespan +from kicad_mcp.prompts.bom_prompts import register_bom_prompts +from kicad_mcp.prompts.drc_prompt import register_drc_prompts +from kicad_mcp.prompts.pattern_prompts import register_pattern_prompts + +# Import prompt handlers +from kicad_mcp.prompts.templates import register_prompts +from kicad_mcp.resources.bom_resources import register_bom_resources +from kicad_mcp.resources.drc_resources import register_drc_resources +from kicad_mcp.resources.files import register_file_resources +from kicad_mcp.resources.netlist_resources import register_netlist_resources +from kicad_mcp.resources.pattern_resources import register_pattern_resources + +# Import resource handlers +from kicad_mcp.resources.projects import register_project_resources +from kicad_mcp.tools.advanced_drc_tools import register_advanced_drc_tools +from kicad_mcp.tools.ai_tools import register_ai_tools +from kicad_mcp.tools.analysis_tools import register_analysis_tools +from kicad_mcp.tools.bom_tools import register_bom_tools +from kicad_mcp.tools.drc_tools import register_drc_tools +from kicad_mcp.tools.export_tools import register_export_tools +from kicad_mcp.tools.layer_tools import register_layer_tools +from kicad_mcp.tools.model3d_tools import register_model3d_tools +from kicad_mcp.tools.netlist_tools import register_netlist_tools +from kicad_mcp.tools.pattern_tools import register_pattern_tools + +# Import tool handlers +from kicad_mcp.tools.project_tools import register_project_tools +from kicad_mcp.tools.symbol_tools import register_symbol_tools # Track cleanup handlers cleanup_handlers = [] @@ -62,7 +63,7 @@ def add_cleanup_handler(handler: Callable) -> None: def run_cleanup_handlers() -> None: """Run all registered cleanup handlers.""" - logging.info(f"Running cleanup handlers...") + logging.info("Running cleanup handlers...") global _shutting_down @@ -71,7 +72,7 @@ def run_cleanup_handlers() -> None: return _shutting_down = True - logging.info(f"Running cleanup handlers...") + logging.info("Running cleanup handlers...") for handler in cleanup_handlers: try: @@ -87,9 +88,9 @@ def shutdown_server(): if _server_instance: try: - logging.info(f"Shutting down KiCad MCP server") + logging.info("Shutting down KiCad MCP server") _server_instance = None - logging.info(f"KiCad MCP server shutdown complete") + logging.info("KiCad MCP server shutdown complete") except Exception as e: logging.error(f"Error shutting down server: {str(e)}", exc_info=True) @@ -125,7 +126,7 @@ def register_signal_handlers(server: FastMCP) -> None: def create_server() -> FastMCP: """Create and configure the KiCad MCP server.""" - logging.info(f"Initializing KiCad MCP server") + logging.info("Initializing KiCad MCP server") # Try to set up KiCad Python path - Removed # kicad_modules_available = setup_kicad_python_path() @@ -136,7 +137,7 @@ def create_server() -> FastMCP: # else: # Always print this now, as we rely on CLI logging.info( - f"KiCad Python module setup removed; relying on kicad-cli for external operations." + "KiCad Python module setup removed; relying on kicad-cli for external operations." ) # Build a lifespan callable with the kwarg baked in (FastMCP 2.x dropped lifespan_kwargs) @@ -146,10 +147,10 @@ def create_server() -> FastMCP: # Initialize FastMCP server mcp = FastMCP("KiCad", lifespan=lifespan_factory) - logging.info(f"Created FastMCP server instance with lifespan management") + logging.info("Created FastMCP server instance with lifespan management") # Register resources - logging.info(f"Registering resources...") + logging.info("Registering resources...") register_project_resources(mcp) register_file_resources(mcp) register_drc_resources(mcp) @@ -158,7 +159,7 @@ def create_server() -> FastMCP: register_pattern_resources(mcp) # Register tools - logging.info(f"Registering tools...") + logging.info("Registering tools...") register_project_tools(mcp) register_analysis_tools(mcp) register_export_tools(mcp) @@ -170,9 +171,10 @@ def create_server() -> FastMCP: register_advanced_drc_tools(mcp) register_symbol_tools(mcp) register_layer_tools(mcp) + register_ai_tools(mcp) # Register prompts - logging.info(f"Registering prompts...") + logging.info("Registering prompts...") register_prompts(mcp) register_drc_prompts(mcp) register_bom_prompts(mcp) @@ -183,12 +185,13 @@ def create_server() -> FastMCP: atexit.register(run_cleanup_handlers) # Add specific cleanup handlers - add_cleanup_handler(lambda: logging.info(f"KiCad MCP server shutdown complete")) + add_cleanup_handler(lambda: logging.info("KiCad MCP server shutdown complete")) # Add temp directory cleanup def cleanup_temp_dirs(): """Clean up any temporary directories created by the server.""" import shutil + from kicad_mcp.utils.temp_dir_manager import get_temp_dirs temp_dirs = get_temp_dirs() @@ -204,7 +207,7 @@ def create_server() -> FastMCP: add_cleanup_handler(cleanup_temp_dirs) - logging.info(f"Server initialization complete") + logging.info("Server initialization complete") return mcp diff --git a/kicad_mcp/tools/advanced_drc_tools.py b/kicad_mcp/tools/advanced_drc_tools.py index 96bdbc8..8914579 100644 --- a/kicad_mcp/tools/advanced_drc_tools.py +++ b/kicad_mcp/tools/advanced_drc_tools.py @@ -5,26 +5,20 @@ Provides MCP tools for advanced Design Rule Check (DRC) functionality including custom rule creation, specialized rule sets, and manufacturing constraint validation. """ -import json -from typing import Any, Dict, List +from typing import Any from fastmcp import FastMCP -from kicad_mcp.utils.advanced_drc import ( - create_drc_manager, - AdvancedDRCManager, - DRCRule, - RuleType, - RuleSeverity -) + +from kicad_mcp.utils.advanced_drc import RuleSeverity, RuleType, create_drc_manager from kicad_mcp.utils.path_validator import validate_kicad_file def register_advanced_drc_tools(mcp: FastMCP) -> None: """Register advanced DRC tools with the MCP server.""" - + @mcp.tool() - def create_drc_rule_set(name: str, technology: str = "standard", - description: str = "") -> Dict[str, Any]: + def create_drc_rule_set(name: str, technology: str = "standard", + description: str = "") -> dict[str, Any]: """ Create a new DRC rule set for a specific technology or application. @@ -45,7 +39,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: """ try: manager = create_drc_manager() - + # Create rule set based on technology if technology.lower() == "hdi": rule_set = manager.create_high_density_rules() @@ -58,14 +52,14 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: rule_set = manager.rule_sets["standard"] rule_set.name = name rule_set.description = description or f"Standard PCB rules for {name}" - + if name: rule_set.name = name if description: rule_set.description = description - + manager.add_rule_set(rule_set) - + return { "success": True, "rule_set_name": rule_set.name, @@ -83,18 +77,18 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: for rule in rule_set.rules ] } - + except Exception as e: return { "success": False, "error": str(e), "rule_set_name": name } - + @mcp.tool() - def create_custom_drc_rule(rule_name: str, rule_type: str, constraint: Dict[str, Any], + def create_custom_drc_rule(rule_name: str, rule_type: str, constraint: dict[str, Any], severity: str = "error", condition: str = None, - description: str = None) -> Dict[str, Any]: + description: str = None) -> dict[str, Any]: """ Create a custom DRC rule with specific constraints and conditions. @@ -114,7 +108,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: """ try: manager = create_drc_manager() - + # Convert string enums try: rule_type_enum = RuleType(rule_type.lower()) @@ -123,7 +117,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "success": False, "error": f"Invalid rule type: {rule_type}. Valid types: {[rt.value for rt in RuleType]}" } - + try: severity_enum = RuleSeverity(severity.lower()) except ValueError: @@ -131,7 +125,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "success": False, "error": f"Invalid severity: {severity}. Valid severities: {[s.value for s in RuleSeverity]}" } - + # Create the rule rule = manager.create_custom_rule( name=rule_name, @@ -141,10 +135,10 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: condition=condition, description=description ) - + # Validate rule syntax validation_errors = manager.validate_rule_syntax(rule) - + return { "success": True, "rule": { @@ -161,16 +155,16 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "errors": validation_errors } } - + except Exception as e: return { "success": False, "error": str(e), "rule_name": rule_name } - + @mcp.tool() - def export_kicad_drc_rules(rule_set_name: str = "standard") -> Dict[str, Any]: + def export_kicad_drc_rules(rule_set_name: str = "standard") -> dict[str, Any]: """ Export DRC rules in KiCad-compatible format. @@ -185,12 +179,12 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: """ try: manager = create_drc_manager() - + # Export to KiCad format kicad_rules = manager.export_kicad_drc_rules(rule_set_name) - + rule_set = manager.rule_sets[rule_set_name] - + return { "success": True, "rule_set_name": rule_set_name, @@ -204,16 +198,16 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "usage": "Copy the kicad_rules text to your KiCad project's custom DRC rules" } } - + except Exception as e: return { "success": False, "error": str(e), "rule_set_name": rule_set_name } - + @mcp.tool() - def analyze_pcb_drc_violations(pcb_file_path: str, rule_set_name: str = "standard") -> Dict[str, Any]: + def analyze_pcb_drc_violations(pcb_file_path: str, rule_set_name: str = "standard") -> dict[str, Any]: """ Analyze a PCB file against advanced DRC rules and report violations. @@ -234,13 +228,13 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: try: validated_path = validate_kicad_file(pcb_file_path, "pcb") manager = create_drc_manager() - + # Perform DRC analysis analysis = manager.analyze_pcb_for_rule_violations(validated_path, rule_set_name) - + # Get rule set info rule_set = manager.rule_sets.get(rule_set_name) - + return { "success": True, "pcb_file": validated_path, @@ -252,16 +246,16 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "total_rules": len(rule_set.rules) if rule_set else 0 } } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def get_manufacturing_constraints(technology: str = "standard") -> Dict[str, Any]: + def get_manufacturing_constraints(technology: str = "standard") -> dict[str, Any]: """ Get manufacturing constraints for a specific PCB technology. @@ -277,7 +271,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: try: manager = create_drc_manager() constraints = manager.generate_manufacturing_constraints(technology) - + # Add recommendations based on technology recommendations = { "standard": [ @@ -301,7 +295,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "Use thermal management for high-power components" ] } - + return { "success": True, "technology": technology, @@ -314,16 +308,16 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "standard": ["IPC-2221", "IPC-2222"] }.get(technology, []) } - + except Exception as e: return { "success": False, "error": str(e), "technology": technology } - + @mcp.tool() - def list_available_rule_sets() -> Dict[str, Any]: + def list_available_rule_sets() -> dict[str, Any]: """ List all available DRC rule sets and their properties. @@ -336,7 +330,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: try: manager = create_drc_manager() rule_set_names = manager.get_rule_set_names() - + rule_sets_info = [] for name in rule_set_names: rule_set = manager.rule_sets[name] @@ -350,7 +344,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "active_rules": len([r for r in rule_set.rules if r.enabled]), "rule_types": list(set(r.rule_type.value for r in rule_set.rules)) }) - + return { "success": True, "rule_sets": rule_sets_info, @@ -358,15 +352,15 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "active_rule_set": manager.active_rule_set, "supported_technologies": ["standard", "hdi", "rf", "automotive"] } - + except Exception as e: return { "success": False, "error": str(e) } - + @mcp.tool() - def validate_drc_rule_syntax(rule_definition: Dict[str, Any]) -> Dict[str, Any]: + def validate_drc_rule_syntax(rule_definition: dict[str, Any]) -> dict[str, Any]: """ Validate the syntax and parameters of a DRC rule definition. @@ -381,7 +375,7 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: """ try: manager = create_drc_manager() - + # Extract rule parameters rule_name = rule_definition.get("name", "") rule_type = rule_definition.get("type", "") @@ -389,24 +383,24 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: severity = rule_definition.get("severity", "error") condition = rule_definition.get("condition") description = rule_definition.get("description") - + # Validate required fields validation_errors = [] - + if not rule_name: validation_errors.append("Rule name is required") - + if not rule_type: validation_errors.append("Rule type is required") elif rule_type not in [rt.value for rt in RuleType]: validation_errors.append(f"Invalid rule type: {rule_type}") - + if not constraint: validation_errors.append("Constraint parameters are required") - + if severity not in [s.value for s in RuleSeverity]: validation_errors.append(f"Invalid severity: {severity}") - + # If basic validation passes, create temporary rule for detailed validation if not validation_errors: try: @@ -418,14 +412,14 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: condition=condition, description=description ) - + # Validate rule syntax syntax_errors = manager.validate_rule_syntax(temp_rule) validation_errors.extend(syntax_errors) - + except Exception as e: validation_errors.append(f"Rule creation failed: {str(e)}") - + return { "success": True, "valid": len(validation_errors) == 0, @@ -437,10 +431,10 @@ def register_advanced_drc_tools(mcp: FastMCP) -> None: "syntax_errors": len([e for e in validation_errors if "syntax" in e.lower() or "condition" in e.lower()]) } } - + except Exception as e: return { "success": False, "error": str(e), "rule_definition": rule_definition - } \ No newline at end of file + } diff --git a/kicad_mcp/tools/ai_tools.py b/kicad_mcp/tools/ai_tools.py new file mode 100644 index 0000000..45c143f --- /dev/null +++ b/kicad_mcp/tools/ai_tools.py @@ -0,0 +1,700 @@ +""" +AI/LLM Integration Tools for KiCad MCP Server. + +Provides intelligent analysis and recommendations for KiCad designs including +smart component suggestions, automated design rule recommendations, and layout optimization. +""" + +from typing import Any + +from fastmcp import FastMCP + +from kicad_mcp.utils.component_utils import ComponentType, get_component_type +from kicad_mcp.utils.file_utils import get_project_files +from kicad_mcp.utils.netlist_parser import parse_netlist_file +from kicad_mcp.utils.pattern_recognition import analyze_circuit_patterns + + +def register_ai_tools(mcp: FastMCP) -> None: + """Register AI/LLM integration tools with the MCP server.""" + + @mcp.tool() + def suggest_components_for_circuit(project_path: str, circuit_function: str = None) -> dict[str, Any]: + """ + Analyze circuit patterns and suggest appropriate components. + + Uses circuit analysis to identify incomplete circuits and suggest + missing components based on common design patterns and best practices. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + circuit_function: Optional description of intended circuit function + + Returns: + Dictionary with component suggestions categorized by circuit type + + Examples: + suggest_components_for_circuit("/path/to/project.kicad_pro") + suggest_components_for_circuit("/path/to/project.kicad_pro", "audio amplifier") + """ + try: + # Get project files + files = get_project_files(project_path) + if "schematic" not in files: + return { + "success": False, + "error": "Schematic file not found in project" + } + + schematic_file = files["schematic"] + + # Analyze existing circuit patterns + patterns = analyze_circuit_patterns(schematic_file) + + # Parse netlist for component analysis + try: + netlist_data = parse_netlist_file(schematic_file) + components = netlist_data.get("components", []) + except: + components = [] + + # Generate suggestions based on patterns + suggestions = _generate_component_suggestions(patterns, components, circuit_function) + + return { + "success": True, + "project_path": project_path, + "circuit_analysis": { + "identified_patterns": list(patterns.keys()), + "component_count": len(components), + "missing_patterns": _identify_missing_patterns(patterns, components) + }, + "component_suggestions": suggestions, + "design_recommendations": _generate_design_recommendations(patterns, components), + "implementation_notes": [ + "Review suggested components for compatibility with existing design", + "Verify component ratings match circuit requirements", + "Consider thermal management for power components", + "Check component availability and cost before finalizing" + ] + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "project_path": project_path + } + + @mcp.tool() + def recommend_design_rules(project_path: str, target_technology: str = "standard") -> dict[str, Any]: + """ + Generate automated design rule recommendations based on circuit analysis. + + Analyzes the circuit topology, component types, and signal characteristics + to recommend appropriate design rules for the specific application. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + target_technology: Target technology ("standard", "hdi", "rf", "automotive") + + Returns: + Dictionary with customized design rule recommendations + + Examples: + recommend_design_rules("/path/to/project.kicad_pro") + recommend_design_rules("/path/to/project.kicad_pro", "rf") + """ + try: + # Get project files + files = get_project_files(project_path) + + analysis_data = {} + + # Analyze schematic if available + if "schematic" in files: + patterns = analyze_circuit_patterns(files["schematic"]) + analysis_data["patterns"] = patterns + + try: + netlist_data = parse_netlist_file(files["schematic"]) + analysis_data["components"] = netlist_data.get("components", []) + except: + analysis_data["components"] = [] + + # Analyze PCB if available + if "pcb" in files: + pcb_analysis = _analyze_pcb_characteristics(files["pcb"]) + analysis_data["pcb"] = pcb_analysis + + # Generate design rules based on analysis + design_rules = _generate_design_rules(analysis_data, target_technology) + + return { + "success": True, + "project_path": project_path, + "target_technology": target_technology, + "circuit_analysis": { + "identified_patterns": list(analysis_data.get("patterns", {}).keys()), + "component_types": _categorize_components(analysis_data.get("components", [])), + "signal_types": _identify_signal_types(analysis_data.get("patterns", {})) + }, + "recommended_rules": design_rules, + "rule_justifications": _generate_rule_justifications(design_rules, analysis_data), + "implementation_priority": _prioritize_rules(design_rules) + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "project_path": project_path + } + + @mcp.tool() + def optimize_pcb_layout(project_path: str, optimization_goals: list[str] = None) -> dict[str, Any]: + """ + Analyze PCB layout and provide optimization suggestions. + + Reviews component placement, routing, and design practices to suggest + improvements for signal integrity, thermal management, and manufacturability. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + optimization_goals: List of optimization priorities (e.g., ["signal_integrity", "thermal", "cost"]) + + Returns: + Dictionary with layout optimization recommendations + + Examples: + optimize_pcb_layout("/path/to/project.kicad_pro") + optimize_pcb_layout("/path/to/project.kicad_pro", ["signal_integrity", "cost"]) + """ + try: + if not optimization_goals: + optimization_goals = ["signal_integrity", "thermal", "manufacturability"] + + # Get project files + files = get_project_files(project_path) + + if "pcb" not in files: + return { + "success": False, + "error": "PCB file not found in project" + } + + pcb_file = files["pcb"] + + # Analyze current layout + layout_analysis = _analyze_pcb_layout(pcb_file) + + # Get circuit context from schematic if available + circuit_context = {} + if "schematic" in files: + patterns = analyze_circuit_patterns(files["schematic"]) + circuit_context = {"patterns": patterns} + + # Generate optimization suggestions + optimizations = _generate_layout_optimizations( + layout_analysis, circuit_context, optimization_goals + ) + + return { + "success": True, + "project_path": project_path, + "optimization_goals": optimization_goals, + "layout_analysis": { + "component_density": layout_analysis.get("component_density", 0), + "routing_utilization": layout_analysis.get("routing_utilization", {}), + "thermal_zones": layout_analysis.get("thermal_zones", []), + "critical_signals": layout_analysis.get("critical_signals", []) + }, + "optimization_suggestions": optimizations, + "implementation_steps": _generate_implementation_steps(optimizations), + "expected_benefits": _calculate_optimization_benefits(optimizations) + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "project_path": project_path + } + + @mcp.tool() + def analyze_design_completeness(project_path: str) -> dict[str, Any]: + """ + Analyze design completeness and suggest missing elements. + + Performs comprehensive analysis to identify missing components, + incomplete circuits, and design gaps that should be addressed. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + + Returns: + Dictionary with completeness analysis and improvement suggestions + """ + try: + files = get_project_files(project_path) + + completeness_analysis = { + "schematic_completeness": 0, + "pcb_completeness": 0, + "design_gaps": [], + "missing_elements": [], + "verification_status": {} + } + + # Analyze schematic completeness + if "schematic" in files: + schematic_analysis = _analyze_schematic_completeness(files["schematic"]) + completeness_analysis.update(schematic_analysis) + + # Analyze PCB completeness + if "pcb" in files: + pcb_analysis = _analyze_pcb_completeness(files["pcb"]) + completeness_analysis["pcb_completeness"] = pcb_analysis["completeness_score"] + completeness_analysis["design_gaps"].extend(pcb_analysis["gaps"]) + + # Overall completeness score + overall_score = ( + completeness_analysis["schematic_completeness"] * 0.6 + + completeness_analysis["pcb_completeness"] * 0.4 + ) + + return { + "success": True, + "project_path": project_path, + "completeness_score": round(overall_score, 1), + "analysis_details": completeness_analysis, + "priority_actions": _prioritize_completeness_actions(completeness_analysis), + "design_checklist": _generate_design_checklist(completeness_analysis), + "recommendations": _generate_completeness_recommendations(completeness_analysis) + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "project_path": project_path + } + + +# Helper functions for component suggestions +def _generate_component_suggestions(patterns: dict, components: list, circuit_function: str = None) -> dict[str, list]: + """Generate component suggestions based on circuit analysis.""" + suggestions = { + "power_management": [], + "signal_conditioning": [], + "protection": [], + "filtering": [], + "interface": [], + "passive_components": [] + } + + # Analyze existing components + component_types = [get_component_type(comp.get("value", "")) for comp in components] + + # Power management suggestions + if "power_supply" in patterns: + if ComponentType.VOLTAGE_REGULATOR not in component_types: + suggestions["power_management"].append({ + "component": "Voltage Regulator", + "suggestion": "Add voltage regulator for stable power supply", + "examples": ["LM7805", "AMS1117-3.3", "LM2596"] + }) + + if ComponentType.CAPACITOR not in component_types: + suggestions["power_management"].append({ + "component": "Decoupling Capacitors", + "suggestion": "Add decoupling capacitors near power pins", + "examples": ["100nF ceramic", "10uF tantalum", "1000uF electrolytic"] + }) + + # Signal conditioning suggestions + if "amplifier" in patterns: + if not any("op" in comp.get("value", "").lower() for comp in components): + suggestions["signal_conditioning"].append({ + "component": "Operational Amplifier", + "suggestion": "Consider op-amp for signal amplification", + "examples": ["LM358", "TL072", "OPA2134"] + }) + + # Protection suggestions + if "microcontroller" in patterns or "processor" in patterns: + if ComponentType.FUSE not in component_types: + suggestions["protection"].append({ + "component": "Fuse or PTC Resettable Fuse", + "suggestion": "Add overcurrent protection", + "examples": ["1A fuse", "PPTC 0.5A", "Polyfuse 1A"] + }) + + if not any("esd" in comp.get("value", "").lower() for comp in components): + suggestions["protection"].append({ + "component": "ESD Protection", + "suggestion": "Add ESD protection for I/O pins", + "examples": ["TVS diode", "ESD suppressors", "Varistors"] + }) + + # Filtering suggestions + if any(pattern in patterns for pattern in ["switching_converter", "motor_driver"]): + suggestions["filtering"].append({ + "component": "EMI Filter", + "suggestion": "Add EMI filtering for switching circuits", + "examples": ["Common mode choke", "Ferrite beads", "Pi filter"] + }) + + # Interface suggestions based on circuit function + if circuit_function: + function_lower = circuit_function.lower() + if "audio" in function_lower: + suggestions["interface"].extend([ + { + "component": "Audio Jack", + "suggestion": "Add audio input/output connector", + "examples": ["3.5mm jack", "RCA connector", "XLR"] + }, + { + "component": "Audio Coupling Capacitor", + "suggestion": "AC coupling for audio signals", + "examples": ["10uF", "47uF", "100uF"] + } + ]) + + if "usb" in function_lower or "communication" in function_lower: + suggestions["interface"].append({ + "component": "USB Connector", + "suggestion": "Add USB interface for communication", + "examples": ["USB-A", "USB-C", "Micro-USB"] + }) + + return suggestions + + +def _identify_missing_patterns(patterns: dict, components: list) -> list[str]: + """Identify common circuit patterns that might be missing.""" + missing_patterns = [] + + has_digital_components = any( + comp.get("value", "").lower() in ["microcontroller", "processor", "mcu"] + for comp in components + ) + + if has_digital_components: + if "crystal_oscillator" not in patterns: + missing_patterns.append("crystal_oscillator") + if "reset_circuit" not in patterns: + missing_patterns.append("reset_circuit") + if "power_supply" not in patterns: + missing_patterns.append("power_supply") + + return missing_patterns + + +def _generate_design_recommendations(patterns: dict, components: list) -> list[str]: + """Generate general design recommendations.""" + recommendations = [] + + if "power_supply" not in patterns and len(components) > 5: + recommendations.append("Consider adding dedicated power supply regulation") + + if len(components) > 20 and "decoupling" not in patterns: + recommendations.append("Add decoupling capacitors for noise reduction") + + if any("high_freq" in str(pattern) for pattern in patterns): + recommendations.append("Consider transmission line effects for high-frequency signals") + + return recommendations + + +# Helper functions for design rules +def _analyze_pcb_characteristics(pcb_file: str) -> dict[str, Any]: + """Analyze PCB file for design rule recommendations.""" + # This is a simplified analysis - in practice would parse the PCB file + return { + "layer_count": 2, # Default assumption + "min_trace_width": 0.1, + "min_via_size": 0.2, + "component_density": "medium" + } + + +def _generate_design_rules(analysis_data: dict, target_technology: str) -> dict[str, dict]: + """Generate design rules based on analysis and technology target.""" + base_rules = { + "trace_width": {"min": 0.1, "preferred": 0.15, "unit": "mm"}, + "via_size": {"min": 0.2, "preferred": 0.3, "unit": "mm"}, + "clearance": {"min": 0.1, "preferred": 0.15, "unit": "mm"}, + "annular_ring": {"min": 0.05, "preferred": 0.1, "unit": "mm"} + } + + # Adjust rules based on technology + if target_technology == "hdi": + base_rules["trace_width"]["min"] = 0.075 + base_rules["via_size"]["min"] = 0.1 + base_rules["clearance"]["min"] = 0.075 + elif target_technology == "rf": + base_rules["trace_width"]["preferred"] = 0.2 + base_rules["clearance"]["preferred"] = 0.2 + elif target_technology == "automotive": + base_rules["trace_width"]["min"] = 0.15 + base_rules["clearance"]["min"] = 0.15 + + # Adjust based on patterns + patterns = analysis_data.get("patterns", {}) + if "power_supply" in patterns: + base_rules["power_trace_width"] = {"min": 0.3, "preferred": 0.5, "unit": "mm"} + + if "high_speed" in patterns: + base_rules["differential_impedance"] = {"target": 100, "tolerance": 10, "unit": "ohm"} + base_rules["single_ended_impedance"] = {"target": 50, "tolerance": 5, "unit": "ohm"} + + return base_rules + + +def _categorize_components(components: list) -> dict[str, int]: + """Categorize components by type.""" + categories = {} + for comp in components: + comp_type = get_component_type(comp.get("value", "")) + category_name = comp_type.name.lower() if comp_type != ComponentType.UNKNOWN else "other" + categories[category_name] = categories.get(category_name, 0) + 1 + + return categories + + +def _identify_signal_types(patterns: dict) -> list[str]: + """Identify signal types based on circuit patterns.""" + signal_types = [] + + if "power_supply" in patterns: + signal_types.append("power") + if "amplifier" in patterns: + signal_types.append("analog") + if "microcontroller" in patterns: + signal_types.extend(["digital", "clock"]) + if "crystal_oscillator" in patterns: + signal_types.append("high_frequency") + + return list(set(signal_types)) + + +def _generate_rule_justifications(design_rules: dict, analysis_data: dict) -> dict[str, str]: + """Generate justifications for recommended design rules.""" + justifications = {} + + patterns = analysis_data.get("patterns", {}) + + if "trace_width" in design_rules: + justifications["trace_width"] = "Based on current carrying capacity and manufacturing constraints" + + if "power_supply" in patterns and "power_trace_width" in design_rules: + justifications["power_trace_width"] = "Wider traces for power distribution to reduce voltage drop" + + if "high_speed" in patterns and "differential_impedance" in design_rules: + justifications["differential_impedance"] = "Controlled impedance required for high-speed signals" + + return justifications + + +def _prioritize_rules(design_rules: dict) -> list[str]: + """Prioritize design rules by implementation importance.""" + priority_order = [] + + if "clearance" in design_rules: + priority_order.append("clearance") + if "trace_width" in design_rules: + priority_order.append("trace_width") + if "via_size" in design_rules: + priority_order.append("via_size") + if "power_trace_width" in design_rules: + priority_order.append("power_trace_width") + if "differential_impedance" in design_rules: + priority_order.append("differential_impedance") + + return priority_order + + +# Helper functions for layout optimization +def _analyze_pcb_layout(pcb_file: str) -> dict[str, Any]: + """Analyze PCB layout for optimization opportunities.""" + # Simplified analysis - would parse actual PCB file + return { + "component_density": 0.6, + "routing_utilization": {"top": 0.4, "bottom": 0.3}, + "thermal_zones": ["high_power_area"], + "critical_signals": ["clock", "reset", "power"] + } + + +def _generate_layout_optimizations(layout_analysis: dict, circuit_context: dict, goals: list[str]) -> dict[str, list]: + """Generate layout optimization suggestions.""" + optimizations = { + "placement": [], + "routing": [], + "thermal": [], + "signal_integrity": [], + "manufacturability": [] + } + + if "signal_integrity" in goals: + optimizations["signal_integrity"].extend([ + "Keep high-speed traces short and direct", + "Minimize via count on critical signals", + "Use ground planes for return current paths" + ]) + + if "thermal" in goals: + optimizations["thermal"].extend([ + "Spread heat-generating components across the board", + "Add thermal vias under power components", + "Consider copper pour for heat dissipation" + ]) + + if "cost" in goals or "manufacturability" in goals: + optimizations["manufacturability"].extend([ + "Use standard via sizes and trace widths", + "Minimize layer count where possible", + "Avoid blind/buried vias unless necessary" + ]) + + return optimizations + + +def _generate_implementation_steps(optimizations: dict) -> list[str]: + """Generate step-by-step implementation guide.""" + steps = [] + + if optimizations.get("placement"): + steps.append("1. Review component placement for optimal positioning") + + if optimizations.get("routing"): + steps.append("2. Re-route critical signals following guidelines") + + if optimizations.get("thermal"): + steps.append("3. Implement thermal management improvements") + + if optimizations.get("signal_integrity"): + steps.append("4. Optimize signal integrity aspects") + + steps.append("5. Run DRC and electrical rules check") + steps.append("6. Verify design meets all requirements") + + return steps + + +def _calculate_optimization_benefits(optimizations: dict) -> dict[str, str]: + """Calculate expected benefits from optimizations.""" + benefits = {} + + if optimizations.get("signal_integrity"): + benefits["signal_integrity"] = "Improved noise margin and reduced EMI" + + if optimizations.get("thermal"): + benefits["thermal"] = "Better thermal performance and component reliability" + + if optimizations.get("manufacturability"): + benefits["manufacturability"] = "Reduced manufacturing cost and higher yield" + + return benefits + + +# Helper functions for design completeness +def _analyze_schematic_completeness(schematic_file: str) -> dict[str, Any]: + """Analyze schematic completeness.""" + try: + patterns = analyze_circuit_patterns(schematic_file) + netlist_data = parse_netlist_file(schematic_file) + components = netlist_data.get("components", []) + + completeness_score = 70 # Base score + missing_elements = [] + + # Check for essential patterns + if "power_supply" in patterns: + completeness_score += 10 + else: + missing_elements.append("power_supply_regulation") + + if len(components) > 5: + if "decoupling" not in patterns: + missing_elements.append("decoupling_capacitors") + else: + completeness_score += 10 + + return { + "schematic_completeness": min(completeness_score, 100), + "missing_elements": missing_elements, + "design_gaps": [], + "verification_status": {"nets": "checked", "components": "verified"} + } + + except Exception: + return { + "schematic_completeness": 50, + "missing_elements": ["analysis_failed"], + "design_gaps": [], + "verification_status": {"status": "error"} + } + + +def _analyze_pcb_completeness(pcb_file: str) -> dict[str, Any]: + """Analyze PCB completeness.""" + # Simplified analysis + return { + "completeness_score": 80, + "gaps": ["silkscreen_labels", "test_points"] + } + + +def _prioritize_completeness_actions(analysis: dict) -> list[str]: + """Prioritize actions for improving design completeness.""" + actions = [] + + if "power_supply_regulation" in analysis.get("missing_elements", []): + actions.append("Add power supply regulation circuit") + + if "decoupling_capacitors" in analysis.get("missing_elements", []): + actions.append("Add decoupling capacitors near ICs") + + if analysis.get("schematic_completeness", 0) < 80: + actions.append("Complete schematic design") + + if analysis.get("pcb_completeness", 0) < 80: + actions.append("Finish PCB layout") + + return actions + + +def _generate_design_checklist(analysis: dict) -> list[dict[str, Any]]: + """Generate design verification checklist.""" + checklist = [ + {"item": "Schematic review complete", "status": "complete" if analysis.get("schematic_completeness", 0) > 90 else "pending"}, + {"item": "Component values verified", "status": "complete" if "components" in analysis.get("verification_status", {}) else "pending"}, + {"item": "Power supply design", "status": "complete" if "power_supply_regulation" not in analysis.get("missing_elements", []) else "pending"}, + {"item": "Signal integrity considerations", "status": "pending"}, + {"item": "Thermal management", "status": "pending"}, + {"item": "Manufacturing readiness", "status": "pending"} + ] + + return checklist + + +def _generate_completeness_recommendations(analysis: dict) -> list[str]: + """Generate recommendations for improving completeness.""" + recommendations = [] + + completeness = analysis.get("schematic_completeness", 0) + + if completeness < 70: + recommendations.append("Focus on completing core circuit functionality") + elif completeness < 85: + recommendations.append("Add protective and filtering components") + else: + recommendations.append("Review design for optimization opportunities") + + if analysis.get("missing_elements"): + recommendations.append(f"Address missing elements: {', '.join(analysis['missing_elements'])}") + + return recommendations diff --git a/kicad_mcp/tools/analysis_tools.py b/kicad_mcp/tools/analysis_tools.py index dfbe544..0c6e2b9 100644 --- a/kicad_mcp/tools/analysis_tools.py +++ b/kicad_mcp/tools/analysis_tools.py @@ -3,8 +3,9 @@ Analysis and validation tools for KiCad projects. """ import os -from typing import Dict, Any, Optional -from mcp.server.fastmcp import FastMCP, Context, Image +from typing import Any + +from mcp.server.fastmcp import FastMCP from kicad_mcp.utils.file_utils import get_project_files @@ -17,7 +18,7 @@ def register_analysis_tools(mcp: FastMCP) -> None: """ @mcp.tool() - def validate_project(project_path: str) -> Dict[str, Any]: + def validate_project(project_path: str) -> dict[str, Any]: """Basic validation of a KiCad project.""" if not os.path.exists(project_path): return {"valid": False, "error": f"Project not found: {project_path}"} @@ -34,7 +35,7 @@ def register_analysis_tools(mcp: FastMCP) -> None: # Validate project file try: - with open(project_path, "r") as f: + with open(project_path) as f: import json json.load(f) diff --git a/kicad_mcp/tools/bom_tools.py b/kicad_mcp/tools/bom_tools.py index fc02b1d..e54e159 100644 --- a/kicad_mcp/tools/bom_tools.py +++ b/kicad_mcp/tools/bom_tools.py @@ -2,12 +2,13 @@ Bill of Materials (BOM) processing tools for KiCad projects. """ -import os import csv import json +import os +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP import pandas as pd -from typing import Dict, List, Any, Optional, Tuple -from mcp.server.fastmcp import FastMCP, Context, Image from kicad_mcp.utils.file_utils import get_project_files @@ -20,7 +21,7 @@ def register_bom_tools(mcp: FastMCP) -> None: """ @mcp.tool() - def analyze_bom(project_path: str) -> Dict[str, Any]: + def analyze_bom(project_path: str) -> dict[str, Any]: """Analyze a KiCad project's Bill of Materials. This tool will look for BOM files related to a KiCad project and provide @@ -37,12 +38,12 @@ def register_bom_tools(mcp: FastMCP) -> None: if not os.path.exists(project_path): print(f"Project not found: {project_path}") - + return {"success": False, "error": f"Project not found: {project_path}"} # Report progress - - + + # Get all project files files = get_project_files(project_path) @@ -56,14 +57,14 @@ def register_bom_tools(mcp: FastMCP) -> None: if not bom_files: print("No BOM files found for project") - + return { "success": False, "error": "No BOM files found. Export a BOM from KiCad first.", "project_path": project_path, } - + # Analyze each BOM file results = { @@ -78,7 +79,7 @@ def register_bom_tools(mcp: FastMCP) -> None: for file_type, file_path in bom_files.items(): try: - + # Parse the BOM file bom_data, format_info = parse_bom_file(file_path) @@ -107,7 +108,7 @@ def register_bom_tools(mcp: FastMCP) -> None: print(f"Error analyzing BOM file {file_path}: {str(e)}", exc_info=True) results["bom_files"][file_type] = {"path": file_path, "error": str(e)} - + # Generate overall component summary if total_components > 0: @@ -148,13 +149,13 @@ def register_bom_tools(mcp: FastMCP) -> None: ) results["component_summary"]["currency"] = currency - - + + return results @mcp.tool() - def export_bom_csv(project_path: str) -> Dict[str, Any]: + def export_bom_csv(project_path: str) -> dict[str, Any]: """Export a Bill of Materials for a KiCad project. This tool attempts to generate a CSV BOM file for a KiCad project. @@ -171,14 +172,14 @@ def register_bom_tools(mcp: FastMCP) -> None: if not os.path.exists(project_path): print(f"Project not found: {project_path}") - + return {"success": False, "error": f"Project not found: {project_path}"} # For now, disable Python modules and use CLI only kicad_modules_available = False # Report progress - + # Get all project files files = get_project_files(project_path) @@ -186,15 +187,15 @@ def register_bom_tools(mcp: FastMCP) -> None: # We need the schematic file to generate a BOM if "schematic" not in files: print("Schematic file not found in project") - + return {"success": False, "error": "Schematic file not found"} schematic_file = files["schematic"] project_dir = os.path.dirname(project_path) project_name = os.path.basename(project_path)[:-10] # Remove .kicad_pro extension - - + + # Try to export BOM # This will depend on KiCad's command-line tools or Python modules @@ -203,24 +204,24 @@ def register_bom_tools(mcp: FastMCP) -> None: if kicad_modules_available: try: # Try to use KiCad Python modules - + export_result = {"success": False, "error": "Python method disabled"} except Exception as e: print(f"Error exporting BOM with Python modules: {str(e)}", exc_info=True) - + export_result = {"success": False, "error": str(e)} # If Python method failed, try command-line method if not export_result.get("success", False): try: - + export_result = {"success": False, "error": "CLI method needs sync implementation"} except Exception as e: print(f"Error exporting BOM with CLI: {str(e)}", exc_info=True) - + export_result = {"success": False, "error": str(e)} - + if export_result.get("success", False): print(f"BOM exported successfully to {export_result.get('output_file', 'unknown location')}") @@ -233,7 +234,7 @@ def register_bom_tools(mcp: FastMCP) -> None: # Helper functions for BOM processing -def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: +def parse_bom_file(file_path: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: """Parse a BOM file and detect its format. Args: @@ -259,7 +260,7 @@ def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any] try: if ext == ".csv": # Try to parse as CSV - with open(file_path, "r", encoding="utf-8-sig") as f: + with open(file_path, encoding="utf-8-sig") as f: # Read a few lines to analyze the format sample = "".join([f.readline() for _ in range(10)]) f.seek(0) # Reset file pointer @@ -317,7 +318,7 @@ def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any] elif ext == ".json": # Parse JSON - with open(file_path, "r") as f: + with open(file_path) as f: data = json.load(f) format_info["detected_format"] = "json" @@ -333,7 +334,7 @@ def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any] else: # Unknown format, try generic CSV parsing as fallback try: - with open(file_path, "r", encoding="utf-8-sig") as f: + with open(file_path, encoding="utf-8-sig") as f: reader = csv.DictReader(f) format_info["header_fields"] = reader.fieldnames if reader.fieldnames else [] format_info["detected_format"] = "unknown_csv" @@ -362,8 +363,8 @@ def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any] def analyze_bom_data( - components: List[Dict[str, Any]], format_info: Dict[str, Any] -) -> Dict[str, Any]: + components: list[dict[str, Any]], format_info: dict[str, Any] +) -> dict[str, Any]: """Analyze component data from a BOM file. Args: @@ -576,7 +577,7 @@ def analyze_bom_data( async def export_bom_with_python( schematic_file: str, output_dir: str, project_name: str, ctx: Context -) -> Dict[str, Any]: +) -> dict[str, Any]: """Export a BOM using KiCad Python modules. Args: @@ -589,7 +590,7 @@ async def export_bom_with_python( Dictionary with export results """ print(f"Exporting BOM for schematic: {schematic_file}") - + try: # Try to import KiCad Python modules @@ -600,7 +601,7 @@ async def export_bom_with_python( # For now, return a message indicating this method is not implemented yet print("BOM export with Python modules not fully implemented") - + return { "success": False, @@ -619,7 +620,7 @@ async def export_bom_with_python( async def export_bom_with_cli( schematic_file: str, output_dir: str, project_name: str, ctx: Context -) -> Dict[str, Any]: +) -> dict[str, Any]: """Export a BOM using KiCad command-line tools. Args: @@ -631,12 +632,12 @@ async def export_bom_with_cli( Returns: Dictionary with export results """ - import subprocess import platform + import subprocess system = platform.system() print(f"Exporting BOM using CLI tools on {system}") - + # Output file path output_file = os.path.join(output_dir, f"{project_name}_bom.csv") @@ -690,7 +691,7 @@ async def export_bom_with_cli( try: print(f"Running command: {' '.join(cmd)}") - + # Run the command process = subprocess.run(cmd, capture_output=True, text=True, timeout=30) @@ -716,10 +717,10 @@ async def export_bom_with_cli( "output_file": output_file, } - + # Read the first few lines of the BOM to verify it's valid - with open(output_file, "r") as f: + with open(output_file) as f: bom_content = f.read(1024) # Read first 1KB if len(bom_content.strip()) == 0: diff --git a/kicad_mcp/tools/drc_impl/cli_drc.py b/kicad_mcp/tools/drc_impl/cli_drc.py index 772c338..f68deaa 100644 --- a/kicad_mcp/tools/drc_impl/cli_drc.py +++ b/kicad_mcp/tools/drc_impl/cli_drc.py @@ -2,17 +2,18 @@ Design Rule Check (DRC) implementation using KiCad command-line interface. """ -import os import json +import os import subprocess import tempfile -from typing import Dict, Any, Optional +from typing import Any + from mcp.server.fastmcp import Context from kicad_mcp.config import system -async def run_drc_via_cli(pcb_file: str, ctx: Context) -> Dict[str, Any]: +async def run_drc_via_cli(pcb_file: str, ctx: Context) -> dict[str, Any]: """Run DRC using KiCad command line tools. Args: @@ -63,7 +64,7 @@ async def run_drc_via_cli(pcb_file: str, ctx: Context) -> Dict[str, Any]: return results # Read the DRC report - with open(output_file, "r") as f: + with open(output_file) as f: try: drc_report = json.load(f) except json.JSONDecodeError: @@ -105,7 +106,7 @@ async def run_drc_via_cli(pcb_file: str, ctx: Context) -> Dict[str, Any]: return results -def find_kicad_cli() -> Optional[str]: +def find_kicad_cli() -> str | None: """Find the kicad-cli executable in the system PATH. Returns: diff --git a/kicad_mcp/tools/drc_tools.py b/kicad_mcp/tools/drc_tools.py index dc5ab97..b05064f 100644 --- a/kicad_mcp/tools/drc_tools.py +++ b/kicad_mcp/tools/drc_tools.py @@ -5,14 +5,14 @@ Design Rule Check (DRC) tools for KiCad PCB files. import os # import logging # <-- Remove if no other logging exists -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context +from typing import Any -from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.drc_history import save_drc_result, get_drc_history, compare_with_previous +from mcp.server.fastmcp import FastMCP # Import implementations from kicad_mcp.tools.drc_impl.cli_drc import run_drc_via_cli +from kicad_mcp.utils.drc_history import compare_with_previous, get_drc_history, save_drc_result +from kicad_mcp.utils.file_utils import get_project_files def register_drc_tools(mcp: FastMCP) -> None: @@ -23,7 +23,7 @@ def register_drc_tools(mcp: FastMCP) -> None: """ @mcp.tool() - def get_drc_history_tool(project_path: str) -> Dict[str, Any]: + def get_drc_history_tool(project_path: str) -> dict[str, Any]: """Get the DRC check history for a KiCad project. Args: @@ -66,7 +66,7 @@ def register_drc_tools(mcp: FastMCP) -> None: } @mcp.tool() - def run_drc_check(project_path: str) -> Dict[str, Any]: + def run_drc_check(project_path: str) -> dict[str, Any]: """Run a Design Rule Check on a KiCad PCB file. Args: @@ -119,7 +119,7 @@ def register_drc_tools(mcp: FastMCP) -> None: elif comparison["change"] > 0: print(f"Found {comparison['change']} new DRC violations since the last check.") else: - print(f"No change in the number of DRC violations since the last check.") + print("No change in the number of DRC violations since the last check.") elif drc_results: # logging.warning(f"[DRC] DRC check reported failure for {pcb_file}: {drc_results.get('error')}") # <-- Remove log # Pass or print a warning if needed diff --git a/kicad_mcp/tools/export_tools.py b/kicad_mcp/tools/export_tools.py index 93afffe..24d9b07 100644 --- a/kicad_mcp/tools/export_tools.py +++ b/kicad_mcp/tools/export_tools.py @@ -2,16 +2,15 @@ Export tools for KiCad projects. """ -import os -import tempfile -import subprocess -import shutil import asyncio -from typing import Dict, Any, Optional -from mcp.server.fastmcp import FastMCP, Context, Image +import os +import shutil +import subprocess + +from mcp.server.fastmcp import Context, FastMCP, Image -from kicad_mcp.utils.file_utils import get_project_files from kicad_mcp.config import KICAD_APP_PATH, system +from kicad_mcp.utils.file_utils import get_project_files def register_export_tools(mcp: FastMCP) -> None: diff --git a/kicad_mcp/tools/layer_tools.py b/kicad_mcp/tools/layer_tools.py index 841e9e6..913c738 100644 --- a/kicad_mcp/tools/layer_tools.py +++ b/kicad_mcp/tools/layer_tools.py @@ -5,22 +5,19 @@ Provides MCP tools for analyzing PCB layer configurations, impedance calculation and manufacturing constraints for multi-layer board designs. """ -import json -from typing import Any, Dict, List +from typing import Any from fastmcp import FastMCP -from kicad_mcp.utils.layer_stackup import ( - create_stackup_analyzer, - LayerStackupAnalyzer -) + +from kicad_mcp.utils.layer_stackup import create_stackup_analyzer from kicad_mcp.utils.path_validator import validate_kicad_file def register_layer_tools(mcp: FastMCP) -> None: """Register layer stack-up analysis tools with the MCP server.""" - + @mcp.tool() - def analyze_pcb_stackup(pcb_file_path: str) -> Dict[str, Any]: + def analyze_pcb_stackup(pcb_file_path: str) -> dict[str, Any]: """ Analyze PCB layer stack-up configuration and properties. @@ -36,30 +33,30 @@ def register_layer_tools(mcp: FastMCP) -> None: try: # Validate PCB file validated_path = validate_kicad_file(pcb_file_path, "pcb") - + # Create analyzer and perform analysis analyzer = create_stackup_analyzer() stackup = analyzer.analyze_pcb_stackup(validated_path) - + # Generate comprehensive report report = analyzer.generate_stackup_report(stackup) - + return { "success": True, "pcb_file": validated_path, "stackup_analysis": report } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def calculate_trace_impedance(pcb_file_path: str, trace_width: float, - layer_name: str = None, spacing: float = None) -> Dict[str, Any]: + def calculate_trace_impedance(pcb_file_path: str, trace_width: float, + layer_name: str = None, spacing: float = None) -> dict[str, Any]: """ Calculate characteristic impedance for specific trace configurations. @@ -81,13 +78,13 @@ def register_layer_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = create_stackup_analyzer() stackup = analyzer.analyze_pcb_stackup(validated_path) - + # Filter signal layers signal_layers = [l for l in stackup.layers if l.layer_type == "signal"] - + if layer_name: signal_layers = [l for l in signal_layers if l.name == layer_name] if not signal_layers: @@ -95,25 +92,25 @@ def register_layer_tools(mcp: FastMCP) -> None: "success": False, "error": f"Layer '{layer_name}' not found or not a signal layer" } - + impedance_results = [] - + for layer in signal_layers: # Calculate single-ended impedance single_ended = analyzer.impedance_calculator.calculate_microstrip_impedance( trace_width, layer, stackup.layers ) - + # Calculate differential impedance if spacing provided differential = None if spacing is not None: differential = analyzer.impedance_calculator.calculate_differential_impedance( trace_width, spacing, layer, stackup.layers ) - + # Find reference layers ref_layers = analyzer._find_reference_layers(layer, stackup.layers) - + impedance_results.append({ "layer_name": layer.name, "trace_width_mm": trace_width, @@ -124,7 +121,7 @@ def register_layer_tools(mcp: FastMCP) -> None: "dielectric_thickness_mm": _get_dielectric_thickness(layer, stackup.layers), "dielectric_constant": _get_dielectric_constant(layer, stackup.layers) }) - + # Generate recommendations recommendations = [] for result in impedance_results: @@ -135,7 +132,7 @@ def register_layer_tools(mcp: FastMCP) -> None: recommendations.append(f"Increase trace width on {result['layer_name']} to reduce impedance") else: recommendations.append(f"Decrease trace width on {result['layer_name']} to increase impedance") - + return { "success": True, "pcb_file": validated_path, @@ -146,14 +143,14 @@ def register_layer_tools(mcp: FastMCP) -> None: }, "recommendations": recommendations } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + def _get_dielectric_thickness(self, signal_layer, layers): """Get thickness of dielectric layer below signal layer.""" try: @@ -164,7 +161,7 @@ def register_layer_tools(mcp: FastMCP) -> None: return None except (ValueError, IndexError): return None - + def _get_dielectric_constant(self, signal_layer, layers): """Get dielectric constant of layer below signal layer.""" try: @@ -175,9 +172,9 @@ def register_layer_tools(mcp: FastMCP) -> None: return None except (ValueError, IndexError): return None - + @mcp.tool() - def validate_stackup_manufacturing(pcb_file_path: str) -> Dict[str, Any]: + def validate_stackup_manufacturing(pcb_file_path: str) -> dict[str, Any]: """ Validate PCB stack-up against manufacturing constraints. @@ -192,19 +189,19 @@ def register_layer_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = create_stackup_analyzer() stackup = analyzer.analyze_pcb_stackup(validated_path) - + # Validate stack-up validation_issues = analyzer.validate_stackup(stackup) - + # Check additional manufacturing constraints manufacturing_checks = self._perform_manufacturing_checks(stackup) - + # Combine all issues all_issues = validation_issues + manufacturing_checks["issues"] - + return { "success": True, "pcb_file": validated_path, @@ -227,37 +224,37 @@ def register_layer_tools(mcp: FastMCP) -> None: "cost_implications": self._assess_cost_implications(stackup), "recommendations": stackup.manufacturing_notes + manufacturing_checks["recommendations"] } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + def _perform_manufacturing_checks(self, stackup): """Perform additional manufacturing feasibility checks.""" issues = [] recommendations = [] - + # Check aspect ratio for drilling copper_thickness = sum(l.thickness for l in stackup.layers if l.copper_weight) max_drill_depth = stackup.total_thickness min_drill_diameter = stackup.constraints.min_via_drill - + aspect_ratio = max_drill_depth / min_drill_diameter if aspect_ratio > stackup.constraints.aspect_ratio_limit: issues.append(f"Aspect ratio {aspect_ratio:.1f}:1 exceeds manufacturing limit") recommendations.append("Consider using buried/blind vias or increasing minimum drill size") - + # Check copper balance top_half_copper = sum(l.thickness for l in stackup.layers[:len(stackup.layers)//2] if l.copper_weight) bottom_half_copper = sum(l.thickness for l in stackup.layers[len(stackup.layers)//2:] if l.copper_weight) - + if abs(top_half_copper - bottom_half_copper) / max(top_half_copper, bottom_half_copper) > 0.4: issues.append("Copper distribution imbalance may cause board warpage") recommendations.append("Redistribute copper or add balancing copper fills") - + # Assess manufacturing complexity complexity_factors = [] if stackup.layer_count > 6: @@ -266,38 +263,38 @@ def register_layer_tools(mcp: FastMCP) -> None: complexity_factors.append("Thick board") if len(set(l.material for l in stackup.layers if l.layer_type == "dielectric")) > 1: complexity_factors.append("Mixed dielectric materials") - + assessment = "Standard" if not complexity_factors else f"Complex ({', '.join(complexity_factors)})" - + return { "issues": issues, "recommendations": recommendations, "assessment": assessment } - + def _assess_cost_implications(self, stackup): """Assess cost implications of the stack-up design.""" cost_factors = [] cost_multiplier = 1.0 - + # Layer count impact if stackup.layer_count > 4: cost_multiplier *= (1.0 + (stackup.layer_count - 4) * 0.15) cost_factors.append(f"{stackup.layer_count}-layer design increases cost") - + # Thickness impact if stackup.total_thickness > 1.6: cost_multiplier *= 1.1 cost_factors.append("Non-standard thickness increases cost") - + # Material impact premium_materials = ["Rogers", "Polyimide"] if any(material in str(stackup.layers) for material in premium_materials): cost_multiplier *= 1.3 cost_factors.append("Premium materials increase cost significantly") - + cost_category = "Low" if cost_multiplier < 1.2 else "Medium" if cost_multiplier < 1.5 else "High" - + return { "cost_category": cost_category, "cost_multiplier": round(cost_multiplier, 2), @@ -308,10 +305,10 @@ def register_layer_tools(mcp: FastMCP) -> None: "Optimize thickness to standard values (1.6mm typical)" ] if cost_multiplier > 1.3 else ["Current design is cost-optimized"] } - + @mcp.tool() def optimize_stackup_for_impedance(pcb_file_path: str, target_impedance: float = 50.0, - differential_target: float = 100.0) -> Dict[str, Any]: + differential_target: float = 100.0) -> dict[str, Any]: """ Optimize stack-up configuration for target impedance values. @@ -328,26 +325,26 @@ def register_layer_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = create_stackup_analyzer() stackup = analyzer.analyze_pcb_stackup(validated_path) - + optimization_results = [] - + # Analyze each signal layer signal_layers = [l for l in stackup.layers if l.layer_type == "signal"] - + for layer in signal_layers: layer_optimization = self._optimize_layer_impedance( layer, stackup.layers, analyzer, target_impedance, differential_target ) optimization_results.append(layer_optimization) - + # Generate overall recommendations overall_recommendations = self._generate_impedance_recommendations( optimization_results, target_impedance, differential_target ) - + return { "success": True, "pcb_file": validated_path, @@ -364,22 +361,22 @@ def register_layer_tools(mcp: FastMCP) -> None: "Update design rules after stack-up modifications" ] } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + def _optimize_layer_impedance(self, layer, layers, analyzer, target_se, target_diff): """Optimize impedance for a specific layer.""" current_impedances = [] optimized_suggestions = [] - + # Test different trace widths test_widths = [0.08, 0.1, 0.125, 0.15, 0.2, 0.25, 0.3] - + for width in test_widths: se_impedance = analyzer.impedance_calculator.calculate_microstrip_impedance( width, layer, layers @@ -387,7 +384,7 @@ def register_layer_tools(mcp: FastMCP) -> None: diff_impedance = analyzer.impedance_calculator.calculate_differential_impedance( width, 0.15, layer, layers # 0.15mm spacing ) - + if se_impedance: current_impedances.append({ "trace_width_mm": width, @@ -396,12 +393,12 @@ def register_layer_tools(mcp: FastMCP) -> None: "se_error": abs(se_impedance - target_se), "diff_error": abs(diff_impedance - target_diff) if diff_impedance else None }) - + # Find best matches best_se = min(current_impedances, key=lambda x: x["se_error"]) if current_impedances else None - best_diff = min([x for x in current_impedances if x["diff_error"] is not None], + best_diff = min([x for x in current_impedances if x["diff_error"] is not None], key=lambda x: x["diff_error"]) if any(x["diff_error"] is not None for x in current_impedances) else None - + return { "layer_name": layer.name, "current_impedances": current_impedances, @@ -411,49 +408,49 @@ def register_layer_tools(mcp: FastMCP) -> None: layer, best_se, best_diff, target_se, target_diff ) } - + def _generate_layer_optimization_notes(self, layer, best_se, best_diff, target_se, target_diff): """Generate optimization notes for a specific layer.""" notes = [] - + if best_se and abs(best_se["se_error"]) > 5: notes.append(f"Difficult to achieve {target_se}Ω on {layer.name} with current stack-up") notes.append("Consider adjusting dielectric thickness or material") - + if best_diff and best_diff["diff_error"] and abs(best_diff["diff_error"]) > 10: notes.append(f"Difficult to achieve {target_diff}Ω differential on {layer.name}") notes.append("Consider adjusting trace spacing or dielectric properties") - + return notes - + def _generate_impedance_recommendations(self, optimization_results, target_se, target_diff): """Generate overall impedance optimization recommendations.""" recommendations = [] - + # Check if any layers have poor impedance control poor_control_layers = [] for result in optimization_results: if result["recommended_for_single_ended"] and result["recommended_for_single_ended"]["se_error"] > 5: poor_control_layers.append(result["layer_name"]) - + if poor_control_layers: recommendations.append(f"Layers with poor impedance control: {', '.join(poor_control_layers)}") recommendations.append("Consider stack-up redesign or use impedance-optimized prepregs") - + # Check for consistent trace widths trace_widths = set() for result in optimization_results: if result["recommended_for_single_ended"]: trace_widths.add(result["recommended_for_single_ended"]["trace_width_mm"]) - + if len(trace_widths) > 2: recommendations.append("Multiple trace widths needed - consider design rule complexity") - + return recommendations - + @mcp.tool() - def compare_stackup_alternatives(pcb_file_path: str, - alternative_configs: List[Dict[str, Any]] = None) -> Dict[str, Any]: + def compare_stackup_alternatives(pcb_file_path: str, + alternative_configs: list[dict[str, Any]] = None) -> dict[str, Any]: """ Compare different stack-up alternatives for the same design. @@ -469,16 +466,16 @@ def register_layer_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = create_stackup_analyzer() current_stackup = analyzer.analyze_pcb_stackup(validated_path) - + # Generate standard alternatives if none provided if not alternative_configs: alternative_configs = self._generate_standard_alternatives(current_stackup) - + comparison_results = [] - + # Analyze current stackup current_analysis = { "name": "Current Design", @@ -487,23 +484,23 @@ def register_layer_tools(mcp: FastMCP) -> None: "score": self._calculate_stackup_score(current_stackup, analyzer) } comparison_results.append(current_analysis) - + # Analyze alternatives for i, config in enumerate(alternative_configs): alt_stackup = self._create_alternative_stackup(current_stackup, config) alt_report = analyzer.generate_stackup_report(alt_stackup) alt_score = self._calculate_stackup_score(alt_stackup, analyzer) - + comparison_results.append({ "name": config.get("name", f"Alternative {i+1}"), "stackup": alt_stackup, "report": alt_report, "score": alt_score }) - + # Rank alternatives ranked_results = sorted(comparison_results, key=lambda x: x["score"]["total"], reverse=True) - + return { "success": True, "pcb_file": validated_path, @@ -529,20 +526,20 @@ def register_layer_tools(mcp: FastMCP) -> None: "reasoning": self._generate_recommendation_reasoning(ranked_results) } } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + def _generate_standard_alternatives(self, current_stackup): """Generate standard alternative stack-up configurations.""" alternatives = [] - + current_layers = current_stackup.layer_count - + # 4-layer alternative (if current is different) if current_layers != 4: alternatives.append({ @@ -550,7 +547,7 @@ def register_layer_tools(mcp: FastMCP) -> None: "layer_count": 4, "description": "Standard 4-layer stack-up for cost optimization" }) - + # 6-layer alternative (if current is different and > 4) if current_layers > 4 and current_layers != 6: alternatives.append({ @@ -558,7 +555,7 @@ def register_layer_tools(mcp: FastMCP) -> None: "layer_count": 6, "description": "6-layer stack-up for improved power distribution" }) - + # High-performance alternative if current_layers <= 8: alternatives.append({ @@ -566,9 +563,9 @@ def register_layer_tools(mcp: FastMCP) -> None: "layer_count": min(current_layers + 2, 10), "description": "Additional layers for better signal integrity" }) - + return alternatives - + def _create_alternative_stackup(self, base_stackup, config): """Create an alternative stack-up based on configuration.""" # This is a simplified implementation - in practice, you'd need @@ -576,75 +573,75 @@ def register_layer_tools(mcp: FastMCP) -> None: alt_stackup = base_stackup # For now, return the same stack-up # TODO: Implement actual alternative stack-up generation return alt_stackup - + def _calculate_stackup_score(self, stackup, analyzer): """Calculate overall score for stack-up quality.""" # Cost score (lower is better, invert for scoring) cost_score = 100 - min(stackup.layer_count * 5, 50) # Penalize high layer count - + # Performance score performance_score = 70 # Base score if stackup.layer_count >= 4: performance_score += 20 # Dedicated power planes if stackup.total_thickness < 2.0: performance_score += 10 # Good for high-frequency - + # Manufacturing score validation_issues = analyzer.validate_stackup(stackup) manufacturing_score = 100 - len(validation_issues) * 10 - + total_score = (cost_score * 0.3 + performance_score * 0.4 + manufacturing_score * 0.3) - + return { "total": round(total_score, 1), "cost": cost_score, "performance": performance_score, "manufacturing": manufacturing_score } - + def _identify_advantages(self, result, all_results): """Identify key advantages of a stack-up configuration.""" advantages = [] - + if result["score"]["cost"] == max(r["score"]["cost"] for r in all_results): advantages.append("Lowest cost option") - + if result["score"]["performance"] == max(r["score"]["performance"] for r in all_results): advantages.append("Best performance characteristics") - + if result["report"]["validation"]["passed"]: advantages.append("Passes all manufacturing validation") - + return advantages[:3] # Limit to top 3 advantages - + def _identify_disadvantages(self, result, all_results): """Identify key disadvantages of a stack-up configuration.""" disadvantages = [] - + if result["score"]["cost"] == min(r["score"]["cost"] for r in all_results): disadvantages.append("Highest cost option") - + if not result["report"]["validation"]["passed"]: disadvantages.append("Has manufacturing validation issues") - + if result["stackup"].layer_count > 8: disadvantages.append("Complex manufacturing due to high layer count") - + return disadvantages[:3] # Limit to top 3 disadvantages - + def _generate_recommendation_reasoning(self, ranked_results): """Generate reasoning for the recommendation.""" best = ranked_results[0] reasoning = f"'{best['name']}' is recommended due to its high overall score ({best['score']['total']:.1f}/100). " - + if best["report"]["validation"]["passed"]: reasoning += "It passes all manufacturing validation checks and " - + if best["score"]["cost"] > 70: reasoning += "offers good cost efficiency." elif best["score"]["performance"] > 80: reasoning += "provides excellent performance characteristics." else: reasoning += "offers the best balance of cost, performance, and manufacturability." - - return reasoning \ No newline at end of file + + return reasoning diff --git a/kicad_mcp/tools/model3d_tools.py b/kicad_mcp/tools/model3d_tools.py index 99f7f6c..d376ade 100644 --- a/kicad_mcp/tools/model3d_tools.py +++ b/kicad_mcp/tools/model3d_tools.py @@ -6,22 +6,23 @@ and visualization data from KiCad PCB files. """ import json -from typing import Any, Dict +from typing import Any from fastmcp import FastMCP + from kicad_mcp.utils.model3d_analyzer import ( + Model3DAnalyzer, analyze_pcb_3d_models, get_mechanical_constraints, - Model3DAnalyzer ) from kicad_mcp.utils.path_validator import validate_kicad_file def register_model3d_tools(mcp: FastMCP) -> None: """Register 3D model analysis tools with the MCP server.""" - + @mcp.tool() - def analyze_3d_models(pcb_file_path: str) -> Dict[str, Any]: + def analyze_3d_models(pcb_file_path: str) -> dict[str, Any]: """ Analyze 3D models and mechanical aspects of a KiCad PCB file. @@ -46,25 +47,25 @@ def register_model3d_tools(mcp: FastMCP) -> None: try: # Validate the PCB file path validated_path = validate_kicad_file(pcb_file_path, "pcb") - + # Perform 3D analysis result = analyze_pcb_3d_models(validated_path) - + return { "success": True, "pcb_file": validated_path, "analysis": result } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def check_mechanical_constraints(pcb_file_path: str) -> Dict[str, Any]: + def check_mechanical_constraints(pcb_file_path: str) -> dict[str, Any]: """ Check mechanical constraints and clearances in a KiCad PCB. @@ -84,22 +85,22 @@ def register_model3d_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + # Perform mechanical analysis analysis = get_mechanical_constraints(validated_path) - + # Generate recommendations recommendations = [] - + if analysis.height_analysis["max"] > 5.0: recommendations.append("Consider using lower profile components to reduce board height") - + if len(analysis.clearance_violations) > 0: recommendations.append("Review component placement to resolve clearance violations") - + if analysis.board_dimensions.width > 80 or analysis.board_dimensions.height > 80: recommendations.append("Large board size may increase manufacturing costs") - + return { "success": True, "pcb_file": validated_path, @@ -124,16 +125,16 @@ def register_model3d_tools(mcp: FastMCP) -> None: "recommendations": recommendations, "component_count": len(analysis.components) } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def generate_3d_visualization_json(pcb_file_path: str, output_path: str = None) -> Dict[str, Any]: + def generate_3d_visualization_json(pcb_file_path: str, output_path: str = None) -> dict[str, Any]: """ Generate JSON data file for 3D visualization of PCB. @@ -150,18 +151,18 @@ def register_model3d_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + # Generate visualization data viz_data = analyze_pcb_3d_models(validated_path) - + # Determine output path if not output_path: output_path = validated_path.replace('.kicad_pcb', '_3d_viz.json') - + # Save visualization data with open(output_path, 'w', encoding='utf-8') as f: json.dump(viz_data, f, indent=2) - + return { "success": True, "pcb_file": validated_path, @@ -170,16 +171,16 @@ def register_model3d_tools(mcp: FastMCP) -> None: "models_found": viz_data.get("stats", {}).get("components_with_3d_models", 0), "board_size": f"{viz_data.get('board_dimensions', {}).get('width', 0):.1f}x{viz_data.get('board_dimensions', {}).get('height', 0):.1f}mm" } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def component_height_distribution(pcb_file_path: str) -> Dict[str, Any]: + def component_height_distribution(pcb_file_path: str) -> dict[str, Any]: """ Analyze the height distribution of components on a PCB. @@ -194,11 +195,11 @@ def register_model3d_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = Model3DAnalyzer(validated_path) components = analyzer.extract_3d_components() height_analysis = analyzer.analyze_component_heights(components) - + # Categorize components by height height_categories = { "very_low": [], # < 1mm @@ -207,10 +208,10 @@ def register_model3d_tools(mcp: FastMCP) -> None: "high": [], # 5-10mm "very_high": [] # > 10mm } - + for comp in components: height = analyzer._estimate_component_height(comp) - + if height < 1.0: height_categories["very_low"].append((comp.reference, height)) elif height < 2.0: @@ -221,18 +222,18 @@ def register_model3d_tools(mcp: FastMCP) -> None: height_categories["high"].append((comp.reference, height)) else: height_categories["very_high"].append((comp.reference, height)) - + return { "success": True, "pcb_file": validated_path, "height_statistics": height_analysis, "height_categories": { - category: [{"component": ref, "height_mm": height} + category: [{"component": ref, "height_mm": height} for ref, height in components] for category, components in height_categories.items() }, "tallest_components": sorted( - [(comp.reference, analyzer._estimate_component_height(comp)) + [(comp.reference, analyzer._estimate_component_height(comp)) for comp in components], key=lambda x: x[1], reverse=True )[:10], # Top 10 tallest components @@ -241,16 +242,16 @@ def register_model3d_tools(mcp: FastMCP) -> None: "recommended_height_mm": height_analysis["max"] + 5.0 # Add 5mm clearance } } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path } - + @mcp.tool() - def check_assembly_feasibility(pcb_file_path: str) -> Dict[str, Any]: + def check_assembly_feasibility(pcb_file_path: str) -> dict[str, Any]: """ Analyze PCB assembly feasibility and identify potential issues. @@ -265,14 +266,14 @@ def register_model3d_tools(mcp: FastMCP) -> None: """ try: validated_path = validate_kicad_file(pcb_file_path, "pcb") - + analyzer = Model3DAnalyzer(validated_path) mechanical_analysis = analyzer.perform_mechanical_analysis() components = mechanical_analysis.components - + assembly_issues = [] assembly_warnings = [] - + # Check for components too close to board edge for comp in components: edge_distance = analyzer._distance_to_board_edge( @@ -284,7 +285,7 @@ def register_model3d_tools(mcp: FastMCP) -> None: "issue": f"Component only {edge_distance:.2f}mm from board edge", "recommendation": "Consider moving component away from edge for easier assembly" }) - + # Check for very small components that might be hard to place small_component_footprints = ["0201", "0402"] for comp in components: @@ -294,19 +295,19 @@ def register_model3d_tools(mcp: FastMCP) -> None: "issue": f"Very small footprint {comp.footprint}", "recommendation": "Verify pick-and-place machine compatibility" }) - + # Check component density - board_area = (mechanical_analysis.board_dimensions.width * + board_area = (mechanical_analysis.board_dimensions.width * mechanical_analysis.board_dimensions.height) component_density = len(components) / (board_area / 100) # Components per cm² - + if component_density > 5.0: assembly_warnings.append({ "component": "Board", "issue": f"High component density: {component_density:.1f} components/cm²", "recommendation": "Consider larger board or fewer components for easier assembly" }) - + return { "success": True, "pcb_file": validated_path, @@ -325,10 +326,10 @@ def register_model3d_tools(mcp: FastMCP) -> None: "Consider component orientation for consistent placement direction" ] if assembly_warnings else ["PCB appears suitable for standard assembly processes"] } - + except Exception as e: return { "success": False, "error": str(e), "pcb_file": pcb_file_path - } \ No newline at end of file + } diff --git a/kicad_mcp/tools/netlist_tools.py b/kicad_mcp/tools/netlist_tools.py index 8f2d8dc..c588533 100644 --- a/kicad_mcp/tools/netlist_tools.py +++ b/kicad_mcp/tools/netlist_tools.py @@ -3,11 +3,12 @@ Netlist extraction and analysis tools for KiCad schematics. """ import os -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.netlist_parser import extract_netlist, analyze_netlist +from kicad_mcp.utils.netlist_parser import analyze_netlist, extract_netlist def register_netlist_tools(mcp: FastMCP) -> None: @@ -18,7 +19,7 @@ def register_netlist_tools(mcp: FastMCP) -> None: """ @mcp.tool() - async def extract_schematic_netlist(schematic_path: str, ctx: Context) -> Dict[str, Any]: + async def extract_schematic_netlist(schematic_path: str, ctx: Context) -> dict[str, Any]: """Extract netlist information from a KiCad schematic. This tool parses a KiCad schematic file and extracts comprehensive @@ -90,7 +91,7 @@ def register_netlist_tools(mcp: FastMCP) -> None: return {"success": False, "error": str(e)} @mcp.tool() - async def extract_project_netlist(project_path: str, ctx: Context) -> Dict[str, Any]: + async def extract_project_netlist(project_path: str, ctx: Context) -> dict[str, Any]: """Extract netlist from a KiCad project's schematic. This tool finds the schematic associated with a KiCad project @@ -144,7 +145,7 @@ def register_netlist_tools(mcp: FastMCP) -> None: return {"success": False, "error": str(e)} @mcp.tool() - async def analyze_schematic_connections(schematic_path: str, ctx: Context) -> Dict[str, Any]: + async def analyze_schematic_connections(schematic_path: str, ctx: Context) -> dict[str, Any]: """Analyze connections in a KiCad schematic. This tool provides detailed analysis of component connections, @@ -256,7 +257,7 @@ def register_netlist_tools(mcp: FastMCP) -> None: @mcp.tool() async def find_component_connections( project_path: str, component_ref: str, ctx: Context - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Find all connections for a specific component in a KiCad project. This tool extracts information about how a specific component diff --git a/kicad_mcp/tools/pattern_tools.py b/kicad_mcp/tools/pattern_tools.py index c92f6a4..a85de6a 100644 --- a/kicad_mcp/tools/pattern_tools.py +++ b/kicad_mcp/tools/pattern_tools.py @@ -3,18 +3,19 @@ Circuit pattern recognition tools for KiCad schematics. """ import os -from typing import Dict, List, Any, Optional -from mcp.server.fastmcp import FastMCP, Context +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP from kicad_mcp.utils.file_utils import get_project_files -from kicad_mcp.utils.netlist_parser import extract_netlist, analyze_netlist +from kicad_mcp.utils.netlist_parser import analyze_netlist, extract_netlist from kicad_mcp.utils.pattern_recognition import ( - identify_power_supplies, identify_amplifiers, - identify_filters, - identify_oscillators, identify_digital_interfaces, + identify_filters, identify_microcontrollers, + identify_oscillators, + identify_power_supplies, identify_sensor_interfaces, ) @@ -27,7 +28,7 @@ def register_pattern_tools(mcp: FastMCP) -> None: """ @mcp.tool() - async def identify_circuit_patterns(schematic_path: str, ctx: Context) -> Dict[str, Any]: + async def identify_circuit_patterns(schematic_path: str, ctx: Context) -> dict[str, Any]: """Identify common circuit patterns in a KiCad schematic. This tool analyzes a schematic to recognize common circuit blocks such as: @@ -141,7 +142,7 @@ def register_pattern_tools(mcp: FastMCP) -> None: return {"success": False, "error": str(e)} @mcp.tool() - def analyze_project_circuit_patterns(project_path: str) -> Dict[str, Any]: + def analyze_project_circuit_patterns(project_path: str) -> dict[str, Any]: """Identify circuit patterns in a KiCad project's schematic. Args: @@ -172,7 +173,7 @@ def register_pattern_tools(mcp: FastMCP) -> None: return {"success": False, "error": "Failed to extract netlist from schematic"} components, nets = analyze_netlist(netlist_data) - + # Identify patterns identified_patterns = {} identified_patterns["power_supply_circuits"] = identify_power_supplies(components, nets) diff --git a/kicad_mcp/tools/project_tools.py b/kicad_mcp/tools/project_tools.py index 3e46a04..0a9608b 100644 --- a/kicad_mcp/tools/project_tools.py +++ b/kicad_mcp/tools/project_tools.py @@ -2,13 +2,14 @@ Project management tools for KiCad. """ -import os import logging -from typing import Dict, List, Any +import os +from typing import Any + from mcp.server.fastmcp import FastMCP -from kicad_mcp.utils.kicad_utils import find_kicad_projects, open_kicad_project from kicad_mcp.utils.file_utils import get_project_files, load_project_json +from kicad_mcp.utils.kicad_utils import find_kicad_projects, open_kicad_project # Get PID for logging # _PID = os.getpid() @@ -22,15 +23,15 @@ def register_project_tools(mcp: FastMCP) -> None: """ @mcp.tool() - def list_projects() -> List[Dict[str, Any]]: + def list_projects() -> list[dict[str, Any]]: """Find and list all KiCad projects on this system.""" - logging.info(f"Executing list_projects tool...") + logging.info("Executing list_projects tool...") projects = find_kicad_projects() logging.info(f"list_projects tool returning {len(projects)} projects.") return projects @mcp.tool() - def get_project_structure(project_path: str) -> Dict[str, Any]: + def get_project_structure(project_path: str) -> dict[str, Any]: """Get the structure and files of a KiCad project.""" if not os.path.exists(project_path): return {"error": f"Project not found: {project_path}"} @@ -56,6 +57,6 @@ def register_project_tools(mcp: FastMCP) -> None: } @mcp.tool() - def open_project(project_path: str) -> Dict[str, Any]: + def open_project(project_path: str) -> dict[str, Any]: """Open a KiCad project in KiCad.""" return open_kicad_project(project_path) diff --git a/kicad_mcp/tools/symbol_tools.py b/kicad_mcp/tools/symbol_tools.py index 6d617f2..f26c222 100644 --- a/kicad_mcp/tools/symbol_tools.py +++ b/kicad_mcp/tools/symbol_tools.py @@ -5,23 +5,19 @@ Provides MCP tools for analyzing, validating, and managing KiCad symbol librarie including library analysis, symbol validation, and organization recommendations. """ -import json import os -from typing import Any, Dict, List +from typing import Any from fastmcp import FastMCP -from kicad_mcp.utils.symbol_library import ( - create_symbol_analyzer, - SymbolLibraryAnalyzer -) -from kicad_mcp.utils.path_validator import validate_path + +from kicad_mcp.utils.symbol_library import create_symbol_analyzer def register_symbol_tools(mcp: FastMCP) -> None: """Register symbol library management tools with the MCP server.""" - + @mcp.tool() - def analyze_symbol_library(library_path: str) -> Dict[str, Any]: + def analyze_symbol_library(library_path: str) -> dict[str, Any]: """ Analyze a KiCad symbol library file for coverage, statistics, and issues. @@ -45,35 +41,35 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {library_path}" } - + if not library_path.endswith('.kicad_sym'): return { "success": False, "error": "File must be a KiCad symbol library (.kicad_sym)" } - + # Create analyzer and load library analyzer = create_symbol_analyzer() library = analyzer.load_library(library_path) - + # Generate comprehensive report report = analyzer.export_symbol_report(library) - + return { "success": True, "library_path": library_path, "report": report } - + except Exception as e: return { "success": False, "error": str(e), "library_path": library_path } - + @mcp.tool() - def validate_symbol_library(library_path: str) -> Dict[str, Any]: + def validate_symbol_library(library_path: str) -> dict[str, Any]: """ Validate symbols in a KiCad library and report issues. @@ -92,14 +88,14 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {library_path}" } - + analyzer = create_symbol_analyzer() library = analyzer.load_library(library_path) - + # Validate all symbols validation_results = [] total_issues = 0 - + for symbol in library.symbols: issues = analyzer.validate_symbol(symbol) if issues: @@ -110,7 +106,7 @@ def register_symbol_tools(mcp: FastMCP) -> None: "severity": "error" if any("Missing essential" in issue for issue in issues) else "warning" }) total_issues += len(issues) - + return { "success": True, "library_path": library_path, @@ -128,17 +124,17 @@ def register_symbol_tools(mcp: FastMCP) -> None: "Add meaningful pin names for better usability" ] if validation_results else ["All symbols pass validation checks"] } - + except Exception as e: return { "success": False, "error": str(e), "library_path": library_path } - + @mcp.tool() - def find_similar_symbols(library_path: str, symbol_name: str, - similarity_threshold: float = 0.7) -> Dict[str, Any]: + def find_similar_symbols(library_path: str, symbol_name: str, + similarity_threshold: float = 0.7) -> dict[str, Any]: """ Find symbols similar to a specified symbol in the library. @@ -159,28 +155,28 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {library_path}" } - + analyzer = create_symbol_analyzer() library = analyzer.load_library(library_path) - + # Find target symbol target_symbol = None for symbol in library.symbols: if symbol.name == symbol_name: target_symbol = symbol break - + if not target_symbol: return { "success": False, "error": f"Symbol '{symbol_name}' not found in library" } - + # Find similar symbols similar_symbols = analyzer.find_similar_symbols( target_symbol, library, similarity_threshold ) - + similar_list = [] for symbol, score in similar_symbols: similar_list.append({ @@ -195,7 +191,7 @@ def register_symbol_tools(mcp: FastMCP) -> None: "missing_keywords": list(set(target_symbol.keywords) - set(symbol.keywords)) } }) - + return { "success": True, "library_path": library_path, @@ -209,16 +205,16 @@ def register_symbol_tools(mcp: FastMCP) -> None: "similarity_threshold": similarity_threshold, "matches_found": len(similar_list) } - + except Exception as e: return { "success": False, "error": str(e), "library_path": library_path } - + @mcp.tool() - def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]: + def get_symbol_details(library_path: str, symbol_name: str) -> dict[str, Any]: """ Get detailed information about a specific symbol in a library. @@ -238,23 +234,23 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {library_path}" } - + analyzer = create_symbol_analyzer() library = analyzer.load_library(library_path) - + # Find target symbol target_symbol = None for symbol in library.symbols: if symbol.name == symbol_name: target_symbol = symbol break - + if not target_symbol: return { "success": False, "error": f"Symbol '{symbol_name}' not found in library" } - + # Extract detailed information pin_details = [] for pin in target_symbol.pins: @@ -267,7 +263,7 @@ def register_symbol_tools(mcp: FastMCP) -> None: "graphic_style": pin.graphic_style, "length_mm": pin.length }) - + property_details = [] for prop in target_symbol.properties: property_details.append({ @@ -277,10 +273,10 @@ def register_symbol_tools(mcp: FastMCP) -> None: "rotation": prop.rotation, "visible": prop.visible }) - + # Validate symbol validation_issues = analyzer.validate_symbol(target_symbol) - + return { "success": True, "library_path": library_path, @@ -306,22 +302,22 @@ def register_symbol_tools(mcp: FastMCP) -> None: "issues": validation_issues }, "statistics": { - "electrical_types": {etype: len([p for p in target_symbol.pins if p.electrical_type == etype]) + "electrical_types": {etype: len([p for p in target_symbol.pins if p.electrical_type == etype]) for etype in set(p.electrical_type for p in target_symbol.pins)}, - "pin_orientations": {orient: len([p for p in target_symbol.pins if p.orientation == orient]) + "pin_orientations": {orient: len([p for p in target_symbol.pins if p.orientation == orient]) for orient in set(p.orientation for p in target_symbol.pins)} } } - + except Exception as e: return { "success": False, "error": str(e), "library_path": library_path } - + @mcp.tool() - def organize_library_by_category(library_path: str) -> Dict[str, Any]: + def organize_library_by_category(library_path: str) -> dict[str, Any]: """ Organize symbols in a library by categories based on keywords and function. @@ -340,24 +336,24 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {library_path}" } - + analyzer = create_symbol_analyzer() library = analyzer.load_library(library_path) - + # Analyze library for categorization analysis = analyzer.analyze_library_coverage(library) - + # Create category-based organization categories = {} uncategorized = [] - + for symbol in library.symbols: symbol_categories = [] - + # Categorize by keywords if symbol.keywords: symbol_categories.extend(symbol.keywords) - + # Categorize by name patterns name_lower = symbol.name.lower() if any(term in name_lower for term in ['resistor', 'res', 'r_']): @@ -376,7 +372,7 @@ def register_symbol_tools(mcp: FastMCP) -> None: symbol_categories.append('integrated_circuits') elif symbol.power_symbol: symbol_categories.append('power') - + # Categorize by pin count pin_count = len(symbol.pins) if pin_count <= 2: @@ -387,7 +383,7 @@ def register_symbol_tools(mcp: FastMCP) -> None: symbol_categories.append('medium_pin_count') else: symbol_categories.append('high_pin_count') - + if symbol_categories: for category in symbol_categories: if category not in categories: @@ -399,20 +395,20 @@ def register_symbol_tools(mcp: FastMCP) -> None: }) else: uncategorized.append(symbol.name) - + # Generate organization recommendations recommendations = [] - + if uncategorized: recommendations.append(f"Add keywords to {len(uncategorized)} uncategorized symbols") - + large_categories = {k: v for k, v in categories.items() if len(v) > 50} if large_categories: recommendations.append(f"Consider splitting large categories: {list(large_categories.keys())}") - + if len(categories) < 5: recommendations.append("Library could benefit from more detailed categorization") - + return { "success": True, "library_path": library_path, @@ -429,16 +425,16 @@ def register_symbol_tools(mcp: FastMCP) -> None: }, "recommendations": recommendations } - + except Exception as e: return { "success": False, "error": str(e), "library_path": library_path } - + @mcp.tool() - def compare_symbol_libraries(library1_path: str, library2_path: str) -> Dict[str, Any]: + def compare_symbol_libraries(library1_path: str, library2_path: str) -> dict[str, Any]: """ Compare two KiCad symbol libraries and identify differences. @@ -460,49 +456,49 @@ def register_symbol_tools(mcp: FastMCP) -> None: "success": False, "error": f"Library file not found: {path}" } - + analyzer = create_symbol_analyzer() - + # Load both libraries library1 = analyzer.load_library(library1_path) library2 = analyzer.load_library(library2_path) - + # Get symbol lists symbols1 = {s.name: s for s in library1.symbols} symbols2 = {s.name: s for s in library2.symbols} - + # Find differences common_symbols = set(symbols1.keys()).intersection(set(symbols2.keys())) unique_to_lib1 = set(symbols1.keys()) - set(symbols2.keys()) unique_to_lib2 = set(symbols2.keys()) - set(symbols1.keys()) - + # Analyze common symbols for differences symbol_differences = [] for symbol_name in common_symbols: sym1 = symbols1[symbol_name] sym2 = symbols2[symbol_name] - + differences = [] - + if len(sym1.pins) != len(sym2.pins): differences.append(f"Pin count: {len(sym1.pins)} vs {len(sym2.pins)}") - + if sym1.description != sym2.description: differences.append("Description differs") - + if set(sym1.keywords) != set(sym2.keywords): differences.append("Keywords differ") - + if differences: symbol_differences.append({ "symbol": symbol_name, "differences": differences }) - + # Analyze library statistics analysis1 = analyzer.analyze_library_coverage(library1) analysis2 = analyzer.analyze_library_coverage(library2) - + return { "success": True, "comparison": { @@ -539,11 +535,11 @@ def register_symbol_tools(mcp: FastMCP) -> None: "Libraries have no common symbols - they appear to serve different purposes" ] } - + except Exception as e: return { "success": False, "error": str(e), "library1_path": library1_path, "library2_path": library2_path - } \ No newline at end of file + } diff --git a/kicad_mcp/utils/advanced_drc.py b/kicad_mcp/utils/advanced_drc.py index 78ecc21..b70d37f 100644 --- a/kicad_mcp/utils/advanced_drc.py +++ b/kicad_mcp/utils/advanced_drc.py @@ -5,12 +5,10 @@ Provides sophisticated DRC rule creation, customization, and validation beyond the basic KiCad DRC capabilities. """ -import json -import re from dataclasses import dataclass, field -from typing import Dict, List, Optional, Any, Union from enum import Enum import logging +from typing import Any logger = logging.getLogger(__name__) @@ -44,11 +42,11 @@ class DRCRule: name: str rule_type: RuleType severity: RuleSeverity - constraint: Dict[str, Any] - condition: Optional[str] = None # Expression for when rule applies - description: Optional[str] = None + constraint: dict[str, Any] + condition: str | None = None # Expression for when rule applies + description: str | None = None enabled: bool = True - custom_message: Optional[str] = None + custom_message: str | None = None @dataclass @@ -57,22 +55,22 @@ class DRCRuleSet: name: str version: str description: str - rules: List[DRCRule] = field(default_factory=list) - technology: Optional[str] = None # e.g., "PCB", "Flex", "HDI" - layer_count: Optional[int] = None - board_thickness: Optional[float] = None - created_by: Optional[str] = None + rules: list[DRCRule] = field(default_factory=list) + technology: str | None = None # e.g., "PCB", "Flex", "HDI" + layer_count: int | None = None + board_thickness: float | None = None + created_by: str | None = None class AdvancedDRCManager: """Manager for advanced DRC rules and validation.""" - + def __init__(self): """Initialize the DRC manager.""" self.rule_sets = {} self.active_rule_set = None self._load_default_rules() - + def _load_default_rules(self) -> None: """Load default DRC rule sets.""" # Standard PCB rules @@ -82,7 +80,7 @@ class AdvancedDRCManager: description="Standard PCB manufacturing rules", technology="PCB" ) - + # Basic clearance rules standard_rules.rules.extend([ DRCRule( @@ -114,10 +112,10 @@ class AdvancedDRCManager: description="Minimum annular ring for vias" ) ]) - + self.rule_sets["standard"] = standard_rules self.active_rule_set = "standard" - + def create_high_density_rules(self) -> DRCRuleSet: """Create rules for high-density interconnect (HDI) boards.""" hdi_rules = DRCRuleSet( @@ -126,7 +124,7 @@ class AdvancedDRCManager: description="High-density interconnect PCB rules", technology="HDI" ) - + hdi_rules.rules.extend([ DRCRule( name="HDI Track Width", @@ -158,9 +156,9 @@ class AdvancedDRCManager: description="Clearance around BGA escape routes" ) ]) - + return hdi_rules - + def create_rf_rules(self) -> DRCRuleSet: """Create rules specifically for RF/microwave designs.""" rf_rules = DRCRuleSet( @@ -169,7 +167,7 @@ class AdvancedDRCManager: description="Rules for RF and microwave PCB designs", technology="RF" ) - + rf_rules.rules.extend([ DRCRule( name="Controlled Impedance Spacing", @@ -196,9 +194,9 @@ class AdvancedDRCManager: description="Precise width control for 50Ω traces" ) ]) - + return rf_rules - + def create_automotive_rules(self) -> DRCRuleSet: """Create automotive-grade reliability rules.""" automotive_rules = DRCRuleSet( @@ -207,7 +205,7 @@ class AdvancedDRCManager: description="Automotive reliability and safety rules", technology="Automotive" ) - + automotive_rules.rules.extend([ DRCRule( name="Safety Critical Clearance", @@ -241,11 +239,11 @@ class AdvancedDRCManager: description="Enhanced annular ring for vibration resistance" ) ]) - + return automotive_rules - - def create_custom_rule(self, name: str, rule_type: RuleType, - constraint: Dict[str, Any], severity: RuleSeverity = RuleSeverity.ERROR, + + def create_custom_rule(self, name: str, rule_type: RuleType, + constraint: dict[str, Any], severity: RuleSeverity = RuleSeverity.ERROR, condition: str = None, description: str = None) -> DRCRule: """Create a custom DRC rule.""" return DRCRule( @@ -256,26 +254,26 @@ class AdvancedDRCManager: condition=condition, description=description ) - - def validate_rule_syntax(self, rule: DRCRule) -> List[str]: + + def validate_rule_syntax(self, rule: DRCRule) -> list[str]: """Validate rule syntax and return any errors.""" errors = [] - + # Validate constraint format if rule.rule_type == RuleType.CLEARANCE: if "min_clearance" not in rule.constraint: errors.append("Clearance rule must specify min_clearance") elif rule.constraint["min_clearance"] <= 0: errors.append("Clearance must be positive") - + elif rule.rule_type == RuleType.TRACK_WIDTH: if "min_width" not in rule.constraint and "max_width" not in rule.constraint: errors.append("Track width rule must specify min_width or max_width") - + elif rule.rule_type == RuleType.VIA_SIZE: if "min_drill" not in rule.constraint and "max_drill" not in rule.constraint: errors.append("Via size rule must specify drill constraints") - + # Validate condition syntax (basic check) if rule.condition: try: @@ -284,40 +282,40 @@ class AdvancedDRCManager: errors.append("Condition must contain a comparison operator") except Exception as e: errors.append(f"Invalid condition syntax: {e}") - + return errors - + def export_kicad_drc_rules(self, rule_set_name: str) -> str: """Export rule set as KiCad-compatible DRC rules.""" if rule_set_name not in self.rule_sets: raise ValueError(f"Rule set '{rule_set_name}' not found") - + rule_set = self.rule_sets[rule_set_name] kicad_rules = [] - + kicad_rules.append(f"# DRC Rules: {rule_set.name}") kicad_rules.append(f"# Description: {rule_set.description}") kicad_rules.append(f"# Version: {rule_set.version}") kicad_rules.append("") - + for rule in rule_set.rules: if not rule.enabled: continue - + kicad_rule = self._convert_to_kicad_rule(rule) if kicad_rule: kicad_rules.append(kicad_rule) kicad_rules.append("") - + return "\n".join(kicad_rules) - - def _convert_to_kicad_rule(self, rule: DRCRule) -> Optional[str]: + + def _convert_to_kicad_rule(self, rule: DRCRule) -> str | None: """Convert DRC rule to KiCad rule format.""" try: rule_lines = [f"# {rule.name}"] if rule.description: rule_lines.append(f"# {rule.description}") - + if rule.rule_type == RuleType.CLEARANCE: clearance = rule.constraint.get("min_clearance", 0.2) rule_lines.append(f"(rule \"{rule.name}\"") @@ -325,7 +323,7 @@ class AdvancedDRCManager: if rule.condition: rule_lines.append(f" (condition \"{rule.condition}\")") rule_lines.append(")") - + elif rule.rule_type == RuleType.TRACK_WIDTH: if "min_width" in rule.constraint: min_width = rule.constraint["min_width"] @@ -334,7 +332,7 @@ class AdvancedDRCManager: if rule.condition: rule_lines.append(f" (condition \"{rule.condition}\")") rule_lines.append(")") - + elif rule.rule_type == RuleType.VIA_SIZE: rule_lines.append(f"(rule \"{rule.name}\"") if "min_drill" in rule.constraint: @@ -344,28 +342,28 @@ class AdvancedDRCManager: if rule.condition: rule_lines.append(f" (condition \"{rule.condition}\")") rule_lines.append(")") - + return "\n".join(rule_lines) - + except Exception as e: logger.error(f"Failed to convert rule {rule.name}: {e}") return None - - def analyze_pcb_for_rule_violations(self, pcb_file_path: str, - rule_set_name: str = None) -> Dict[str, Any]: + + def analyze_pcb_for_rule_violations(self, pcb_file_path: str, + rule_set_name: str = None) -> dict[str, Any]: """Analyze PCB file against rule set and report violations.""" if rule_set_name is None: rule_set_name = self.active_rule_set - + if rule_set_name not in self.rule_sets: raise ValueError(f"Rule set '{rule_set_name}' not found") - + rule_set = self.rule_sets[rule_set_name] violations = [] - + # This would integrate with actual PCB analysis # For now, return structure for potential violations - + return { "pcb_file": pcb_file_path, "rule_set": rule_set_name, @@ -377,8 +375,8 @@ class AdvancedDRCManager: "total": len(violations) } } - - def generate_manufacturing_constraints(self, technology: str = "standard") -> Dict[str, Any]: + + def generate_manufacturing_constraints(self, technology: str = "standard") -> dict[str, Any]: """Generate manufacturing constraints for specific technology.""" constraints = { "standard": { @@ -416,17 +414,17 @@ class AdvancedDRCManager: "vibration_resistant": True } } - + return constraints.get(technology, constraints["standard"]) - + def add_rule_set(self, rule_set: DRCRuleSet) -> None: """Add a rule set to the manager.""" self.rule_sets[rule_set.name.lower().replace(" ", "_")] = rule_set - - def get_rule_set_names(self) -> List[str]: + + def get_rule_set_names(self) -> list[str]: """Get list of available rule set names.""" return list(self.rule_sets.keys()) - + def set_active_rule_set(self, name: str) -> None: """Set the active rule set.""" if name not in self.rule_sets: @@ -437,10 +435,10 @@ class AdvancedDRCManager: def create_drc_manager() -> AdvancedDRCManager: """Create and initialize a DRC manager with default rule sets.""" manager = AdvancedDRCManager() - + # Add specialized rule sets manager.add_rule_set(manager.create_high_density_rules()) manager.add_rule_set(manager.create_rf_rules()) manager.add_rule_set(manager.create_automotive_rules()) - - return manager \ No newline at end of file + + return manager diff --git a/kicad_mcp/utils/component_layout.py b/kicad_mcp/utils/component_layout.py index 8ae70f3..d817771 100644 --- a/kicad_mcp/utils/component_layout.py +++ b/kicad_mcp/utils/component_layout.py @@ -5,7 +5,6 @@ Stub implementation to fix import issues. """ from dataclasses import dataclass -from typing import Tuple, List @dataclass @@ -15,7 +14,7 @@ class SchematicBounds: x_max: float y_min: float y_max: float - + def contains_point(self, x: float, y: float) -> bool: """Check if a point is within the bounds.""" return self.x_min <= x <= self.x_max and self.y_min <= y <= self.y_max @@ -23,14 +22,14 @@ class SchematicBounds: class ComponentLayoutManager: """Manages component layout in schematic.""" - + def __init__(self): self.bounds = SchematicBounds(-1000, 1000, -1000, 1000) - + def get_bounds(self) -> SchematicBounds: """Get the schematic bounds.""" return self.bounds - + def validate_placement(self, x: float, y: float) -> bool: """Validate if a component can be placed at the given coordinates.""" - return self.bounds.contains_point(x, y) \ No newline at end of file + return self.bounds.contains_point(x, y) diff --git a/kicad_mcp/utils/component_utils.py b/kicad_mcp/utils/component_utils.py index e918b25..368e908 100644 --- a/kicad_mcp/utils/component_utils.py +++ b/kicad_mcp/utils/component_utils.py @@ -2,8 +2,28 @@ Utility functions for working with KiCad component values and properties. """ +from enum import Enum import re -from typing import Any, Optional, Tuple, Union, Dict +from typing import Any + + +class ComponentType(Enum): + """Enumeration of electronic component types.""" + RESISTOR = "resistor" + CAPACITOR = "capacitor" + INDUCTOR = "inductor" + DIODE = "diode" + TRANSISTOR = "transistor" + IC = "integrated_circuit" + CONNECTOR = "connector" + CRYSTAL = "crystal" + VOLTAGE_REGULATOR = "voltage_regulator" + FUSE = "fuse" + SWITCH = "switch" + RELAY = "relay" + TRANSFORMER = "transformer" + LED = "led" + UNKNOWN = "unknown" def extract_voltage_from_regulator(value: str) -> str: @@ -146,7 +166,7 @@ def extract_frequency_from_value(value: str) -> str: return "unknown" -def extract_resistance_value(value: str) -> Tuple[Optional[float], Optional[str]]: +def extract_resistance_value(value: str) -> tuple[float | None, str | None]: """Extract resistance value and unit from component value. Args: @@ -187,7 +207,7 @@ def extract_resistance_value(value: str) -> Tuple[Optional[float], Optional[str] return None, None -def extract_capacitance_value(value: str) -> Tuple[Optional[float], Optional[str]]: +def extract_capacitance_value(value: str) -> tuple[float | None, str | None]: """Extract capacitance value and unit from component value. Args: @@ -242,7 +262,7 @@ def extract_capacitance_value(value: str) -> Tuple[Optional[float], Optional[str return None, None -def extract_inductance_value(value: str) -> Tuple[Optional[float], Optional[str]]: +def extract_inductance_value(value: str) -> tuple[float | None, str | None]: """Extract inductance value and unit from component value. Args: @@ -396,7 +416,7 @@ def get_component_type_from_reference(reference: str) -> str: return "" -def is_power_component(component: Dict[str, Any]) -> bool: +def is_power_component(component: dict[str, Any]) -> bool: """Check if a component is likely a power-related component. Args: @@ -433,3 +453,130 @@ def is_power_component(component: Dict[str, Any]) -> bool: # Not identified as a power component return False + + +def get_component_type(value: str) -> ComponentType: + """Determine component type from value string. + + Args: + value: Component value or part number + + Returns: + ComponentType enum value + """ + value_lower = value.lower() + + # Check for resistor patterns + if (re.search(r'\d+[kmgr]?ω|ω', value_lower) or + re.search(r'\d+[kmgr]?ohm', value_lower) or + re.search(r'resistor', value_lower)): + return ComponentType.RESISTOR + + # Check for capacitor patterns + if (re.search(r'\d+[pnumkμ]?f', value_lower) or + re.search(r'capacitor|cap', value_lower)): + return ComponentType.CAPACITOR + + # Check for inductor patterns + if (re.search(r'\d+[pnumkμ]?h', value_lower) or + re.search(r'inductor|coil', value_lower)): + return ComponentType.INDUCTOR + + # Check for diode patterns + if ('diode' in value_lower or 'led' in value_lower or + value_lower.startswith(('1n', 'bar', 'ss'))): + if 'led' in value_lower: + return ComponentType.LED + return ComponentType.DIODE + + # Check for transistor patterns + if (re.search(r'transistor|mosfet|bjt|fet', value_lower) or + value_lower.startswith(('2n', 'bc', 'tip', 'irf', 'fqp'))): + return ComponentType.TRANSISTOR + + # Check for IC patterns + if (re.search(r'ic|chip|processor|mcu|cpu', value_lower) or + value_lower.startswith(('lm', 'tlv', 'op', 'ad', 'max', 'lt'))): + return ComponentType.IC + + # Check for voltage regulator patterns + if (re.search(r'regulator|ldo', value_lower) or + re.search(r'78\d\d|79\d\d|lm317|ams1117', value_lower)): + return ComponentType.VOLTAGE_REGULATOR + + # Check for connector patterns + if re.search(r'connector|conn|jack|plug|header', value_lower): + return ComponentType.CONNECTOR + + # Check for crystal patterns + if re.search(r'crystal|xtal|oscillator|mhz|khz', value_lower): + return ComponentType.CRYSTAL + + # Check for fuse patterns + if re.search(r'fuse|ptc', value_lower): + return ComponentType.FUSE + + # Check for switch patterns + if re.search(r'switch|button|sw', value_lower): + return ComponentType.SWITCH + + # Check for relay patterns + if re.search(r'relay', value_lower): + return ComponentType.RELAY + + # Check for transformer patterns + if re.search(r'transformer|trans', value_lower): + return ComponentType.TRANSFORMER + + return ComponentType.UNKNOWN + + +def get_standard_values(component_type: ComponentType) -> list[str]: + """Get standard component values for a given component type. + + Args: + component_type: Type of component + + Returns: + List of standard values as strings + """ + if component_type == ComponentType.RESISTOR: + return [ + "1Ω", "1.2Ω", "1.5Ω", "1.8Ω", "2.2Ω", "2.7Ω", "3.3Ω", "3.9Ω", "4.7Ω", "5.6Ω", "6.8Ω", "8.2Ω", + "10Ω", "12Ω", "15Ω", "18Ω", "22Ω", "27Ω", "33Ω", "39Ω", "47Ω", "56Ω", "68Ω", "82Ω", + "100Ω", "120Ω", "150Ω", "180Ω", "220Ω", "270Ω", "330Ω", "390Ω", "470Ω", "560Ω", "680Ω", "820Ω", + "1kΩ", "1.2kΩ", "1.5kΩ", "1.8kΩ", "2.2kΩ", "2.7kΩ", "3.3kΩ", "3.9kΩ", "4.7kΩ", "5.6kΩ", "6.8kΩ", "8.2kΩ", + "10kΩ", "12kΩ", "15kΩ", "18kΩ", "22kΩ", "27kΩ", "33kΩ", "39kΩ", "47kΩ", "56kΩ", "68kΩ", "82kΩ", + "100kΩ", "120kΩ", "150kΩ", "180kΩ", "220kΩ", "270kΩ", "330kΩ", "390kΩ", "470kΩ", "560kΩ", "680kΩ", "820kΩ", + "1MΩ", "1.2MΩ", "1.5MΩ", "1.8MΩ", "2.2MΩ", "2.7MΩ", "3.3MΩ", "3.9MΩ", "4.7MΩ", "5.6MΩ", "6.8MΩ", "8.2MΩ", + "10MΩ" + ] + + elif component_type == ComponentType.CAPACITOR: + return [ + "1pF", "1.5pF", "2.2pF", "3.3pF", "4.7pF", "6.8pF", "10pF", "15pF", "22pF", "33pF", "47pF", "68pF", + "100pF", "150pF", "220pF", "330pF", "470pF", "680pF", + "1nF", "1.5nF", "2.2nF", "3.3nF", "4.7nF", "6.8nF", "10nF", "15nF", "22nF", "33nF", "47nF", "68nF", + "100nF", "150nF", "220nF", "330nF", "470nF", "680nF", + "1μF", "1.5μF", "2.2μF", "3.3μF", "4.7μF", "6.8μF", "10μF", "15μF", "22μF", "33μF", "47μF", "68μF", + "100μF", "150μF", "220μF", "330μF", "470μF", "680μF", + "1000μF", "1500μF", "2200μF", "3300μF", "4700μF", "6800μF", "10000μF" + ] + + elif component_type == ComponentType.INDUCTOR: + return [ + "1nH", "1.5nH", "2.2nH", "3.3nH", "4.7nH", "6.8nH", "10nH", "15nH", "22nH", "33nH", "47nH", "68nH", + "100nH", "150nH", "220nH", "330nH", "470nH", "680nH", + "1μH", "1.5μH", "2.2μH", "3.3μH", "4.7μH", "6.8μH", "10μH", "15μH", "22μH", "33μH", "47μH", "68μH", + "100μH", "150μH", "220μH", "330μH", "470μH", "680μH", + "1mH", "1.5mH", "2.2mH", "3.3mH", "4.7mH", "6.8mH", "10mH", "15mH", "22mH", "33mH", "47mH", "68mH", + "100mH", "150mH", "220mH", "330mH", "470mH", "680mH" + ] + + elif component_type == ComponentType.CRYSTAL: + return [ + "32.768kHz", "1MHz", "2MHz", "4MHz", "8MHz", "10MHz", "12MHz", "16MHz", "20MHz", "24MHz", "25MHz", "27MHz" + ] + + else: + return [] diff --git a/kicad_mcp/utils/coordinate_converter.py b/kicad_mcp/utils/coordinate_converter.py index 65bc5b6..5a8ea24 100644 --- a/kicad_mcp/utils/coordinate_converter.py +++ b/kicad_mcp/utils/coordinate_converter.py @@ -4,26 +4,25 @@ Coordinate conversion utilities for KiCad. Stub implementation to fix import issues. """ -from typing import Tuple, Union class CoordinateConverter: """Converts between different coordinate systems in KiCad.""" - + def __init__(self): self.scale_factor = 1.0 - + def to_kicad_units(self, mm: float) -> float: """Convert millimeters to KiCad internal units.""" return mm * 1e6 # KiCad uses nanometers internally - + def from_kicad_units(self, units: float) -> float: """Convert KiCad internal units to millimeters.""" return units / 1e6 -def validate_position(x: Union[float, int], y: Union[float, int]) -> bool: +def validate_position(x: float | int, y: float | int) -> bool: """Validate if a position is within reasonable bounds.""" # Basic validation - positions should be reasonable max_coord = 1000 # mm - return abs(x) <= max_coord and abs(y) <= max_coord \ No newline at end of file + return abs(x) <= max_coord and abs(y) <= max_coord diff --git a/kicad_mcp/utils/drc_history.py b/kicad_mcp/utils/drc_history.py index 535ec9e..2c9aa87 100644 --- a/kicad_mcp/utils/drc_history.py +++ b/kicad_mcp/utils/drc_history.py @@ -4,12 +4,12 @@ Utilities for tracking DRC history for KiCad projects. This will allow users to compare DRC results over time. """ -import os +from datetime import datetime import json +import os import platform import time -from datetime import datetime -from typing import Dict, List, Any, Optional +from typing import Any # Directory for storing DRC history if platform.system() == "Windows": @@ -44,7 +44,7 @@ def get_project_history_path(project_path: str) -> str: return os.path.join(DRC_HISTORY_DIR, history_filename) -def save_drc_result(project_path: str, drc_result: Dict[str, Any]) -> None: +def save_drc_result(project_path: str, drc_result: dict[str, Any]) -> None: """Save a DRC result to the project's history. Args: @@ -68,9 +68,9 @@ def save_drc_result(project_path: str, drc_result: Dict[str, Any]) -> None: # Load existing history or create new if os.path.exists(history_path): try: - with open(history_path, "r") as f: + with open(history_path) as f: history = json.load(f) - except (json.JSONDecodeError, IOError) as e: + except (OSError, json.JSONDecodeError) as e: print(f"Error loading DRC history: {str(e)}") history = {"project_path": project_path, "entries": []} else: @@ -89,11 +89,11 @@ def save_drc_result(project_path: str, drc_result: Dict[str, Any]) -> None: with open(history_path, "w") as f: json.dump(history, f, indent=2) print(f"Saved DRC history entry to {history_path}") - except IOError as e: + except OSError as e: print(f"Error saving DRC history: {str(e)}") -def get_drc_history(project_path: str) -> List[Dict[str, Any]]: +def get_drc_history(project_path: str) -> list[dict[str, Any]]: """Get the DRC history for a project. Args: @@ -109,7 +109,7 @@ def get_drc_history(project_path: str) -> List[Dict[str, Any]]: return [] try: - with open(history_path, "r") as f: + with open(history_path) as f: history = json.load(f) # Sort entries by timestamp (newest first) @@ -118,14 +118,14 @@ def get_drc_history(project_path: str) -> List[Dict[str, Any]]: ) return entries - except (json.JSONDecodeError, IOError) as e: + except (OSError, json.JSONDecodeError) as e: print(f"Error reading DRC history: {str(e)}") return [] def compare_with_previous( - project_path: str, current_result: Dict[str, Any] -) -> Optional[Dict[str, Any]]: + project_path: str, current_result: dict[str, Any] +) -> dict[str, Any] | None: """Compare current DRC result with the previous one. Args: diff --git a/kicad_mcp/utils/env.py b/kicad_mcp/utils/env.py index 0625ce8..854bb51 100644 --- a/kicad_mcp/utils/env.py +++ b/kicad_mcp/utils/env.py @@ -2,12 +2,11 @@ Environment variable handling for KiCad MCP Server. """ -import os import logging -from typing import Dict, Optional +import os -def load_dotenv(env_file: str = ".env") -> Dict[str, str]: +def load_dotenv(env_file: str = ".env") -> dict[str, str]: """Load environment variables from .env file. Args: @@ -29,7 +28,7 @@ def load_dotenv(env_file: str = ".env") -> Dict[str, str]: logging.info(f"Found .env file at: {env_path}") try: - with open(env_path, "r") as f: + with open(env_path) as f: logging.info(f"Successfully opened {env_path} for reading.") line_num = 0 for line in f: @@ -49,9 +48,7 @@ def load_dotenv(env_file: str = ".env") -> Dict[str, str]: logging.debug(f"Parsed line {line_num}: Key='{key}', RawValue='{value}'") # Remove quotes if present - if value.startswith('"') and value.endswith('"'): - value = value[1:-1] - elif value.startswith("'") and value.endswith("'"): + if value.startswith('"') and value.endswith('"') or value.startswith("'") and value.endswith("'"): value = value[1:-1] # Expand ~ to user's home directory @@ -71,7 +68,7 @@ def load_dotenv(env_file: str = ".env") -> Dict[str, str]: logging.warning(f"Skipping line {line_num} (no '=' found): {line}") logging.info(f"Finished processing {env_path}") - except Exception as e: + except Exception: # Use logging.exception to include traceback logging.exception(f"Error loading .env file '{env_path}'") @@ -79,7 +76,7 @@ def load_dotenv(env_file: str = ".env") -> Dict[str, str]: return env_vars -def find_env_file(filename: str = ".env") -> Optional[str]: +def find_env_file(filename: str = ".env") -> str | None: """Find a .env file in the current directory or parent directories. Args: diff --git a/kicad_mcp/utils/kicad_api_detection.py b/kicad_mcp/utils/kicad_api_detection.py index fc836c4..1a9df45 100644 --- a/kicad_mcp/utils/kicad_api_detection.py +++ b/kicad_mcp/utils/kicad_api_detection.py @@ -3,9 +3,8 @@ Utility functions for detecting and selecting available KiCad API approaches. """ import os -import subprocess import shutil -from typing import Tuple, Optional, Literal +import subprocess from kicad_mcp.config import system diff --git a/kicad_mcp/utils/kicad_utils.py b/kicad_mcp/utils/kicad_utils.py index a076ba4..ca43426 100644 --- a/kicad_mcp/utils/kicad_utils.py +++ b/kicad_mcp/utils/kicad_utils.py @@ -2,24 +2,24 @@ KiCad-specific utility functions. """ -import os import logging # Import logging +import os import subprocess import sys # Add sys import -from typing import Dict, List, Any +from typing import Any from kicad_mcp.config import ( - KICAD_USER_DIR, + ADDITIONAL_SEARCH_PATHS, KICAD_APP_PATH, KICAD_EXTENSIONS, - ADDITIONAL_SEARCH_PATHS, + KICAD_USER_DIR, ) # Get PID for logging - Removed, handled by logging config # _PID = os.getpid() -def find_kicad_projects() -> List[Dict[str, Any]]: +def find_kicad_projects() -> list[dict[str, Any]]: """Find KiCad projects in the user's directory. Returns: @@ -99,7 +99,7 @@ def get_project_name_from_path(project_path: str) -> str: return basename[: -len(KICAD_EXTENSIONS["project"])] -def open_kicad_project(project_path: str) -> Dict[str, Any]: +def open_kicad_project(project_path: str) -> dict[str, Any]: """Open a KiCad project using the KiCad application. Args: diff --git a/kicad_mcp/utils/layer_stackup.py b/kicad_mcp/utils/layer_stackup.py index f6732ae..f7eb737 100644 --- a/kicad_mcp/utils/layer_stackup.py +++ b/kicad_mcp/utils/layer_stackup.py @@ -5,12 +5,11 @@ Provides functionality to analyze PCB layer configurations, impedance calculatio manufacturing constraints, and design rule validation for multi-layer boards. """ -import json -import re from dataclasses import dataclass -from typing import Dict, List, Optional, Any, Tuple import logging import math +import re +from typing import Any logger = logging.getLogger(__name__) @@ -22,22 +21,22 @@ class LayerDefinition: layer_type: str # "signal", "power", "ground", "dielectric", "soldermask", "silkscreen" thickness: float # in mm material: str - dielectric_constant: Optional[float] = None - loss_tangent: Optional[float] = None - copper_weight: Optional[float] = None # in oz (for copper layers) - layer_number: Optional[int] = None - kicad_layer_id: Optional[str] = None + dielectric_constant: float | None = None + loss_tangent: float | None = None + copper_weight: float | None = None # in oz (for copper layers) + layer_number: int | None = None + kicad_layer_id: str | None = None @dataclass class ImpedanceCalculation: """Impedance calculation results for a trace configuration.""" trace_width: float - trace_spacing: Optional[float] # For differential pairs - impedance_single: Optional[float] - impedance_differential: Optional[float] + trace_spacing: float | None # For differential pairs + impedance_single: float | None + impedance_differential: float | None layer_name: str - reference_layers: List[str] + reference_layers: list[str] calculation_method: str @@ -48,8 +47,8 @@ class StackupConstraints: min_via_drill: float min_annular_ring: float aspect_ratio_limit: float - dielectric_thickness_limits: Tuple[float, float] - copper_weight_options: List[float] + dielectric_thickness_limits: tuple[float, float] + copper_weight_options: list[float] layer_count_limit: int @@ -57,23 +56,23 @@ class StackupConstraints: class LayerStackup: """Complete PCB layer stack-up definition.""" name: str - layers: List[LayerDefinition] + layers: list[LayerDefinition] total_thickness: float layer_count: int - impedance_calculations: List[ImpedanceCalculation] + impedance_calculations: list[ImpedanceCalculation] constraints: StackupConstraints - manufacturing_notes: List[str] + manufacturing_notes: list[str] class LayerStackupAnalyzer: """Analyzer for PCB layer stack-up configurations.""" - + def __init__(self): """Initialize the layer stack-up analyzer.""" self.standard_materials = self._load_standard_materials() self.impedance_calculator = ImpedanceCalculator() - - def _load_standard_materials(self) -> Dict[str, Dict[str, Any]]: + + def _load_standard_materials(self) -> dict[str, dict[str, Any]]: """Load standard PCB materials database.""" return { "FR4_Standard": { @@ -112,28 +111,28 @@ class LayerStackupAnalyzer: "description": "Thick prepreg 1080 glass style" } } - + def analyze_pcb_stackup(self, pcb_file_path: str) -> LayerStackup: """Analyze PCB file and extract layer stack-up information.""" try: - with open(pcb_file_path, 'r', encoding='utf-8') as f: + with open(pcb_file_path, encoding='utf-8') as f: content = f.read() - + # Extract layer definitions layers = self._parse_layers(content) - + # Calculate total thickness total_thickness = sum(layer.thickness for layer in layers if layer.thickness) - + # Extract manufacturing constraints constraints = self._extract_constraints(content) - + # Perform impedance calculations impedance_calcs = self._calculate_impedances(layers, content) - + # Generate manufacturing notes notes = self._generate_manufacturing_notes(layers, total_thickness) - + stackup = LayerStackup( name=f"PCB_Stackup_{len(layers)}_layers", layers=layers, @@ -143,38 +142,38 @@ class LayerStackupAnalyzer: constraints=constraints, manufacturing_notes=notes ) - + logger.info(f"Analyzed {len(layers)}-layer stack-up with {total_thickness:.3f}mm total thickness") return stackup - + except Exception as e: logger.error(f"Failed to analyze PCB stack-up from {pcb_file_path}: {e}") raise - - def _parse_layers(self, content: str) -> List[LayerDefinition]: + + def _parse_layers(self, content: str) -> list[LayerDefinition]: """Parse layer definitions from PCB content.""" layers = [] - + # Extract layer setup section setup_match = re.search(r'\(setup[^)]*\(stackup[^)]*\)', content, re.DOTALL) if not setup_match: # Fallback to basic layer extraction return self._parse_basic_layers(content) - + stackup_content = setup_match.group(0) - + # Parse individual layers layer_pattern = r'\(layer\s+"([^"]+)"\s+\(type\s+(\w+)\)\s*(?:\(thickness\s+([\d.]+)\))?\s*(?:\(material\s+"([^"]+)"\))?' - + for match in re.finditer(layer_pattern, stackup_content): layer_name = match.group(1) layer_type = match.group(2) thickness = float(match.group(3)) if match.group(3) else None material = match.group(4) or "Unknown" - + # Get material properties material_props = self.standard_materials.get(material, {}) - + layer = LayerDefinition( name=layer_name, layer_type=layer_type, @@ -185,29 +184,29 @@ class LayerStackupAnalyzer: copper_weight=1.0 if layer_type in ["signal", "power", "ground"] else None ) layers.append(layer) - + # If no stack-up found, create standard layers if not layers: layers = self._create_standard_stackup(content) - + return layers - - def _parse_basic_layers(self, content: str) -> List[LayerDefinition]: + + def _parse_basic_layers(self, content: str) -> list[LayerDefinition]: """Parse basic layer information when detailed stack-up is not available.""" layers = [] - + # Find layer definitions in PCB layer_pattern = r'\((\d+)\s+"([^"]+)"\s+(signal|power|user)\)' - + found_layers = [] for match in re.finditer(layer_pattern, content): layer_num = int(match.group(1)) layer_name = match.group(2) layer_type = match.group(3) found_layers.append((layer_num, layer_name, layer_type)) - + found_layers.sort(key=lambda x: x[0]) # Sort by layer number - + # Create layer definitions with estimated properties for i, (layer_num, layer_name, layer_type) in enumerate(found_layers): # Estimate thickness based on layer type and position @@ -215,7 +214,7 @@ class LayerStackupAnalyzer: thickness = 0.035 # 35μm copper else: thickness = 0.017 # 17μm inner layers - + layer = LayerDefinition( name=layer_name, layer_type="signal" if layer_type == "signal" else layer_type, @@ -226,7 +225,7 @@ class LayerStackupAnalyzer: kicad_layer_id=str(layer_num) ) layers.append(layer) - + # Add dielectric layer between copper layers (except after last layer) if i < len(found_layers) - 1: dielectric_thickness = 0.2 if len(found_layers) <= 4 else 0.1 @@ -239,24 +238,24 @@ class LayerStackupAnalyzer: loss_tangent=0.02 ) layers.append(dielectric) - + return layers - - def _create_standard_stackup(self, content: str) -> List[LayerDefinition]: + + def _create_standard_stackup(self, content: str) -> list[LayerDefinition]: """Create a standard 4-layer stack-up when no stack-up is defined.""" return [ LayerDefinition("Top", "signal", 0.035, "Copper", copper_weight=1.0), - LayerDefinition("Prepreg_1", "dielectric", 0.2, "Prepreg_106", + LayerDefinition("Prepreg_1", "dielectric", 0.2, "Prepreg_106", dielectric_constant=4.2, loss_tangent=0.02), LayerDefinition("Inner1", "power", 0.017, "Copper", copper_weight=0.5), - LayerDefinition("Core", "dielectric", 1.2, "FR4_Standard", + LayerDefinition("Core", "dielectric", 1.2, "FR4_Standard", dielectric_constant=4.35, loss_tangent=0.02), LayerDefinition("Inner2", "ground", 0.017, "Copper", copper_weight=0.5), - LayerDefinition("Prepreg_2", "dielectric", 0.2, "Prepreg_106", + LayerDefinition("Prepreg_2", "dielectric", 0.2, "Prepreg_106", dielectric_constant=4.2, loss_tangent=0.02), LayerDefinition("Bottom", "signal", 0.035, "Copper", copper_weight=1.0) ] - + def _extract_constraints(self, content: str) -> StackupConstraints: """Extract manufacturing constraints from PCB.""" # Default constraints - could be extracted from design rules @@ -269,28 +268,28 @@ class LayerStackupAnalyzer: copper_weight_options=[0.5, 1.0, 2.0], # oz layer_count_limit=16 ) - - def _calculate_impedances(self, layers: List[LayerDefinition], - content: str) -> List[ImpedanceCalculation]: + + def _calculate_impedances(self, layers: list[LayerDefinition], + content: str) -> list[ImpedanceCalculation]: """Calculate characteristic impedances for signal layers.""" impedance_calcs = [] - + signal_layers = [l for l in layers if l.layer_type == "signal"] - + for signal_layer in signal_layers: # Find reference layers (adjacent power/ground planes) ref_layers = self._find_reference_layers(signal_layer, layers) - + # Calculate for standard trace widths for trace_width in [0.1, 0.15, 0.2, 0.25]: # mm single_ended = self.impedance_calculator.calculate_microstrip_impedance( trace_width, signal_layer, layers ) - + differential = self.impedance_calculator.calculate_differential_impedance( trace_width, 0.15, signal_layer, layers # 0.15mm spacing ) - + impedance_calcs.append(ImpedanceCalculation( trace_width=trace_width, trace_spacing=0.15, @@ -300,68 +299,68 @@ class LayerStackupAnalyzer: reference_layers=ref_layers, calculation_method="microstrip" )) - + return impedance_calcs - - def _find_reference_layers(self, signal_layer: LayerDefinition, - layers: List[LayerDefinition]) -> List[str]: + + def _find_reference_layers(self, signal_layer: LayerDefinition, + layers: list[LayerDefinition]) -> list[str]: """Find reference planes for a signal layer.""" ref_layers = [] signal_idx = layers.index(signal_layer) - + # Look for adjacent power/ground layers for i in range(max(0, signal_idx - 2), min(len(layers), signal_idx + 3)): if i != signal_idx and layers[i].layer_type in ["power", "ground"]: ref_layers.append(layers[i].name) - + return ref_layers - - def _generate_manufacturing_notes(self, layers: List[LayerDefinition], - total_thickness: float) -> List[str]: + + def _generate_manufacturing_notes(self, layers: list[LayerDefinition], + total_thickness: float) -> list[str]: """Generate manufacturing and assembly notes.""" notes = [] - + copper_layers = len([l for l in layers if l.layer_type in ["signal", "power", "ground"]]) - + if copper_layers > 8: notes.append("High layer count may require specialized manufacturing") - + if total_thickness > 3.0: notes.append("Thick board may require extended drill programs") elif total_thickness < 0.8: notes.append("Thin board requires careful handling during assembly") - + # Check for impedance control requirements signal_layers = len([l for l in layers if l.layer_type == "signal"]) if signal_layers > 2: notes.append("Multi-layer design - impedance control recommended") - + # Material considerations materials = set(l.material for l in layers if l.layer_type == "dielectric") if len(materials) > 1: notes.append("Mixed dielectric materials - verify thermal expansion compatibility") - + return notes - - def validate_stackup(self, stackup: LayerStackup) -> List[str]: + + def validate_stackup(self, stackup: LayerStackup) -> list[str]: """Validate stack-up for manufacturability and design rules.""" issues = [] - + # Check layer count if stackup.layer_count > stackup.constraints.layer_count_limit: issues.append(f"Layer count {stackup.layer_count} exceeds limit of {stackup.constraints.layer_count_limit}") - + # Check total thickness if stackup.total_thickness > 6.0: issues.append(f"Total thickness {stackup.total_thickness:.2f}mm may be difficult to manufacture") - + # Check for proper reference planes signal_layers = [l for l in stackup.layers if l.layer_type == "signal"] power_ground_layers = [l for l in stackup.layers if l.layer_type in ["power", "ground"]] - + if len(signal_layers) > 2 and len(power_ground_layers) < 2: issues.append("Multi-layer design should have dedicated power and ground planes") - + # Check dielectric thickness for layer in stackup.layers: if layer.layer_type == "dielectric": @@ -369,26 +368,26 @@ class LayerStackupAnalyzer: issues.append(f"Dielectric layer '{layer.name}' thickness {layer.thickness:.3f}mm is too thin") elif layer.thickness > stackup.constraints.dielectric_thickness_limits[1]: issues.append(f"Dielectric layer '{layer.name}' thickness {layer.thickness:.3f}mm is too thick") - + # Check copper balance top_copper = sum(l.thickness for l in stackup.layers[:len(stackup.layers)//2] if l.copper_weight) bottom_copper = sum(l.thickness for l in stackup.layers[len(stackup.layers)//2:] if l.copper_weight) - + if abs(top_copper - bottom_copper) / max(top_copper, bottom_copper) > 0.3: issues.append("Copper distribution is unbalanced - may cause warpage") - + return issues - - def generate_stackup_report(self, stackup: LayerStackup) -> Dict[str, Any]: + + def generate_stackup_report(self, stackup: LayerStackup) -> dict[str, Any]: """Generate comprehensive stack-up analysis report.""" validation_issues = self.validate_stackup(stackup) - + # Calculate electrical properties electrical_props = self._calculate_electrical_properties(stackup) - + # Generate recommendations recommendations = self._generate_stackup_recommendations(stackup, validation_issues) - + return { "stackup_info": { "name": stackup.name, @@ -434,126 +433,126 @@ class LayerStackupAnalyzer: }, "recommendations": recommendations } - - def _calculate_electrical_properties(self, stackup: LayerStackup) -> Dict[str, Any]: + + def _calculate_electrical_properties(self, stackup: LayerStackup) -> dict[str, Any]: """Calculate overall electrical properties of the stack-up.""" # Calculate effective dielectric constant dielectric_layers = [l for l in stackup.layers if l.layer_type == "dielectric" and l.dielectric_constant] - + if dielectric_layers: weighted_dk = sum(l.dielectric_constant * l.thickness for l in dielectric_layers) / sum(l.thickness for l in dielectric_layers) avg_loss_tangent = sum(l.loss_tangent or 0 for l in dielectric_layers) / len(dielectric_layers) else: weighted_dk = 4.35 # Default FR4 avg_loss_tangent = 0.02 - + return { "effective_dielectric_constant": weighted_dk, "average_loss_tangent": avg_loss_tangent, "total_copper_thickness_mm": sum(l.thickness for l in stackup.layers if l.copper_weight), "total_dielectric_thickness_mm": sum(l.thickness for l in stackup.layers if l.layer_type == "dielectric") } - - def _generate_stackup_recommendations(self, stackup: LayerStackup, - issues: List[str]) -> List[str]: + + def _generate_stackup_recommendations(self, stackup: LayerStackup, + issues: list[str]) -> list[str]: """Generate recommendations for stack-up optimization.""" recommendations = [] - + if issues: recommendations.append("Address validation issues before manufacturing") - + # Impedance recommendations impedance_50ohm = [imp for imp in stackup.impedance_calculations if imp.impedance_single and abs(imp.impedance_single - 50) < 5] if not impedance_50ohm and stackup.impedance_calculations: recommendations.append("Consider adjusting trace widths to achieve 50Ω characteristic impedance") - + # Layer count recommendations if stackup.layer_count == 2: recommendations.append("Consider 4-layer stack-up for better signal integrity and power distribution") elif stackup.layer_count > 8: recommendations.append("High layer count - ensure proper via management and signal routing") - + # Material recommendations materials = set(l.material for l in stackup.layers if l.layer_type == "dielectric") if "Rogers" in str(materials) and "FR4" in str(materials): recommendations.append("Mixed materials detected - verify thermal expansion compatibility") - + return recommendations class ImpedanceCalculator: """Calculator for transmission line impedance.""" - - def calculate_microstrip_impedance(self, trace_width: float, signal_layer: LayerDefinition, - layers: List[LayerDefinition]) -> Optional[float]: + + def calculate_microstrip_impedance(self, trace_width: float, signal_layer: LayerDefinition, + layers: list[LayerDefinition]) -> float | None: """Calculate microstrip impedance for a trace.""" try: # Find the dielectric layer below the signal layer signal_idx = layers.index(signal_layer) dielectric = None - + for i in range(signal_idx + 1, len(layers)): if layers[i].layer_type == "dielectric": dielectric = layers[i] break - + if not dielectric or not dielectric.dielectric_constant: return None - + # Microstrip impedance calculation (simplified) h = dielectric.thickness # dielectric height w = trace_width # trace width er = dielectric.dielectric_constant - + # Wheeler's formula for microstrip impedance if w/h > 1: z0 = (120 * math.pi) / (math.sqrt(er) * (w/h + 1.393 + 0.667 * math.log(w/h + 1.444))) else: z0 = (60 * math.log(8*h/w + w/(4*h))) / math.sqrt(er) - + return round(z0, 1) - + except (ValueError, ZeroDivisionError, IndexError): return None - + def calculate_differential_impedance(self, trace_width: float, trace_spacing: float, - signal_layer: LayerDefinition, - layers: List[LayerDefinition]) -> Optional[float]: + signal_layer: LayerDefinition, + layers: list[LayerDefinition]) -> float | None: """Calculate differential impedance for a trace pair.""" try: single_ended = self.calculate_microstrip_impedance(trace_width, signal_layer, layers) if not single_ended: return None - + # Find the dielectric layer below the signal layer signal_idx = layers.index(signal_layer) dielectric = None - + for i in range(signal_idx + 1, len(layers)): if layers[i].layer_type == "dielectric": dielectric = layers[i] break - + if not dielectric: return None - + # Approximate differential impedance calculation h = dielectric.thickness w = trace_width s = trace_spacing - + # Coupling factor (simplified) k = s / (s + 2*w) - + # Differential impedance approximation z_diff = 2 * single_ended * (1 - k) - + return round(z_diff, 1) - + except (ValueError, ZeroDivisionError): return None def create_stackup_analyzer() -> LayerStackupAnalyzer: """Create and initialize a layer stack-up analyzer.""" - return LayerStackupAnalyzer() \ No newline at end of file + return LayerStackupAnalyzer() diff --git a/kicad_mcp/utils/model3d_analyzer.py b/kicad_mcp/utils/model3d_analyzer.py index 4285703..cd9dbb4 100644 --- a/kicad_mcp/utils/model3d_analyzer.py +++ b/kicad_mcp/utils/model3d_analyzer.py @@ -5,12 +5,10 @@ Provides functionality to analyze 3D models, visualizations, and mechanical cons from KiCad PCB files including component placement, clearances, and board dimensions. """ -import json -import os -import re from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple, Any import logging +import re +from typing import Any logger = logging.getLogger(__name__) @@ -19,90 +17,90 @@ logger = logging.getLogger(__name__) class Component3D: """Represents a 3D component with position and model information.""" reference: str - position: Tuple[float, float, float] # X, Y, Z coordinates in mm - rotation: Tuple[float, float, float] # Rotation around X, Y, Z axes - model_path: Optional[str] - model_scale: Tuple[float, float, float] = (1.0, 1.0, 1.0) - model_offset: Tuple[float, float, float] = (0.0, 0.0, 0.0) - footprint: Optional[str] = None - value: Optional[str] = None + position: tuple[float, float, float] # X, Y, Z coordinates in mm + rotation: tuple[float, float, float] # Rotation around X, Y, Z axes + model_path: str | None + model_scale: tuple[float, float, float] = (1.0, 1.0, 1.0) + model_offset: tuple[float, float, float] = (0.0, 0.0, 0.0) + footprint: str | None = None + value: str | None = None -@dataclass +@dataclass class BoardDimensions: """PCB board physical dimensions and constraints.""" width: float # mm height: float # mm thickness: float # mm - outline_points: List[Tuple[float, float]] # Board outline coordinates - holes: List[Tuple[float, float, float]] # Hole positions and diameters - keepout_areas: List[Dict[str, Any]] # Keepout zones + outline_points: list[tuple[float, float]] # Board outline coordinates + holes: list[tuple[float, float, float]] # Hole positions and diameters + keepout_areas: list[dict[str, Any]] # Keepout zones @dataclass class MechanicalAnalysis: """Results of mechanical/3D analysis.""" board_dimensions: BoardDimensions - components: List[Component3D] - clearance_violations: List[Dict[str, Any]] - height_analysis: Dict[str, float] # min, max, average heights - mechanical_constraints: List[str] # Constraint violations or warnings + components: list[Component3D] + clearance_violations: list[dict[str, Any]] + height_analysis: dict[str, float] # min, max, average heights + mechanical_constraints: list[str] # Constraint violations or warnings class Model3DAnalyzer: """Analyzer for 3D models and mechanical aspects of KiCad PCBs.""" - + def __init__(self, pcb_file_path: str): """Initialize with PCB file path.""" self.pcb_file_path = pcb_file_path self.pcb_data = None self._load_pcb_data() - + def _load_pcb_data(self) -> None: """Load and parse PCB file data.""" try: - with open(self.pcb_file_path, 'r', encoding='utf-8') as f: + with open(self.pcb_file_path, encoding='utf-8') as f: content = f.read() # Parse S-expression format (simplified) self.pcb_data = content except Exception as e: logger.error(f"Failed to load PCB file {self.pcb_file_path}: {e}") self.pcb_data = None - - def extract_3d_components(self) -> List[Component3D]: + + def extract_3d_components(self) -> list[Component3D]: """Extract 3D component information from PCB data.""" components = [] - + if not self.pcb_data: return components - + # Parse footprint modules with 3D models footprint_pattern = r'\(footprint\s+"([^"]+)"[^)]*\(at\s+([\d.-]+)\s+([\d.-]+)(?:\s+([\d.-]+))?\)' model_pattern = r'\(model\s+"([^"]+)"[^)]*\(at\s+\(xyz\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\)\)[^)]*\(scale\s+\(xyz\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\)\)' reference_pattern = r'\(fp_text\s+reference\s+"([^"]+)"' value_pattern = r'\(fp_text\s+value\s+"([^"]+)"' - + # Find all footprints for footprint_match in re.finditer(footprint_pattern, self.pcb_data, re.MULTILINE): footprint_name = footprint_match.group(1) x_pos = float(footprint_match.group(2)) y_pos = float(footprint_match.group(3)) rotation = float(footprint_match.group(4)) if footprint_match.group(4) else 0.0 - + # Extract the footprint section start_pos = footprint_match.start() footprint_section = self._extract_footprint_section(start_pos) - + # Find reference and value within this footprint ref_match = re.search(reference_pattern, footprint_section) val_match = re.search(value_pattern, footprint_section) - + reference = ref_match.group(1) if ref_match else "Unknown" value = val_match.group(1) if val_match else "" - + # Find 3D model within this footprint model_match = re.search(model_pattern, footprint_section) - + if model_match: model_path = model_match.group(1) model_x = float(model_match.group(2)) @@ -111,7 +109,7 @@ class Model3DAnalyzer: scale_x = float(model_match.group(5)) scale_y = float(model_match.group(6)) scale_z = float(model_match.group(7)) - + component = Component3D( reference=reference, position=(x_pos, y_pos, 0.0), # Z will be calculated from model @@ -123,15 +121,15 @@ class Model3DAnalyzer: value=value ) components.append(component) - + logger.info(f"Extracted {len(components)} 3D components from PCB") return components - + def _extract_footprint_section(self, start_pos: int) -> str: """Extract a complete footprint section from PCB data.""" if not self.pcb_data: return "" - + # Find the matching closing parenthesis level = 0 i = start_pos @@ -143,23 +141,23 @@ class Model3DAnalyzer: if level == 0: return self.pcb_data[start_pos:i+1] i += 1 - + return self.pcb_data[start_pos:start_pos + 10000] # Fallback - + def analyze_board_dimensions(self) -> BoardDimensions: """Analyze board physical dimensions and constraints.""" if not self.pcb_data: return BoardDimensions(0, 0, 1.6, [], [], []) - + # Extract board outline (Edge.Cuts layer) edge_pattern = r'\(gr_line\s+\(start\s+([\d.-]+)\s+([\d.-]+)\)\s+\(end\s+([\d.-]+)\s+([\d.-]+)\)\s+\(stroke[^)]*\)\s+\(layer\s+"Edge\.Cuts"\)' - + outline_points = [] for match in re.finditer(edge_pattern, self.pcb_data): start_x, start_y = float(match.group(1)), float(match.group(2)) end_x, end_y = float(match.group(3)), float(match.group(4)) outline_points.extend([(start_x, start_y), (end_x, end_y)]) - + # Calculate board dimensions if outline_points: x_coords = [p[0] for p in outline_points] @@ -168,50 +166,50 @@ class Model3DAnalyzer: height = max(y_coords) - min(y_coords) else: width = height = 0 - + # Extract board thickness from stackup (if available) or default to 1.6mm thickness = 1.6 thickness_pattern = r'\(thickness\s+([\d.]+)\)' thickness_match = re.search(thickness_pattern, self.pcb_data) if thickness_match: thickness = float(thickness_match.group(1)) - + # Find holes holes = [] hole_pattern = r'\(pad[^)]*\(type\s+thru_hole\)[^)]*\(at\s+([\d.-]+)\s+([\d.-]+)\)[^)]*\(size\s+([\d.-]+)' for match in re.finditer(hole_pattern, self.pcb_data): x, y, diameter = float(match.group(1)), float(match.group(2)), float(match.group(3)) holes.append((x, y, diameter)) - + return BoardDimensions( width=width, - height=height, + height=height, thickness=thickness, outline_points=list(set(outline_points)), # Remove duplicates holes=holes, keepout_areas=[] # TODO: Extract keepout zones ) - - def analyze_component_heights(self, components: List[Component3D]) -> Dict[str, float]: + + def analyze_component_heights(self, components: list[Component3D]) -> dict[str, float]: """Analyze component height distribution.""" heights = [] - + for component in components: if component.model_path: # Estimate height from model scale and type estimated_height = self._estimate_component_height(component) heights.append(estimated_height) - + if not heights: return {"min": 0, "max": 0, "average": 0, "count": 0} - + return { "min": min(heights), "max": max(heights), "average": sum(heights) / len(heights), "count": len(heights) } - + def _estimate_component_height(self, component: Component3D) -> float: """Estimate component height based on footprint and model.""" # Component height estimation based on common footprint patterns @@ -221,39 +219,39 @@ class Model3DAnalyzer: "0603": 0.95, "0805": 1.35, "1206": 1.7, - + # IC packages "SOIC": 2.65, "QFP": 1.75, "BGA": 1.5, "TQFP": 1.4, - + # Through-hole "DIP": 4.0, "TO-220": 4.5, "TO-92": 4.5, } - + # Check footprint name for height hints footprint = component.footprint or "" for pattern, height in footprint_heights.items(): if pattern in footprint.upper(): return height * component.model_scale[2] # Apply Z scaling - + # Default height based on model scale return 2.0 * component.model_scale[2] - - def check_clearance_violations(self, components: List[Component3D], - board_dims: BoardDimensions) -> List[Dict[str, Any]]: + + def check_clearance_violations(self, components: list[Component3D], + board_dims: BoardDimensions) -> list[dict[str, Any]]: """Check for 3D clearance violations between components.""" violations = [] - + # Component-to-component clearance for i, comp1 in enumerate(components): for j, comp2 in enumerate(components[i+1:], i+1): distance = self._calculate_3d_distance(comp1, comp2) min_clearance = self._get_minimum_clearance(comp1, comp2) - + if distance < min_clearance: violations.append({ "type": "component_clearance", @@ -263,12 +261,12 @@ class Model3DAnalyzer: "required_clearance": min_clearance, "severity": "warning" if distance > min_clearance * 0.8 else "error" }) - + # Board edge clearance for component in components: edge_distance = self._distance_to_board_edge(component, board_dims) min_edge_clearance = 0.5 # 0.5mm minimum edge clearance - + if edge_distance < min_edge_clearance: violations.append({ "type": "board_edge_clearance", @@ -277,43 +275,43 @@ class Model3DAnalyzer: "required_clearance": min_edge_clearance, "severity": "warning" }) - + return violations - + def _calculate_3d_distance(self, comp1: Component3D, comp2: Component3D) -> float: """Calculate 3D distance between two components.""" dx = comp1.position[0] - comp2.position[0] - dy = comp1.position[1] - comp2.position[1] + dy = comp1.position[1] - comp2.position[1] dz = comp1.position[2] - comp2.position[2] return (dx*dx + dy*dy + dz*dz) ** 0.5 - + def _get_minimum_clearance(self, comp1: Component3D, comp2: Component3D) -> float: """Get minimum required clearance between components.""" # Base clearance rules (can be made more sophisticated) base_clearance = 0.2 # 0.2mm base clearance - + # Larger clearance for high-power components - if any(keyword in (comp1.value or "") + (comp2.value or "") + if any(keyword in (comp1.value or "") + (comp2.value or "") for keyword in ["POWER", "REGULATOR", "MOSFET"]): return base_clearance + 1.0 - + return base_clearance - - def _distance_to_board_edge(self, component: Component3D, + + def _distance_to_board_edge(self, component: Component3D, board_dims: BoardDimensions) -> float: """Calculate minimum distance from component to board edge.""" if not board_dims.outline_points: return float('inf') - + # Simplified calculation - distance to bounding rectangle x_coords = [p[0] for p in board_dims.outline_points] y_coords = [p[1] for p in board_dims.outline_points] - + min_x, max_x = min(x_coords), max(x_coords) min_y, max_y = min(y_coords), max(y_coords) - + comp_x, comp_y = component.position[0], component.position[1] - + # Distance to each edge distances = [ comp_x - min_x, # Left edge @@ -321,16 +319,16 @@ class Model3DAnalyzer: comp_y - min_y, # Bottom edge max_y - comp_y # Top edge ] - + return min(distances) - - def generate_3d_visualization_data(self) -> Dict[str, Any]: + + def generate_3d_visualization_data(self) -> dict[str, Any]: """Generate data structure for 3D visualization.""" components = self.extract_3d_components() board_dims = self.analyze_board_dimensions() height_analysis = self.analyze_component_heights(components) clearance_violations = self.check_clearance_violations(components, board_dims) - + return { "board_dimensions": { "width": board_dims.width, @@ -359,26 +357,26 @@ class Model3DAnalyzer: "violation_count": len(clearance_violations) } } - + def perform_mechanical_analysis(self) -> MechanicalAnalysis: """Perform comprehensive mechanical analysis.""" components = self.extract_3d_components() board_dims = self.analyze_board_dimensions() height_analysis = self.analyze_component_heights(components) clearance_violations = self.check_clearance_violations(components, board_dims) - + # Generate mechanical constraints and warnings constraints = [] - + if height_analysis["max"] > 10.0: # 10mm height limit example constraints.append(f"Board height {height_analysis['max']:.1f}mm exceeds 10mm limit") - + if board_dims.width > 100 or board_dims.height > 100: constraints.append(f"Board dimensions {board_dims.width:.1f}x{board_dims.height:.1f}mm are large") - + if len(clearance_violations) > 0: constraints.append(f"{len(clearance_violations)} clearance violations found") - + return MechanicalAnalysis( board_dimensions=board_dims, components=components, @@ -388,7 +386,7 @@ class Model3DAnalyzer: ) -def analyze_pcb_3d_models(pcb_file_path: str) -> Dict[str, Any]: +def analyze_pcb_3d_models(pcb_file_path: str) -> dict[str, Any]: """Convenience function to analyze 3D models in a PCB file.""" try: analyzer = Model3DAnalyzer(pcb_file_path) @@ -401,4 +399,4 @@ def analyze_pcb_3d_models(pcb_file_path: str) -> Dict[str, Any]: def get_mechanical_constraints(pcb_file_path: str) -> MechanicalAnalysis: """Get mechanical analysis and constraints for a PCB.""" analyzer = Model3DAnalyzer(pcb_file_path) - return analyzer.perform_mechanical_analysis() \ No newline at end of file + return analyzer.perform_mechanical_analysis() diff --git a/kicad_mcp/utils/netlist_parser.py b/kicad_mcp/utils/netlist_parser.py index 5202652..b2c08a2 100644 --- a/kicad_mcp/utils/netlist_parser.py +++ b/kicad_mcp/utils/netlist_parser.py @@ -2,10 +2,10 @@ KiCad schematic netlist extraction utilities. """ +from collections import defaultdict import os import re -from typing import Any, Dict, List -from collections import defaultdict +from typing import Any class SchematicParser: @@ -45,14 +45,14 @@ class SchematicParser: raise FileNotFoundError(f"Schematic file not found: {self.schematic_path}") try: - with open(self.schematic_path, "r") as f: + with open(self.schematic_path) as f: self.content = f.read() print(f"Successfully loaded schematic: {self.schematic_path}") except Exception as e: print(f"Error reading schematic file: {str(e)}") raise - def parse(self) -> Dict[str, Any]: + def parse(self) -> dict[str, Any]: """Parse the schematic to extract netlist information. Returns: @@ -98,7 +98,7 @@ class SchematicParser: ) return result - def _extract_s_expressions(self, pattern: str) -> List[str]: + def _extract_s_expressions(self, pattern: str) -> list[str]: """Extract all matching S-expressions from the schematic content. Args: @@ -158,7 +158,7 @@ class SchematicParser: print(f"Extracted {len(self.components)} components") - def _parse_component(self, symbol_expr: str) -> Dict[str, Any]: + def _parse_component(self, symbol_expr: str) -> dict[str, Any]: """Parse a component from a symbol S-expression. Args: @@ -414,7 +414,7 @@ class SchematicParser: print(f"Found {len(self.nets)} potential nets from labels and power symbols") -def extract_netlist(schematic_path: str) -> Dict[str, Any]: +def extract_netlist(schematic_path: str) -> dict[str, Any]: """Extract netlist information from a KiCad schematic file. Args: @@ -431,7 +431,60 @@ def extract_netlist(schematic_path: str) -> Dict[str, Any]: return {"error": str(e), "components": {}, "nets": {}, "component_count": 0, "net_count": 0} -def analyze_netlist(netlist_data: Dict[str, Any]) -> Dict[str, Any]: +def parse_netlist_file(schematic_path: str) -> dict[str, Any]: + """Parse a KiCad schematic file and extract netlist data. + + This is the main interface function used by AI tools for circuit analysis. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + + Returns: + Dictionary containing: + - components: List of component dictionaries with reference, value, etc. + - nets: Dictionary of net names and connected components + - component_count: Total number of components + - net_count: Total number of nets + """ + try: + # Extract raw netlist data + netlist_data = extract_netlist(schematic_path) + + # Convert components dict to list format expected by AI tools + components = [] + for ref, component_info in netlist_data.get("components", {}).items(): + component = { + "reference": ref, + "value": component_info.get("value", ""), + "footprint": component_info.get("footprint", ""), + "lib_id": component_info.get("lib_id", ""), + } + # Add any additional properties + if "properties" in component_info: + component.update(component_info["properties"]) + components.append(component) + + return { + "components": components, + "nets": netlist_data.get("nets", {}), + "component_count": len(components), + "net_count": len(netlist_data.get("nets", {})), + "labels": netlist_data.get("labels", []), + "power_symbols": netlist_data.get("power_symbols", []) + } + + except Exception as e: + print(f"Error parsing netlist file: {str(e)}") + return { + "components": [], + "nets": {}, + "component_count": 0, + "net_count": 0, + "error": str(e) + } + + +def analyze_netlist(netlist_data: dict[str, Any]) -> dict[str, Any]: """Analyze netlist data to provide insights. Args: diff --git a/kicad_mcp/utils/pattern_recognition.py b/kicad_mcp/utils/pattern_recognition.py index d84a568..952e1d6 100644 --- a/kicad_mcp/utils/pattern_recognition.py +++ b/kicad_mcp/utils/pattern_recognition.py @@ -3,16 +3,17 @@ Circuit pattern recognition functions for KiCad schematics. """ import re -from typing import Dict, List, Any +from typing import Any + from kicad_mcp.utils.component_utils import ( - extract_voltage_from_regulator, extract_frequency_from_value, + extract_voltage_from_regulator, ) def identify_power_supplies( - components: Dict[str, Any], nets: Dict[str, Any] -) -> List[Dict[str, Any]]: + components: dict[str, Any], nets: dict[str, Any] +) -> list[dict[str, Any]]: """Identify power supply circuits in the schematic. Args: @@ -89,7 +90,7 @@ def identify_power_supplies( return power_supplies -def identify_amplifiers(components: Dict[str, Any], nets: Dict[str, Any]) -> List[Dict[str, Any]]: +def identify_amplifiers(components: dict[str, Any], nets: dict[str, Any]) -> list[dict[str, Any]]: """Identify amplifier circuits in the schematic. Args: @@ -167,7 +168,7 @@ def identify_amplifiers(components: Dict[str, Any], nets: Dict[str, Any]) -> Lis ) # Look for transistor amplifiers - transistor_refs = [ref for ref in components.keys() if ref.startswith("Q")] + transistor_refs = [ref for ref in components if ref.startswith("Q")] for ref in transistor_refs: component = components[ref] @@ -234,7 +235,7 @@ def identify_amplifiers(components: Dict[str, Any], nets: Dict[str, Any]) -> Lis return amplifiers -def identify_filters(components: Dict[str, Any], nets: Dict[str, Any]) -> List[Dict[str, Any]]: +def identify_filters(components: dict[str, Any], nets: dict[str, Any]) -> list[dict[str, Any]]: """Identify filter circuits in the schematic. Args: @@ -248,8 +249,8 @@ def identify_filters(components: Dict[str, Any], nets: Dict[str, Any]) -> List[D # Look for RC low-pass filters # These typically have a resistor followed by a capacitor to ground - resistor_refs = [ref for ref in components.keys() if ref.startswith("R")] - capacitor_refs = [ref for ref in components.keys() if ref.startswith("C")] + resistor_refs = [ref for ref in components if ref.startswith("R")] + capacitor_refs = [ref for ref in components if ref.startswith("C")] for r_ref in resistor_refs: r_nets = [] @@ -356,7 +357,7 @@ def identify_filters(components: Dict[str, Any], nets: Dict[str, Any]) -> List[D return filters -def identify_oscillators(components: Dict[str, Any], nets: Dict[str, Any]) -> List[Dict[str, Any]]: +def identify_oscillators(components: dict[str, Any], nets: dict[str, Any]) -> list[dict[str, Any]]: """Identify oscillator circuits in the schematic. Args: @@ -441,8 +442,8 @@ def identify_oscillators(components: Dict[str, Any], nets: Dict[str, Any]) -> Li def identify_digital_interfaces( - components: Dict[str, Any], nets: Dict[str, Any] -) -> List[Dict[str, Any]]: + components: dict[str, Any], nets: dict[str, Any] +) -> list[dict[str, Any]]: """Identify digital interface circuits in the schematic. Args: @@ -458,7 +459,7 @@ def identify_digital_interfaces( i2c_signals = {"SCL", "SDA", "I2C_SCL", "I2C_SDA"} has_i2c = False - for net_name in nets.keys(): + for net_name in nets: if any(signal in net_name.upper() for signal in i2c_signals): has_i2c = True break @@ -469,7 +470,7 @@ def identify_digital_interfaces( "type": "i2c_interface", "signals_found": [ net - for net in nets.keys() + for net in nets if any(signal in net.upper() for signal in i2c_signals) ], } @@ -479,7 +480,7 @@ def identify_digital_interfaces( spi_signals = {"MOSI", "MISO", "SCK", "SS", "SPI_MOSI", "SPI_MISO", "SPI_SCK", "SPI_CS"} has_spi = False - for net_name in nets.keys(): + for net_name in nets: if any(signal in net_name.upper() for signal in spi_signals): has_spi = True break @@ -490,7 +491,7 @@ def identify_digital_interfaces( "type": "spi_interface", "signals_found": [ net - for net in nets.keys() + for net in nets if any(signal in net.upper() for signal in spi_signals) ], } @@ -500,7 +501,7 @@ def identify_digital_interfaces( uart_signals = {"TX", "RX", "TXD", "RXD", "UART_TX", "UART_RX"} has_uart = False - for net_name in nets.keys(): + for net_name in nets: if any(signal in net_name.upper() for signal in uart_signals): has_uart = True break @@ -511,7 +512,7 @@ def identify_digital_interfaces( "type": "uart_interface", "signals_found": [ net - for net in nets.keys() + for net in nets if any(signal in net.upper() for signal in uart_signals) ], } @@ -521,7 +522,7 @@ def identify_digital_interfaces( usb_signals = {"USB_D+", "USB_D-", "USB_DP", "USB_DM", "D+", "D-", "DP", "DM", "VBUS"} has_usb = False - for net_name in nets.keys(): + for net_name in nets: if any(signal in net_name.upper() for signal in usb_signals): has_usb = True break @@ -539,7 +540,7 @@ def identify_digital_interfaces( "type": "usb_interface", "signals_found": [ net - for net in nets.keys() + for net in nets if any(signal in net.upper() for signal in usb_signals) ], } @@ -549,7 +550,7 @@ def identify_digital_interfaces( ethernet_signals = {"TX+", "TX-", "RX+", "RX-", "MDI", "MDIO", "ETH"} has_ethernet = False - for net_name in nets.keys(): + for net_name in nets: if any(signal in net_name.upper() for signal in ethernet_signals): has_ethernet = True break @@ -567,7 +568,7 @@ def identify_digital_interfaces( "type": "ethernet_interface", "signals_found": [ net - for net in nets.keys() + for net in nets if any(signal in net.upper() for signal in ethernet_signals) ], } @@ -577,8 +578,8 @@ def identify_digital_interfaces( def identify_sensor_interfaces( - components: Dict[str, Any], nets: Dict[str, Any] -) -> List[Dict[str, Any]]: + components: dict[str, Any], nets: dict[str, Any] +) -> list[dict[str, Any]]: """Identify sensor interface circuits in the schematic. Args: @@ -792,7 +793,7 @@ def identify_sensor_interfaces( # Look for common analog sensors # These often don't have specific ICs but have designators like "RT" for thermistors thermistor_refs = [ - ref for ref in components.keys() if ref.startswith("RT") or ref.startswith("TH") + ref for ref in components if ref.startswith("RT") or ref.startswith("TH") ] for ref in thermistor_refs: component = components[ref] @@ -808,7 +809,7 @@ def identify_sensor_interfaces( # Look for photodiodes, photoresistors (LDRs) photosensor_refs = [ - ref for ref in components.keys() if ref.startswith("PD") or ref.startswith("LDR") + ref for ref in components if ref.startswith("PD") or ref.startswith("LDR") ] for ref in photosensor_refs: component = components[ref] @@ -823,7 +824,7 @@ def identify_sensor_interfaces( ) # Look for potentiometers (often used for manual sensing/control) - pot_refs = [ref for ref in components.keys() if ref.startswith("RV") or ref.startswith("POT")] + pot_refs = [ref for ref in components if ref.startswith("RV") or ref.startswith("POT")] for ref in pot_refs: component = components[ref] sensor_interfaces.append( @@ -839,7 +840,7 @@ def identify_sensor_interfaces( return sensor_interfaces -def identify_microcontrollers(components: Dict[str, Any]) -> List[Dict[str, Any]]: +def identify_microcontrollers(components: dict[str, Any]) -> list[dict[str, Any]]: """Identify microcontroller circuits in the schematic. Args: @@ -1026,3 +1027,120 @@ def identify_microcontrollers(components: Dict[str, Any]) -> List[Dict[str, Any] break return microcontrollers + + +def analyze_circuit_patterns(schematic_file: str) -> dict[str, Any]: + """Analyze circuit patterns in a schematic file. + + Args: + schematic_file: Path to KiCad schematic file + + Returns: + Dictionary of identified patterns + """ + try: + from kicad_mcp.utils.netlist_parser import parse_netlist_file + + # Parse netlist to get components and nets + netlist_data = parse_netlist_file(schematic_file) + components = netlist_data.get("components", {}) + nets = netlist_data.get("nets", {}) + + patterns = {} + + # Identify various circuit patterns + power_supplies = identify_power_supplies(components, nets) + if power_supplies: + patterns["power_supply"] = power_supplies + + amplifiers = identify_amplifiers(components, nets) + if amplifiers: + patterns["amplifier"] = amplifiers + + oscillators = identify_oscillators(components, nets) + if oscillators: + patterns["crystal_oscillator"] = oscillators + + interfaces = identify_digital_interfaces(components, nets) + if interfaces: + patterns["digital_interface"] = interfaces + + sensors = identify_sensor_interfaces(components, nets) + if sensors: + patterns["sensor_interface"] = sensors + + mcus = identify_microcontrollers(components) + if mcus: + patterns["microcontroller"] = mcus + + # Look for decoupling capacitors + decoupling_caps = [] + for ref, component in components.items(): + if ref.startswith("C") and component.get("value", "").lower() in ["100nf", "0.1uf"]: + decoupling_caps.append(ref) + + if decoupling_caps: + patterns["decoupling"] = decoupling_caps + + return patterns + + except Exception as e: + return {"error": f"Failed to analyze patterns: {str(e)}"} + + +def get_component_recommendations(patterns: dict[str, Any]) -> list[dict[str, Any]]: + """Get component recommendations based on identified patterns. + + Args: + patterns: Dictionary of identified circuit patterns + + Returns: + List of component recommendations + """ + recommendations = [] + + # Power supply recommendations + if "power_supply" in patterns: + power_circuits = patterns["power_supply"] + for circuit in power_circuits: + if circuit.get("type") == "linear_regulator": + recommendations.append({ + "category": "power_management", + "component": "Filter Capacitor", + "value": "1000µF", + "reason": "Output filtering for linear regulator", + "priority": "high" + }) + + # Microcontroller recommendations + if "microcontroller" in patterns: + recommendations.extend([ + { + "category": "power_management", + "component": "Decoupling Capacitor", + "value": "100nF", + "reason": "Power supply decoupling for microcontroller", + "priority": "high" + }, + { + "category": "reset_circuit", + "component": "Pull-up Resistor", + "value": "10kΩ", + "reason": "Reset pin pull-up for microcontroller", + "priority": "medium" + } + ]) + + # Crystal oscillator recommendations + if "crystal_oscillator" in patterns: + recommendations.extend([ + { + "category": "timing", + "component": "Load Capacitor", + "value": "22pF", + "reason": "Crystal load capacitance", + "priority": "high" + } + ]) + + return recommendations diff --git a/kicad_mcp/utils/symbol_library.py b/kicad_mcp/utils/symbol_library.py index d04fec2..3c83842 100644 --- a/kicad_mcp/utils/symbol_library.py +++ b/kicad_mcp/utils/symbol_library.py @@ -5,12 +5,11 @@ Provides functionality to analyze, manage, and manipulate KiCad symbol libraries including library validation, symbol extraction, and library organization. """ -import json +from dataclasses import dataclass +import logging import os import re -from dataclasses import dataclass -from typing import Dict, List, Optional, Any, Tuple -import logging +from typing import Any logger = logging.getLogger(__name__) @@ -20,7 +19,7 @@ class SymbolPin: """Represents a symbol pin with electrical and geometric properties.""" number: str name: str - position: Tuple[float, float] + position: tuple[float, float] orientation: str # "L", "R", "U", "D" electrical_type: str # "input", "output", "bidirectional", "power_in", etc. graphic_style: str # "line", "inverted", "clock", etc. @@ -32,7 +31,7 @@ class SymbolProperty: """Symbol property like reference, value, footprint, etc.""" name: str value: str - position: Tuple[float, float] + position: tuple[float, float] rotation: float = 0.0 visible: bool = True justify: str = "left" @@ -41,11 +40,11 @@ class SymbolProperty: @dataclass class SymbolGraphics: """Graphical elements of a symbol.""" - rectangles: List[Dict[str, Any]] - circles: List[Dict[str, Any]] - arcs: List[Dict[str, Any]] - polylines: List[Dict[str, Any]] - text: List[Dict[str, Any]] + rectangles: list[dict[str, Any]] + circles: list[dict[str, Any]] + arcs: list[dict[str, Any]] + polylines: list[dict[str, Any]] + text: list[dict[str, Any]] @dataclass @@ -54,14 +53,14 @@ class Symbol: name: str library_id: str description: str - keywords: List[str] - pins: List[SymbolPin] - properties: List[SymbolProperty] + keywords: list[str] + pins: list[SymbolPin] + properties: list[SymbolProperty] graphics: SymbolGraphics - footprint_filters: List[str] - aliases: List[str] = None + footprint_filters: list[str] + aliases: list[str] = None power_symbol: bool = False - extends: Optional[str] = None # For derived symbols + extends: str | None = None # For derived symbols @dataclass @@ -70,31 +69,31 @@ class SymbolLibrary: name: str file_path: str version: str - symbols: List[Symbol] - metadata: Dict[str, Any] + symbols: list[Symbol] + metadata: dict[str, Any] class SymbolLibraryAnalyzer: """Analyzer for KiCad symbol libraries.""" - + def __init__(self): """Initialize the symbol library analyzer.""" self.libraries = {} self.symbol_cache = {} - + def load_library(self, library_path: str) -> SymbolLibrary: """Load a KiCad symbol library file.""" try: - with open(library_path, 'r', encoding='utf-8') as f: + with open(library_path, encoding='utf-8') as f: content = f.read() - + # Parse library header library_name = os.path.basename(library_path).replace('.kicad_sym', '') version = self._extract_version(content) - + # Parse symbols symbols = self._parse_symbols(content) - + library = SymbolLibrary( name=library_name, file_path=library_path, @@ -102,45 +101,45 @@ class SymbolLibraryAnalyzer: symbols=symbols, metadata=self._extract_metadata(content) ) - + self.libraries[library_name] = library logger.info(f"Loaded library '{library_name}' with {len(symbols)} symbols") - + return library - + except Exception as e: logger.error(f"Failed to load library {library_path}: {e}") raise - + def _extract_version(self, content: str) -> str: """Extract version from library content.""" version_match = re.search(r'\(version\s+(\d+)\)', content) return version_match.group(1) if version_match else "unknown" - - def _extract_metadata(self, content: str) -> Dict[str, Any]: + + def _extract_metadata(self, content: str) -> dict[str, Any]: """Extract library metadata.""" metadata = {} - + # Extract generator info generator_match = re.search(r'\(generator\s+"([^"]+)"\)', content) if generator_match: metadata["generator"] = generator_match.group(1) - + return metadata - - def _parse_symbols(self, content: str) -> List[Symbol]: + + def _parse_symbols(self, content: str) -> list[Symbol]: """Parse symbols from library content.""" symbols = [] - + # Find all symbol definitions symbol_pattern = r'\(symbol\s+"([^"]+)"[^)]*\)' symbol_matches = [] - + # Use a more sophisticated parser to handle nested parentheses level = 0 current_symbol = None symbol_start = 0 - + for i, char in enumerate(content): if char == '(': if level == 0 and content[i:i+8] == '(symbol ': @@ -154,50 +153,50 @@ class SymbolLibraryAnalyzer: if symbol: symbols.append(symbol) current_symbol = None - + # Check if we're starting a symbol if level == 1 and content[i:i+8] == '(symbol ' and current_symbol is None: # Extract symbol name name_match = re.search(r'\(symbol\s+"([^"]+)"', content[i:i+100]) if name_match: current_symbol = name_match.group(1) - + logger.info(f"Parsed {len(symbols)} symbols from library") return symbols - - def _parse_single_symbol(self, symbol_content: str) -> Optional[Symbol]: + + def _parse_single_symbol(self, symbol_content: str) -> Symbol | None: """Parse a single symbol definition.""" try: # Extract symbol name name_match = re.search(r'\(symbol\s+"([^"]+)"', symbol_content) if not name_match: return None - + name = name_match.group(1) - + # Parse basic properties description = self._extract_property(symbol_content, "description") or "" keywords = self._extract_keywords(symbol_content) - + # Parse pins pins = self._parse_pins(symbol_content) - + # Parse properties properties = self._parse_properties(symbol_content) - + # Parse graphics graphics = self._parse_graphics(symbol_content) - + # Parse footprint filters footprint_filters = self._parse_footprint_filters(symbol_content) - + # Check if it's a power symbol power_symbol = "(power)" in symbol_content - + # Check for extends (derived symbols) extends_match = re.search(r'\(extends\s+"([^"]+)"\)', symbol_content) extends = extends_match.group(1) if extends_match else None - + return Symbol( name=name, library_id=name, # Will be updated with library prefix @@ -211,31 +210,31 @@ class SymbolLibraryAnalyzer: power_symbol=power_symbol, extends=extends ) - + except Exception as e: logger.error(f"Failed to parse symbol: {e}") return None - - def _extract_property(self, content: str, prop_name: str) -> Optional[str]: + + def _extract_property(self, content: str, prop_name: str) -> str | None: """Extract a property value from symbol content.""" pattern = f'\\(property\\s+"{prop_name}"\\s+"([^"]*)"' match = re.search(pattern, content) return match.group(1) if match else None - - def _extract_keywords(self, content: str) -> List[str]: + + def _extract_keywords(self, content: str) -> list[str]: """Extract keywords from symbol content.""" keywords_match = re.search(r'\(keywords\s+"([^"]*)"\)', content) if keywords_match: return [k.strip() for k in keywords_match.group(1).split() if k.strip()] return [] - - def _parse_pins(self, content: str) -> List[SymbolPin]: + + def _parse_pins(self, content: str) -> list[SymbolPin]: """Parse pins from symbol content.""" pins = [] - + # Pin pattern - matches KiCad 6+ format pin_pattern = r'\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)[^)]*\(name\s+"([^"]*)"\s+[^)]*\)\s+\(number\s+"([^"]*)"\s+[^)]*\)' - + for match in re.finditer(pin_pattern, content): electrical_type = match.group(1) graphic_style = match.group(2) @@ -245,11 +244,11 @@ class SymbolLibraryAnalyzer: length = float(match.group(6)) pin_name = match.group(7) pin_number = match.group(8) - + # Convert angle to orientation orientation_map = {0: "R", 90: "U", 180: "L", 270: "D"} orientation = orientation_map.get(orientation_angle, "R") - + pin = SymbolPin( number=pin_number, name=pin_name, @@ -260,23 +259,23 @@ class SymbolLibraryAnalyzer: length=length ) pins.append(pin) - + return pins - - def _parse_properties(self, content: str) -> List[SymbolProperty]: + + def _parse_properties(self, content: str) -> list[SymbolProperty]: """Parse symbol properties.""" properties = [] - + # Property pattern prop_pattern = r'\(property\s+"([^"]+)"\s+"([^"]*)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)' - + for match in re.finditer(prop_pattern, content): name = match.group(1) value = match.group(2) x = float(match.group(3)) y = float(match.group(4)) rotation = float(match.group(5)) - + prop = SymbolProperty( name=name, value=value, @@ -284,9 +283,9 @@ class SymbolLibraryAnalyzer: rotation=rotation ) properties.append(prop) - + return properties - + def _parse_graphics(self, content: str) -> SymbolGraphics: """Parse graphical elements from symbol.""" rectangles = [] @@ -294,7 +293,7 @@ class SymbolLibraryAnalyzer: arcs = [] polylines = [] text = [] - + # Parse rectangles rect_pattern = r'\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)' for match in re.finditer(rect_pattern, content): @@ -302,7 +301,7 @@ class SymbolLibraryAnalyzer: "start": (float(match.group(1)), float(match.group(2))), "end": (float(match.group(3)), float(match.group(4))) }) - + # Parse circles circle_pattern = r'\(circle\s+\(center\s+([-\d.]+)\s+([-\d.]+)\)\s+\(radius\s+([-\d.]+)\)' for match in re.finditer(circle_pattern, content): @@ -310,11 +309,11 @@ class SymbolLibraryAnalyzer: "center": (float(match.group(1)), float(match.group(2))), "radius": float(match.group(3)) }) - + # Parse polylines (simplified) poly_pattern = r'\(polyline[^)]*\(pts[^)]+\)' polylines = [{"data": match.group(0)} for match in re.finditer(poly_pattern, content)] - + return SymbolGraphics( rectangles=rectangles, circles=circles, @@ -322,21 +321,21 @@ class SymbolLibraryAnalyzer: polylines=polylines, text=text ) - - def _parse_footprint_filters(self, content: str) -> List[str]: + + def _parse_footprint_filters(self, content: str) -> list[str]: """Parse footprint filters from symbol.""" filters = [] - + # Look for footprint filter section fp_filter_match = re.search(r'\(fp_filters[^)]*\)', content, re.DOTALL) if fp_filter_match: filter_content = fp_filter_match.group(0) filter_pattern = r'"([^"]+)"' filters = [match.group(1) for match in re.finditer(filter_pattern, filter_content)] - + return filters - - def analyze_library_coverage(self, library: SymbolLibrary) -> Dict[str, Any]: + + def analyze_library_coverage(self, library: SymbolLibrary) -> dict[str, Any]: """Analyze symbol library coverage and statistics.""" analysis = { "total_symbols": len(library.symbols), @@ -348,36 +347,36 @@ class SymbolLibraryAnalyzer: "unused_symbols": [], "statistics": {} } - + # Analyze by categories (based on keywords/names) categories = {} electrical_types = {} pin_counts = {} - + for symbol in library.symbols: # Categorize by keywords for keyword in symbol.keywords: categories[keyword] = categories.get(keyword, 0) + 1 - + # Count pin types for pin in symbol.pins: electrical_types[pin.electrical_type] = electrical_types.get(pin.electrical_type, 0) + 1 - + # Pin count distribution pin_count = len(symbol.pins) pin_counts[pin_count] = pin_counts.get(pin_count, 0) + 1 - + # Check for missing essential properties essential_props = ["Reference", "Value", "Footprint"] symbol_props = [p.name for p in symbol.properties] - + for prop in essential_props: if prop not in symbol_props: analysis["missing_properties"].append({ "symbol": symbol.name, "missing_property": prop }) - + analysis.update({ "categories": categories, "electrical_types": electrical_types, @@ -389,29 +388,29 @@ class SymbolLibraryAnalyzer: "power_symbols": len([s for s in library.symbols if s.power_symbol]) } }) - + return analysis - - def find_similar_symbols(self, symbol: Symbol, library: SymbolLibrary, - threshold: float = 0.7) -> List[Tuple[Symbol, float]]: + + def find_similar_symbols(self, symbol: Symbol, library: SymbolLibrary, + threshold: float = 0.7) -> list[tuple[Symbol, float]]: """Find symbols similar to the given symbol.""" similar = [] - + for candidate in library.symbols: if candidate.name == symbol.name: continue - + similarity = self._calculate_symbol_similarity(symbol, candidate) if similarity >= threshold: similar.append((candidate, similarity)) - + return sorted(similar, key=lambda x: x[1], reverse=True) - + def _calculate_symbol_similarity(self, symbol1: Symbol, symbol2: Symbol) -> float: """Calculate similarity score between two symbols.""" score = 0.0 factors = 0 - + # Pin count similarity if symbol1.pins and symbol2.pins: pin_diff = abs(len(symbol1.pins) - len(symbol2.pins)) @@ -419,7 +418,7 @@ class SymbolLibraryAnalyzer: pin_similarity = 1.0 - (pin_diff / max_pins) if max_pins > 0 else 1.0 score += pin_similarity * 0.4 factors += 0.4 - + # Keyword similarity keywords1 = set(symbol1.keywords) keywords2 = set(symbol2.keywords) @@ -429,65 +428,65 @@ class SymbolLibraryAnalyzer: keyword_similarity = keyword_intersection / keyword_union if keyword_union > 0 else 0.0 score += keyword_similarity * 0.3 factors += 0.3 - + # Name similarity (simple string comparison) name_similarity = self._string_similarity(symbol1.name, symbol2.name) score += name_similarity * 0.3 factors += 0.3 - + return score / factors if factors > 0 else 0.0 - + def _string_similarity(self, str1: str, str2: str) -> float: """Calculate string similarity using simple character overlap.""" if not str1 or not str2: return 0.0 - + str1_lower = str1.lower() str2_lower = str2.lower() - + # Simple character-based similarity intersection = len(set(str1_lower).intersection(set(str2_lower))) union = len(set(str1_lower).union(set(str2_lower))) - + return intersection / union if union > 0 else 0.0 - - def validate_symbol(self, symbol: Symbol) -> List[str]: + + def validate_symbol(self, symbol: Symbol) -> list[str]: """Validate a symbol and return list of issues.""" issues = [] - + # Check for essential properties prop_names = [p.name for p in symbol.properties] essential_props = ["Reference", "Value"] - + for prop in essential_props: if prop not in prop_names: issues.append(f"Missing essential property: {prop}") - + # Check pin consistency pin_numbers = [p.number for p in symbol.pins] if len(pin_numbers) != len(set(pin_numbers)): issues.append("Duplicate pin numbers found") - + # Check for pins without names unnamed_pins = [p.number for p in symbol.pins if not p.name] if unnamed_pins: issues.append(f"Pins without names: {', '.join(unnamed_pins)}") - + # Validate electrical types - valid_types = ["input", "output", "bidirectional", "tri_state", "passive", - "free", "unspecified", "power_in", "power_out", "open_collector", + valid_types = ["input", "output", "bidirectional", "tri_state", "passive", + "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect"] - + for pin in symbol.pins: if pin.electrical_type not in valid_types: issues.append(f"Invalid electrical type '{pin.electrical_type}' for pin {pin.number}") - + return issues - - def export_symbol_report(self, library: SymbolLibrary) -> Dict[str, Any]: + + def export_symbol_report(self, library: SymbolLibrary) -> dict[str, Any]: """Export a comprehensive symbol library report.""" analysis = self.analyze_library_coverage(library) - + # Add validation results validation_results = [] for symbol in library.symbols: @@ -497,7 +496,7 @@ class SymbolLibraryAnalyzer: "symbol": symbol.name, "issues": issues }) - + return { "library_info": { "name": library.name, @@ -513,33 +512,33 @@ class SymbolLibraryAnalyzer: }, "recommendations": self._generate_recommendations(library, analysis, validation_results) } - - def _generate_recommendations(self, library: SymbolLibrary, - analysis: Dict[str, Any], - validation_results: List[Dict[str, Any]]) -> List[str]: + + def _generate_recommendations(self, library: SymbolLibrary, + analysis: dict[str, Any], + validation_results: list[dict[str, Any]]) -> list[str]: """Generate recommendations for library improvement.""" recommendations = [] - + # Check for missing footprint filters no_filters = [s for s in library.symbols if not s.footprint_filters] if len(no_filters) > len(library.symbols) * 0.5: recommendations.append("Consider adding footprint filters to more symbols for better component matching") - + # Check for validation issues if validation_results: recommendations.append(f"Address {len(validation_results)} symbols with validation issues") - + # Check pin distribution if analysis["statistics"]["avg_pins_per_symbol"] > 50: recommendations.append("Library contains many high-pin-count symbols - consider splitting complex symbols") - + # Check category distribution if len(analysis["categories"]) < 5: recommendations.append("Consider adding more keyword categories for better symbol organization") - + return recommendations def create_symbol_analyzer() -> SymbolLibraryAnalyzer: """Create and initialize a symbol library analyzer.""" - return SymbolLibraryAnalyzer() \ No newline at end of file + return SymbolLibraryAnalyzer() diff --git a/kicad_mcp/utils/temp_dir_manager.py b/kicad_mcp/utils/temp_dir_manager.py index c724dca..9b27fec 100644 --- a/kicad_mcp/utils/temp_dir_manager.py +++ b/kicad_mcp/utils/temp_dir_manager.py @@ -2,10 +2,9 @@ Utility for managing temporary directories. """ -from typing import List # List of temporary directories to clean up -_temp_dirs: List[str] = [] +_temp_dirs: list[str] = [] def register_temp_dir(temp_dir: str) -> None: @@ -18,7 +17,7 @@ def register_temp_dir(temp_dir: str) -> None: _temp_dirs.append(temp_dir) -def get_temp_dirs() -> List[str]: +def get_temp_dirs() -> list[str]: """Get all registered temporary directories. Returns: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b04fd22..d5533ba 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -3,8 +3,7 @@ Tests for the kicad_mcp.config module. """ import os import platform -from unittest.mock import patch, MagicMock -import pytest +from unittest.mock import patch class TestConfigModule: @@ -13,7 +12,7 @@ class TestConfigModule: def test_system_detection(self): """Test that system is properly detected.""" from kicad_mcp.config import system - + assert system in ['Darwin', 'Windows', 'Linux'] or isinstance(system, str) assert system == platform.system() @@ -22,12 +21,13 @@ class TestConfigModule: with patch('platform.system', return_value='Darwin'): # Need to reload the config module after patching import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - - from kicad_mcp.config import KICAD_USER_DIR, KICAD_APP_PATH, KICAD_PYTHON_BASE - - assert KICAD_USER_DIR == os.path.expanduser("~/Documents/KiCad") + + from kicad_mcp.config import KICAD_APP_PATH, KICAD_PYTHON_BASE, KICAD_USER_DIR + + assert os.path.expanduser("~/Documents/KiCad") == KICAD_USER_DIR assert KICAD_APP_PATH == "/Applications/KiCad/KiCad.app" assert "Contents/Frameworks/Python.framework" in KICAD_PYTHON_BASE @@ -35,12 +35,13 @@ class TestConfigModule: """Test Windows-specific path configuration.""" with patch('platform.system', return_value='Windows'): import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - - from kicad_mcp.config import KICAD_USER_DIR, KICAD_APP_PATH, KICAD_PYTHON_BASE - - assert KICAD_USER_DIR == os.path.expanduser("~/Documents/KiCad") + + from kicad_mcp.config import KICAD_APP_PATH, KICAD_PYTHON_BASE, KICAD_USER_DIR + + assert os.path.expanduser("~/Documents/KiCad") == KICAD_USER_DIR assert KICAD_APP_PATH == r"C:\Program Files\KiCad" assert KICAD_PYTHON_BASE == "" @@ -48,12 +49,13 @@ class TestConfigModule: """Test Linux-specific path configuration.""" with patch('platform.system', return_value='Linux'): import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - - from kicad_mcp.config import KICAD_USER_DIR, KICAD_APP_PATH, KICAD_PYTHON_BASE - - assert KICAD_USER_DIR == os.path.expanduser("~/KiCad") + + from kicad_mcp.config import KICAD_APP_PATH, KICAD_PYTHON_BASE, KICAD_USER_DIR + + assert os.path.expanduser("~/KiCad") == KICAD_USER_DIR assert KICAD_APP_PATH == "/usr/share/kicad" assert KICAD_PYTHON_BASE == "" @@ -61,21 +63,22 @@ class TestConfigModule: """Test that unknown systems default to macOS paths.""" with patch('platform.system', return_value='FreeBSD'): import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - - from kicad_mcp.config import KICAD_USER_DIR, KICAD_APP_PATH - - assert KICAD_USER_DIR == os.path.expanduser("~/Documents/KiCad") + + from kicad_mcp.config import KICAD_APP_PATH, KICAD_USER_DIR + + assert os.path.expanduser("~/Documents/KiCad") == KICAD_USER_DIR assert KICAD_APP_PATH == "/Applications/KiCad/KiCad.app" def test_kicad_extensions(self): """Test KiCad file extension mappings.""" from kicad_mcp.config import KICAD_EXTENSIONS - - expected_keys = ["project", "pcb", "schematic", "design_rules", + + expected_keys = ["project", "pcb", "schematic", "design_rules", "worksheet", "footprint", "netlist", "kibot_config"] - + for key in expected_keys: assert key in KICAD_EXTENSIONS assert isinstance(KICAD_EXTENSIONS[key], str) @@ -84,10 +87,10 @@ class TestConfigModule: def test_data_extensions(self): """Test data file extensions list.""" from kicad_mcp.config import DATA_EXTENSIONS - + assert isinstance(DATA_EXTENSIONS, list) assert len(DATA_EXTENSIONS) > 0 - + expected_extensions = [".csv", ".pos", ".net", ".zip", ".drl"] for ext in expected_extensions: assert ext in DATA_EXTENSIONS @@ -95,13 +98,13 @@ class TestConfigModule: def test_circuit_defaults(self): """Test circuit default parameters.""" from kicad_mcp.config import CIRCUIT_DEFAULTS - - required_keys = ["grid_spacing", "component_spacing", "wire_width", + + required_keys = ["grid_spacing", "component_spacing", "wire_width", "text_size", "pin_length"] - + for key in required_keys: assert key in CIRCUIT_DEFAULTS - + # Test specific types assert isinstance(CIRCUIT_DEFAULTS["text_size"], list) assert len(CIRCUIT_DEFAULTS["text_size"]) == 2 @@ -110,13 +113,13 @@ class TestConfigModule: def test_common_libraries_structure(self): """Test common libraries configuration structure.""" from kicad_mcp.config import COMMON_LIBRARIES - + expected_categories = ["basic", "power", "connectors"] - + for category in expected_categories: assert category in COMMON_LIBRARIES assert isinstance(COMMON_LIBRARIES[category], dict) - + for component, info in COMMON_LIBRARIES[category].items(): assert "library" in info assert "symbol" in info @@ -126,15 +129,15 @@ class TestConfigModule: def test_default_footprints_structure(self): """Test default footprints configuration structure.""" from kicad_mcp.config import DEFAULT_FOOTPRINTS - + # Test that at least some common components are present common_components = ["R", "C", "LED", "D"] - + for component in common_components: assert component in DEFAULT_FOOTPRINTS assert isinstance(DEFAULT_FOOTPRINTS[component], list) assert len(DEFAULT_FOOTPRINTS[component]) > 0 - + # All footprints should be strings for footprint in DEFAULT_FOOTPRINTS[component]: assert isinstance(footprint, str) @@ -143,10 +146,10 @@ class TestConfigModule: def test_timeout_constants(self): """Test timeout constants are reasonable values.""" from kicad_mcp.config import TIMEOUT_CONSTANTS - - required_keys = ["kicad_cli_version_check", "kicad_cli_export", + + required_keys = ["kicad_cli_version_check", "kicad_cli_export", "application_open", "subprocess_default"] - + for key in required_keys: assert key in TIMEOUT_CONSTANTS timeout = TIMEOUT_CONSTANTS[key] @@ -156,10 +159,10 @@ class TestConfigModule: def test_progress_constants(self): """Test progress constants are valid percentages.""" from kicad_mcp.config import PROGRESS_CONSTANTS - - required_keys = ["start", "detection", "setup", "processing", + + required_keys = ["start", "detection", "setup", "processing", "finishing", "validation", "complete"] - + for key in required_keys: assert key in PROGRESS_CONSTANTS progress = PROGRESS_CONSTANTS[key] @@ -169,7 +172,7 @@ class TestConfigModule: def test_display_constants(self): """Test display constants are reasonable values.""" from kicad_mcp.config import DISPLAY_CONSTANTS - + assert "bom_preview_limit" in DISPLAY_CONSTANTS limit = DISPLAY_CONSTANTS["bom_preview_limit"] assert isinstance(limit, int) @@ -179,9 +182,10 @@ class TestConfigModule: """Test behavior with empty KICAD_SEARCH_PATHS.""" with patch.dict(os.environ, {"KICAD_SEARCH_PATHS": ""}): import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - + # Should still have default locations if they exist from kicad_mcp.config import ADDITIONAL_SEARCH_PATHS assert isinstance(ADDITIONAL_SEARCH_PATHS, list) @@ -191,11 +195,12 @@ class TestConfigModule: with patch.dict(os.environ, {"KICAD_SEARCH_PATHS": "/nonexistent/path1,/nonexistent/path2"}), \ patch('os.path.exists', return_value=False): import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - + from kicad_mcp.config import ADDITIONAL_SEARCH_PATHS - + # Should not contain the nonexistent paths assert "/nonexistent/path1" not in ADDITIONAL_SEARCH_PATHS assert "/nonexistent/path2" not in ADDITIONAL_SEARCH_PATHS @@ -205,13 +210,14 @@ class TestConfigModule: with patch.dict(os.environ, {"KICAD_SEARCH_PATHS": "~/test_path1, ~/test_path2 "}), \ patch('os.path.exists', return_value=True), \ patch('os.path.expanduser', side_effect=lambda x: x.replace("~", "/home/user")): - + import importlib + import kicad_mcp.config importlib.reload(kicad_mcp.config) - + from kicad_mcp.config import ADDITIONAL_SEARCH_PATHS - + # Should contain expanded paths assert "/home/user/test_path1" in ADDITIONAL_SEARCH_PATHS assert "/home/user/test_path2" in ADDITIONAL_SEARCH_PATHS @@ -219,10 +225,10 @@ class TestConfigModule: def test_default_project_locations_expanded(self): """Test that default project locations are properly expanded.""" from kicad_mcp.config import DEFAULT_PROJECT_LOCATIONS - + assert isinstance(DEFAULT_PROJECT_LOCATIONS, list) assert len(DEFAULT_PROJECT_LOCATIONS) > 0 - + # All should start with ~/ for location in DEFAULT_PROJECT_LOCATIONS: - assert location.startswith("~/") \ No newline at end of file + assert location.startswith("~/") diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 01b1f20..870f30e 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -1,8 +1,8 @@ """ Tests for the kicad_mcp.context module. """ -import asyncio -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch + import pytest from kicad_mcp.context import KiCadAppContext, kicad_lifespan @@ -17,7 +17,7 @@ class TestKiCadAppContext: kicad_modules_available=True, cache={} ) - + assert context.kicad_modules_available is True assert context.cache == {} assert isinstance(context.cache, dict) @@ -29,7 +29,7 @@ class TestKiCadAppContext: kicad_modules_available=False, cache=test_cache ) - + assert context.kicad_modules_available is False assert context.cache == test_cache assert context.cache["test_key"] == "test_value" @@ -41,11 +41,11 @@ class TestKiCadAppContext: kicad_modules_available=True, cache={"initial": "value"} ) - + # Should be able to modify the cache (it's mutable) context.cache["new_key"] = "new_value" assert context.cache["new_key"] == "new_value" - + # Should be able to reassign fields context.kicad_modules_available = False assert context.kicad_modules_available is False @@ -69,10 +69,10 @@ class TestKiCadLifespan: assert context.kicad_modules_available is True assert isinstance(context.cache, dict) assert len(context.cache) == 0 - + # Add something to cache to test cleanup context.cache["test"] = "value" - + # Verify logging calls mock_logging.info.assert_any_call("Starting KiCad MCP server initialization") mock_logging.info.assert_any_call("KiCad MCP server initialization complete") @@ -94,7 +94,7 @@ class TestKiCadLifespan: context.cache["key1"] = "value1" context.cache["key2"] = {"nested": "data"} context.cache["key3"] = [1, 2, 3] - + assert context.cache["key1"] == "value1" assert context.cache["key2"]["nested"] == "data" assert context.cache["key3"] == [1, 2, 3] @@ -109,7 +109,7 @@ class TestKiCadLifespan: context.cache["test1"] = "value1" context.cache["test2"] = "value2" assert len(context.cache) == 2 - + # Verify cache cleanup was logged mock_logging.info.assert_any_call("Clearing cache with 2 entries") @@ -121,7 +121,7 @@ class TestKiCadLifespan: async with kicad_lifespan(mock_server, kicad_modules_available=True) as context: context.cache["test"] = "value" raise ValueError("Test exception") - + # Verify cleanup still occurred mock_logging.info.assert_any_call("Shutting down KiCad MCP server") mock_logging.info.assert_any_call("KiCad MCP server shutdown complete") @@ -132,11 +132,11 @@ class TestKiCadLifespan: """Test temporary directory cleanup functionality.""" with patch('kicad_mcp.context.logging') as mock_logging, \ patch('kicad_mcp.context.shutil') as mock_shutil: - + async with kicad_lifespan(mock_server, kicad_modules_available=True) as context: # The current implementation has an empty created_temp_dirs list pass - + # Verify shutil was imported (even if not used in current implementation) # This tests the import doesn't fail @@ -147,26 +147,26 @@ class TestKiCadLifespan: # Mock the created_temp_dirs to have some directories for testing with patch('kicad_mcp.context.logging') as mock_logging, \ patch('kicad_mcp.context.shutil') as mock_shutil: - + # Patch the created_temp_dirs list in the function scope original_lifespan = kicad_lifespan - + async def patched_lifespan(server, kicad_modules_available=False): async with original_lifespan(server, kicad_modules_available) as context: # Simulate having temp directories to clean up context._temp_dirs = ["/tmp/test1", "/tmp/test2"] # Add test attribute yield context - + # Simulate cleanup with error test_dirs = ["/tmp/test1", "/tmp/test2"] mock_shutil.rmtree.side_effect = [None, OSError("Permission denied")] - + for temp_dir in test_dirs: try: mock_shutil.rmtree(temp_dir, ignore_errors=True) except Exception as e: mock_logging.error(f"Error cleaning up temporary directory {temp_dir}: {str(e)}") - + # The current implementation doesn't actually have temp dirs, so we test the structure async with kicad_lifespan(mock_server) as context: pass @@ -186,7 +186,7 @@ class TestKiCadLifespan: with patch('kicad_mcp.context.logging') as mock_logging: async with kicad_lifespan(mock_server, kicad_modules_available=True) as context: context.cache["test"] = "data" - + # Check specific log messages expected_calls = [ "Starting KiCad MCP server initialization", @@ -196,7 +196,7 @@ class TestKiCadLifespan: "Clearing cache with 1 entries", "KiCad MCP server shutdown complete" ] - + for expected_call in expected_calls: mock_logging.info.assert_any_call(expected_call) @@ -207,23 +207,23 @@ class TestKiCadLifespan: async with kicad_lifespan(mock_server, kicad_modules_available=False) as context: # Don't add anything to cache pass - + # Should not log cache clearing for empty cache calls = [call.args[0] for call in mock_logging.info.call_args_list] cache_clear_calls = [call for call in calls if "Clearing cache" in call] assert len(cache_clear_calls) == 0 - @pytest.mark.asyncio + @pytest.mark.asyncio async def test_multiple_lifespan_instances(self, mock_server): """Test that multiple lifespan instances work independently.""" # Test sequential usage async with kicad_lifespan(mock_server, kicad_modules_available=True) as context1: context1.cache["instance1"] = "data1" assert len(context1.cache) == 1 - + async with kicad_lifespan(mock_server, kicad_modules_available=False) as context2: context2.cache["instance2"] = "data2" assert len(context2.cache) == 1 assert context2.kicad_modules_available is False # Should not have data from first instance - assert "instance1" not in context2.cache \ No newline at end of file + assert "instance1" not in context2.cache diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 057749f..792b4b8 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -2,18 +2,19 @@ Tests for the kicad_mcp.server module. """ import logging -from unittest.mock import Mock, patch, MagicMock, call -import pytest import signal +from unittest.mock import Mock, call, patch + +import pytest from kicad_mcp.server import ( - add_cleanup_handler, - run_cleanup_handlers, - shutdown_server, - register_signal_handlers, + add_cleanup_handler, create_server, + main, + register_signal_handlers, + run_cleanup_handlers, setup_logging, - main + shutdown_server, ) @@ -29,9 +30,9 @@ class TestCleanupHandlers: """Test adding cleanup handlers.""" def dummy_handler(): pass - + add_cleanup_handler(dummy_handler) - + from kicad_mcp.server import cleanup_handlers assert dummy_handler in cleanup_handlers @@ -39,13 +40,13 @@ class TestCleanupHandlers: """Test adding multiple cleanup handlers.""" def handler1(): pass - + def handler2(): pass - + add_cleanup_handler(handler1) add_cleanup_handler(handler2) - + from kicad_mcp.server import cleanup_handlers assert handler1 in cleanup_handlers assert handler2 in cleanup_handlers @@ -58,12 +59,12 @@ class TestCleanupHandlers: handler1.__name__ = "handler1" handler2 = Mock() handler2.__name__ = "handler2" - + add_cleanup_handler(handler1) add_cleanup_handler(handler2) - + run_cleanup_handlers() - + handler1.assert_called_once() handler2.assert_called_once() mock_logging.info.assert_any_call("Running cleanup handlers...") @@ -75,17 +76,17 @@ class TestCleanupHandlers: def failing_handler(): raise ValueError("Test error") failing_handler.__name__ = "failing_handler" - + def working_handler(): pass working_handler.__name__ = "working_handler" - + add_cleanup_handler(failing_handler) add_cleanup_handler(working_handler) - + # Should not raise exception run_cleanup_handlers() - + mock_logging.error.assert_called() # Should still log success for working handler mock_logging.info.assert_any_call("Cleanup handler working_handler completed successfully") @@ -96,13 +97,13 @@ class TestCleanupHandlers: """Test that cleanup handlers don't run twice.""" handler = Mock() handler.__name__ = "test_handler" - + add_cleanup_handler(handler) - + # Run twice run_cleanup_handlers() run_cleanup_handlers() - + # Handler should only be called once handler.assert_called_once() @@ -119,16 +120,16 @@ class TestServerShutdown: def test_shutdown_server_with_instance(self, mock_logging): """Test shutting down server when instance exists.""" import kicad_mcp.server - + # Set up mock server instance mock_server = Mock() kicad_mcp.server._server_instance = mock_server - + shutdown_server() - + mock_logging.info.assert_any_call("Shutting down KiCad MCP server") mock_logging.info.assert_any_call("KiCad MCP server shutdown complete") - + # Server instance should be cleared assert kicad_mcp.server._server_instance is None @@ -136,7 +137,7 @@ class TestServerShutdown: def test_shutdown_server_no_instance(self, mock_logging): """Test shutting down server when no instance exists.""" shutdown_server() - + # Should not log anything since no server instance exists mock_logging.info.assert_not_called() @@ -149,15 +150,15 @@ class TestSignalHandlers: def test_register_signal_handlers_success(self, mock_logging, mock_signal): """Test successful signal handler registration.""" mock_server = Mock() - + register_signal_handlers(mock_server) - + # Should register handlers for SIGINT and SIGTERM expected_calls = [ call(signal.SIGINT, mock_signal.call_args_list[0][0][1]), call(signal.SIGTERM, mock_signal.call_args_list[1][0][1]) ] - + assert mock_signal.call_count == 2 mock_logging.info.assert_any_call("Registered handler for signal 2") # SIGINT mock_logging.info.assert_any_call("Registered handler for signal 15") # SIGTERM @@ -168,9 +169,9 @@ class TestSignalHandlers: """Test signal handler registration failure.""" mock_server = Mock() mock_signal.side_effect = ValueError("Signal not supported") - + register_signal_handlers(mock_server) - + # Should log errors for failed registrations mock_logging.error.assert_called() @@ -181,16 +182,16 @@ class TestSignalHandlers: def test_signal_handler_execution(self, mock_logging, mock_exit, mock_shutdown, mock_cleanup): """Test that signal handler executes cleanup and shutdown.""" mock_server = Mock() - + with patch('kicad_mcp.server.signal.signal') as mock_signal: register_signal_handlers(mock_server) - + # Get the registered handler function handler_func = mock_signal.call_args_list[0][0][1] - + # Call the handler handler_func(signal.SIGINT, None) - + # Verify cleanup sequence mock_logging.info.assert_any_call("Received signal 2, initiating shutdown...") mock_cleanup.assert_called_once() @@ -210,20 +211,20 @@ class TestCreateServer: """Test basic server creation.""" mock_server_instance = Mock() mock_fastmcp.return_value = mock_server_instance - + server = create_server() - + # Verify FastMCP was created with correct parameters mock_fastmcp.assert_called_once() args, kwargs = mock_fastmcp.call_args assert args[0] == "KiCad" # Server name assert "lifespan" in kwargs - + # Verify signal handlers and cleanup were registered mock_register_signals.assert_called_once_with(mock_server_instance) mock_atexit.assert_called_once() mock_add_cleanup.assert_called() - + assert server == mock_server_instance @patch('kicad_mcp.server.logging') @@ -232,13 +233,13 @@ class TestCreateServer: """Test server creation logging.""" mock_server_instance = Mock() mock_fastmcp.return_value = mock_server_instance - + with patch('kicad_mcp.server.register_signal_handlers'), \ patch('kicad_mcp.server.atexit.register'), \ patch('kicad_mcp.server.add_cleanup_handler'): - + create_server() - + # Verify logging calls expected_log_calls = [ "Initializing KiCad MCP server", @@ -249,7 +250,7 @@ class TestCreateServer: "Registering prompts...", "Server initialization complete" ] - + for expected_call in expected_log_calls: mock_logging.info.assert_any_call(expected_call) @@ -262,15 +263,15 @@ class TestCreateServer: # Mock temp directories mock_get_temp_dirs.return_value = ["/tmp/test1", "/tmp/test2"] mock_exists.return_value = True - + with patch('kicad_mcp.server.FastMCP'), \ patch('kicad_mcp.server.register_signal_handlers'), \ patch('kicad_mcp.server.atexit.register'), \ patch('kicad_mcp.server.add_cleanup_handler') as mock_add_cleanup, \ patch('kicad_mcp.server.shutil.rmtree') as mock_rmtree: - + create_server() - + # Get the cleanup handler that was added cleanup_calls = mock_add_cleanup.call_args_list cleanup_handler = None @@ -279,7 +280,7 @@ class TestCreateServer: if 'cleanup_temp_dirs' in str(call_args[0]): cleanup_handler = call_args[0] break - + # Execute the cleanup handler manually to test it if cleanup_handler: cleanup_handler() @@ -294,10 +295,10 @@ class TestSetupLogging: def test_setup_logging(self, mock_basic_config): """Test logging setup configuration.""" setup_logging() - + mock_basic_config.assert_called_once() args, kwargs = mock_basic_config.call_args - + assert kwargs['level'] == logging.INFO assert 'format' in kwargs assert '%(asctime)s' in kwargs['format'] @@ -314,13 +315,13 @@ class TestMain: """Test successful main execution.""" mock_server = Mock() mock_create_server.return_value = mock_server - + main() - + mock_setup_logging.assert_called_once() mock_create_server.assert_called_once() mock_server.run.assert_called_once() - + mock_logging.info.assert_any_call("Starting KiCad MCP server...") mock_logging.info.assert_any_call("Server shutdown complete") @@ -332,9 +333,9 @@ class TestMain: mock_server = Mock() mock_server.run.side_effect = KeyboardInterrupt() mock_create_server.return_value = mock_server - + main() - + mock_logging.info.assert_any_call("Server interrupted by user") mock_logging.info.assert_any_call("Server shutdown complete") @@ -346,9 +347,9 @@ class TestMain: mock_server = Mock() mock_server.run.side_effect = RuntimeError("Server error") mock_create_server.return_value = mock_server - + main() - + mock_logging.error.assert_any_call("Server error: Server error") mock_logging.info.assert_any_call("Server shutdown complete") @@ -359,9 +360,9 @@ class TestMain: mock_server = Mock() mock_server.run.side_effect = Exception("Test exception") mock_create_server.return_value = mock_server - + with patch('kicad_mcp.server.logging') as mock_logging: main() - + # Verify finally block executed - mock_logging.info.assert_any_call("Server shutdown complete") \ No newline at end of file + mock_logging.info.assert_any_call("Server shutdown complete") diff --git a/tests/unit/utils/test_component_utils.py b/tests/unit/utils/test_component_utils.py index b9e9083..f02ed1a 100644 --- a/tests/unit/utils/test_component_utils.py +++ b/tests/unit/utils/test_component_utils.py @@ -4,17 +4,17 @@ Tests for the kicad_mcp.utils.component_utils module. import pytest from kicad_mcp.utils.component_utils import ( - extract_voltage_from_regulator, - extract_frequency_from_value, - extract_resistance_value, extract_capacitance_value, + extract_frequency_from_value, extract_inductance_value, - format_resistance, + extract_resistance_value, + extract_voltage_from_regulator, format_capacitance, format_inductance, - normalize_component_value, + format_resistance, get_component_type_from_reference, - is_power_component + is_power_component, + normalize_component_value, ) @@ -30,7 +30,7 @@ class TestExtractVoltageFromRegulator: ("7815", "15V"), ("LM7805", "5V"), ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -42,7 +42,7 @@ class TestExtractVoltageFromRegulator: ("LM7905", "5V"), # Actually returns positive value based on pattern ("LM7912", "12V"), # Actually returns positive value based on pattern ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -57,7 +57,7 @@ class TestExtractVoltageFromRegulator: ("LD1117-5.0", "5V"), # Returns 5V not 5.0V ("REG_5V", "5V"), ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -72,7 +72,7 @@ class TestExtractVoltageFromRegulator: ("MCP1700-3.3", "3.3V"), ("MCP1700-5.0", "5V"), ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -85,7 +85,7 @@ class TestExtractVoltageFromRegulator: ("78xx", "unknown"), ("7890", "unknown"), # Outside reasonable range ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -97,7 +97,7 @@ class TestExtractVoltageFromRegulator: ("Lm7805", "5V"), ("lm1117-3.3", "3.3V"), ] - + for value, expected in test_cases: assert extract_voltage_from_regulator(value) == expected @@ -116,7 +116,7 @@ class TestExtractFrequencyFromValue: ("27M", "27.000MHz"), ("32k", "32.000kHz"), ] - + for value, expected in test_cases: assert extract_frequency_from_value(value) == expected @@ -131,7 +131,7 @@ class TestExtractFrequencyFromValue: ("27MHZ", "27.000MHz"), # Function returns with decimal precision ("25MHz", "25.000MHz"), # Function returns with decimal precision ] - + for value, expected in test_cases: assert extract_frequency_from_value(value) == expected @@ -143,7 +143,7 @@ class TestExtractFrequencyFromValue: ("500Hz", "500.000Hz"), # Small value with Hz ("16MHz", "16.000MHz"), # MHz value ] - + for value, expected in test_cases: assert extract_frequency_from_value(value) == expected @@ -155,7 +155,7 @@ class TestExtractFrequencyFromValue: ("no_freq_here", "unknown"), ("ABC", "unknown"), ] - + for value, expected in test_cases: assert extract_frequency_from_value(value) == expected @@ -166,7 +166,7 @@ class TestExtractFrequencyFromValue: ("32.768 kHz", "32.768kHz"), ("Crystal 16MHz", "16.000MHz"), # Description with frequency ] - + for value, expected in test_cases: assert extract_frequency_from_value(value) == expected @@ -184,7 +184,7 @@ class TestExtractResistanceValue: ("47R", (47.0, "Ω")), ("2.2", (2.2, "Ω")), ] - + for value, expected in test_cases: assert extract_resistance_value(value) == expected @@ -194,11 +194,11 @@ class TestExtractResistanceValue: # It extracts the first part before the unit test_cases = [ ("4k7", (4.0, "K")), # Gets 4 from "4k7" - ("2k2", (2.0, "K")), # Gets 2 from "2k2" + ("2k2", (2.0, "K")), # Gets 2 from "2k2" ("1M2", (1.0, "M")), # Gets 1 from "1M2" ("10k5", (10.0, "K")), # Gets 10 from "10k5" ] - + for value, expected in test_cases: assert extract_resistance_value(value) == expected @@ -211,7 +211,7 @@ class TestExtractResistanceValue: ("abc", (None, None)), ("xyz123", (None, None)), # Invalid format, changed from k10 which matches ] - + for value, expected in test_cases: assert extract_resistance_value(value) == expected @@ -225,7 +225,7 @@ class TestExtractResistanceValue: ("1m", (1.0, "M")), ("1M", (1.0, "M")), ] - + for value, expected in test_cases: result = extract_resistance_value(value) assert result[0] == expected[0] @@ -245,20 +245,20 @@ class TestExtractCapacitanceValue: ("22μF", (22.0, "μF")), ("0.1μF", (0.1, "μF")), ] - + for value, expected in test_cases: assert extract_capacitance_value(value) == expected def test_special_notation(self): """Test special notation like '4n7' - current implementation limitation.""" - # Note: Current implementation doesn't properly handle 4n7 = 4.7nF + # Note: Current implementation doesn't properly handle 4n7 = 4.7nF test_cases = [ ("4n7", (4.0, "nF")), # Gets 4 from "4n7" ("2u2", (2.0, "μF")), # Gets 2 from "2u2" ("10p5", (10.0, "pF")), # Gets 10 from "10p5" ("1μ2", (1.0, "μF")), # Gets 1 from "1μ2" ] - + for value, expected in test_cases: assert extract_capacitance_value(value) == expected @@ -272,7 +272,7 @@ class TestExtractCapacitanceValue: ("100pf", (100.0, "pF")), ("100PF", (100.0, "pF")), ] - + for value, expected in test_cases: assert extract_capacitance_value(value) == expected @@ -284,7 +284,7 @@ class TestExtractCapacitanceValue: ("10X", (None, None)), ("abc", (None, None)), ] - + for value, expected in test_cases: assert extract_capacitance_value(value) == expected @@ -301,7 +301,7 @@ class TestExtractInductanceValue: ("22μH", (22.0, "μH")), ("1mH", (1.0, "mH")), # Changed from "1H" which doesn't match the pattern ] - + for value, expected in test_cases: assert extract_inductance_value(value) == expected @@ -312,7 +312,7 @@ class TestExtractInductanceValue: ("2m2H", (2.2, "mH")), ("10n5H", (10.5, "nH")), ] - + for value, expected in test_cases: assert extract_inductance_value(value) == expected @@ -324,7 +324,7 @@ class TestExtractInductanceValue: ("10X", (None, None)), ("abc", (None, None)), ] - + for value, expected in test_cases: assert extract_inductance_value(value) == expected @@ -340,7 +340,7 @@ class TestFormatFunctions: ((1.0, "M"), "1MΩ"), ((10.0, "k"), "10kΩ"), ] - + for (value, unit), expected in test_cases: assert format_resistance(value, unit) == expected @@ -352,7 +352,7 @@ class TestFormatFunctions: ((10.0, "μF"), "10μF"), ((0.1, "μF"), "0.1μF"), ] - + for (value, unit), expected in test_cases: assert format_capacitance(value, unit) == expected @@ -364,7 +364,7 @@ class TestFormatFunctions: ((10.0, "mH"), "10mH"), ((1.0, "H"), "1H"), ] - + for (value, unit), expected in test_cases: assert format_inductance(value, unit) == expected @@ -380,12 +380,12 @@ class TestNormalizeComponentValue: ("100", "R", "100Ω"), ("1M", "R", "1MΩ"), ] - + for value, comp_type, expected in test_cases: result = normalize_component_value(value, comp_type) # Handle the .0 formatting for integer values if result == "10.0K": - result = "10K" + result = "10K" assert result == expected def test_capacitor_normalization(self): @@ -395,7 +395,7 @@ class TestNormalizeComponentValue: ("4.7nF", "C", "4.7nF"), ("100pF", "C", "100pF"), ] - + for value, comp_type, expected in test_cases: assert normalize_component_value(value, comp_type) == expected @@ -406,7 +406,7 @@ class TestNormalizeComponentValue: ("4.7nH", "L", "4.7nH"), ("100mH", "L", "100mH"), ] - + for value, comp_type, expected in test_cases: assert normalize_component_value(value, comp_type) == expected @@ -438,7 +438,7 @@ class TestGetComponentTypeFromReference: ("LED1", "LED"), ("SW1", "SW"), ] - + for reference, expected in test_cases: assert get_component_type_from_reference(reference) == expected @@ -451,7 +451,7 @@ class TestGetComponentTypeFromReference: ("PWR1", "PWR"), ("REG1", "REG"), ] - + for reference, expected in test_cases: assert get_component_type_from_reference(reference) == expected @@ -462,7 +462,7 @@ class TestGetComponentTypeFromReference: ("Led1", "Led"), ("PWr1", "PWr"), ] - + for reference, expected in test_cases: assert get_component_type_from_reference(reference) == expected @@ -473,7 +473,7 @@ class TestGetComponentTypeFromReference: ("", ""), # Empty string ("123", ""), # All numbers ] - + for reference, expected in test_cases: assert get_component_type_from_reference(reference) == expected @@ -484,7 +484,7 @@ class TestGetComponentTypeFromReference: ("IC_1", "IC_"), ("U_PWR1", "U_PWR"), ] - + for reference, expected in test_cases: assert get_component_type_from_reference(reference) == expected @@ -501,7 +501,7 @@ class TestIsPowerComponent: ({"reference": "R1"}, False), ({"reference": "C1"}, False), ] - + for component, expected in test_cases: assert is_power_component(component) == expected @@ -514,7 +514,7 @@ class TestIsPowerComponent: ({"lib_id": "power:VDD", "reference": "U1"}, True), ({"value": "74HC00", "reference": "U1"}, False), ] - + for component, expected in test_cases: assert is_power_component(component) == expected @@ -530,7 +530,7 @@ class TestIsPowerComponent: ({"value": "74HC00", "reference": "U1"}, False), ({"value": "BC547", "reference": "Q1"}, False), ] - + for component, expected in test_cases: assert is_power_component(component) == expected @@ -542,7 +542,7 @@ class TestIsPowerComponent: ({"value": "lm317", "reference": "U1"}, True), ({"lib_id": "POWER:VDD", "reference": "U1"}, True), ] - + for component, expected in test_cases: assert is_power_component(component) == expected @@ -554,7 +554,7 @@ class TestIsPowerComponent: ({"value": "", "reference": "U1"}, False), ({"lib_id": "", "reference": "U1"}, False), ] - + for component, expected in test_cases: assert is_power_component(component) == expected @@ -566,14 +566,14 @@ class TestIsPowerComponent: "lib_id": "Regulator_Linear:L7805", "footprint": "TO-220-3", } - + non_power_component = { "reference": "U2", "value": "74HC00", "lib_id": "Logic:74HC00", "footprint": "SOIC-14", } - + assert is_power_component(power_component) == True assert is_power_component(non_power_component) == False @@ -589,16 +589,16 @@ class TestIntegration: "value": "10k", "lib_id": "Device:R" } - + comp_type = get_component_type_from_reference(resistor["reference"]) assert comp_type == "R" - + normalized_value = normalize_component_value(resistor["value"], comp_type) - # Handle the .0 formatting for integer values + # Handle the .0 formatting for integer values if normalized_value == "10.0K": normalized_value = "10K" assert normalized_value == "10K" - + assert not is_power_component(resistor) def test_power_regulator_analysis(self): @@ -608,13 +608,13 @@ class TestIntegration: "value": "LM7805", "lib_id": "Regulator_Linear:L7805" } - + comp_type = get_component_type_from_reference(regulator["reference"]) assert comp_type == "U" - + voltage = extract_voltage_from_regulator(regulator["value"]) assert voltage == "5V" - + assert is_power_component(regulator) def test_crystal_analysis(self): @@ -624,11 +624,11 @@ class TestIntegration: "value": "16MHz Crystal", "lib_id": "Device:Crystal" } - + comp_type = get_component_type_from_reference(crystal["reference"]) assert comp_type == "Y" - + frequency = extract_frequency_from_value(crystal["value"]) assert frequency == "16.000MHz" - - assert not is_power_component(crystal) \ No newline at end of file + + assert not is_power_component(crystal) diff --git a/tests/unit/utils/test_file_utils.py b/tests/unit/utils/test_file_utils.py index 93686ab..538a769 100644 --- a/tests/unit/utils/test_file_utils.py +++ b/tests/unit/utils/test_file_utils.py @@ -4,8 +4,7 @@ Tests for the kicad_mcp.utils.file_utils module. import json import os import tempfile -from unittest.mock import Mock, patch, mock_open -import pytest +from unittest.mock import mock_open, patch from kicad_mcp.utils.file_utils import get_project_files, load_project_json @@ -23,9 +22,9 @@ class TestGetProjectFiles: mock_get_name.return_value = "myproject" mock_exists.side_effect = lambda x: x.endswith(('.kicad_pcb', '.kicad_sch')) mock_listdir.return_value = ["myproject-bom.csv", "myproject-pos.pos"] - + result = get_project_files("/test/project/myproject.kicad_pro") - + # Should include project file and detected files assert result["project"] == "/test/project/myproject.kicad_pro" assert "pcb" in result or "schematic" in result @@ -41,14 +40,14 @@ class TestGetProjectFiles: mock_dirname.return_value = "/test/project" mock_get_name.return_value = "test_project" mock_listdir.return_value = [] - + # Mock all KiCad extensions as existing def mock_exists_func(path): return any(ext in path for ext in ['.kicad_pcb', '.kicad_sch', '.kicad_mod']) mock_exists.side_effect = mock_exists_func - + result = get_project_files("/test/project/test_project.kicad_pro") - + assert result["project"] == "/test/project/test_project.kicad_pro" # Check that KiCad file types are included expected_types = ["pcb", "schematic", "footprint"] @@ -72,15 +71,15 @@ class TestGetProjectFiles: "project-gerbers.zip", "project.drl" ] - + result = get_project_files("/test/project/project.kicad_pro") - + # Should have project file and data files assert result["project"] == "/test/project/project.kicad_pro" assert "bom" in result assert "positions" in result assert "net" in result - + # Check paths are correct assert result["bom"] == "/test/project/project-bom.csv" assert result["positions"] == "/test/project/project_positions.pos" @@ -95,9 +94,9 @@ class TestGetProjectFiles: mock_get_name.return_value = "project" mock_exists.return_value = False mock_listdir.side_effect = OSError("Permission denied") - + result = get_project_files("/test/project/project.kicad_pro") - + # Should still return project file assert result["project"] == "/test/project/project.kicad_pro" # Should not crash and return basic result @@ -113,9 +112,9 @@ class TestGetProjectFiles: mock_get_name.return_value = "project" mock_exists.return_value = False mock_listdir.return_value = ["other_file.txt", "unrelated.csv"] - + result = get_project_files("/test/project/project.kicad_pro") - + # Should only have the project file assert result["project"] == "/test/project/project.kicad_pro" assert len(result) == 1 @@ -135,9 +134,9 @@ class TestGetProjectFiles: "myproject.net", # no separator "myprojectdata.zip" # no separator, should use extension ] - + result = get_project_files("/test/project/myproject.kicad_pro") - + # Check different parsing results assert "bom" in result assert "positions" in result @@ -152,14 +151,14 @@ class TestGetProjectFiles: pcb_path = os.path.join(temp_dir, "test.kicad_pcb") sch_path = os.path.join(temp_dir, "test.kicad_sch") bom_path = os.path.join(temp_dir, "test-bom.csv") - + # Create actual files for path in [project_path, pcb_path, sch_path, bom_path]: with open(path, 'w') as f: f.write("test content") - + result = get_project_files(project_path) - + # Should find all files assert result["project"] == project_path assert result["pcb"] == pcb_path @@ -174,10 +173,10 @@ class TestLoadProjectJson: """Test successful JSON loading.""" test_data = {"version": 1, "board": {"thickness": 1.6}} json_content = json.dumps(test_data) - + with patch('builtins.open', mock_open(read_data=json_content)): result = load_project_json("/test/project.kicad_pro") - + assert result == test_data assert result["version"] == 1 assert result["board"]["thickness"] == 1.6 @@ -186,30 +185,30 @@ class TestLoadProjectJson: """Test handling of missing file.""" with patch('builtins.open', side_effect=FileNotFoundError("File not found")): result = load_project_json("/nonexistent/project.kicad_pro") - + assert result is None def test_load_project_json_invalid_json(self): """Test handling of invalid JSON.""" invalid_json = '{"version": 1, "incomplete":' - + with patch('builtins.open', mock_open(read_data=invalid_json)): result = load_project_json("/test/project.kicad_pro") - + assert result is None def test_load_project_json_empty_file(self): """Test handling of empty file.""" with patch('builtins.open', mock_open(read_data="")): result = load_project_json("/test/project.kicad_pro") - + assert result is None def test_load_project_json_permission_error(self): """Test handling of permission errors.""" with patch('builtins.open', side_effect=PermissionError("Permission denied")): result = load_project_json("/test/project.kicad_pro") - + assert result is None def test_load_project_json_complex_data(self): @@ -233,10 +232,10 @@ class TestLoadProjectJson: } } json_content = json.dumps(complex_data) - + with patch('builtins.open', mock_open(read_data=json_content)): result = load_project_json("/test/project.kicad_pro") - + assert result == complex_data assert len(result["board"]["layers"]) == 2 assert len(result["nets"]) == 2 @@ -250,11 +249,11 @@ class TestLoadProjectJson: "author": "José María" # Accented characters } json_content = json.dumps(unicode_data, ensure_ascii=False) - + with patch('builtins.open', mock_open(read_data=json_content)) as mock_file: mock_file.return_value.__enter__.return_value.read.return_value = json_content result = load_project_json("/test/project.kicad_pro") - + assert result == unicode_data assert result["title"] == "测试项目" assert result["author"] == "José María" @@ -262,11 +261,11 @@ class TestLoadProjectJson: def test_load_project_json_real_file(self): """Test with real temporary file.""" test_data = {"version": 1, "test": True} - + with tempfile.NamedTemporaryFile(mode='w', suffix='.kicad_pro', delete=False) as temp_file: json.dump(test_data, temp_file) temp_file.flush() - + try: result = load_project_json(temp_file.name) assert result == test_data @@ -283,26 +282,26 @@ class TestIntegration: # Create project structure project_path = os.path.join(temp_dir, "integration_test.kicad_pro") pcb_path = os.path.join(temp_dir, "integration_test.kicad_pcb") - + # Create project JSON file project_data = { "version": 1, "board": {"thickness": 1.6}, "nets": [] } - + with open(project_path, 'w') as f: json.dump(project_data, f) - + # Create PCB file with open(pcb_path, 'w') as f: f.write("PCB content") - + # Test file discovery files = get_project_files(project_path) assert files["project"] == project_path assert files["pcb"] == pcb_path - + # Test JSON loading json_data = load_project_json(project_path) assert json_data == project_data @@ -312,20 +311,20 @@ class TestIntegration: def test_project_name_integration(self, mock_get_name): """Test integration with get_project_name_from_path function.""" mock_get_name.return_value = "custom_name" - + with tempfile.TemporaryDirectory() as temp_dir: project_path = os.path.join(temp_dir, "actual_file.kicad_pro") custom_pcb = os.path.join(temp_dir, "custom_name.kicad_pcb") - + # Create files with custom naming with open(project_path, 'w') as f: f.write('{"version": 1}') with open(custom_pcb, 'w') as f: f.write("PCB content") - + files = get_project_files(project_path) - + # Should use the mocked project name mock_get_name.assert_called_once_with(project_path) assert files["project"] == project_path - assert files["pcb"] == custom_pcb \ No newline at end of file + assert files["pcb"] == custom_pcb diff --git a/tests/unit/utils/test_kicad_cli.py b/tests/unit/utils/test_kicad_cli.py index b309218..28a32e8 100644 --- a/tests/unit/utils/test_kicad_cli.py +++ b/tests/unit/utils/test_kicad_cli.py @@ -1,20 +1,20 @@ """ Tests for the kicad_mcp.utils.kicad_cli module. """ -import os import platform import subprocess -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch + import pytest from kicad_mcp.utils.kicad_cli import ( KiCadCLIError, KiCadCLIManager, - get_cli_manager, find_kicad_cli, + get_cli_manager, get_kicad_cli_path, + get_kicad_version, is_kicad_cli_available, - get_kicad_version ) @@ -25,7 +25,7 @@ class TestKiCadCLIError: """Test that KiCadCLIError can be created and raised.""" with pytest.raises(KiCadCLIError) as exc_info: raise KiCadCLIError("Test error message") - + assert str(exc_info.value) == "Test error message" @@ -39,7 +39,7 @@ class TestKiCadCLIManager: def test_init(self): """Test manager initialization.""" manager = KiCadCLIManager() - + assert manager._cached_cli_path is None assert manager._cache_validated is False assert manager._system == platform.system() @@ -50,9 +50,9 @@ class TestKiCadCLIManager: """Test successful CLI detection.""" mock_detect.return_value = "/usr/bin/kicad-cli" mock_validate.return_value = True - + result = self.manager.find_kicad_cli() - + assert result == "/usr/bin/kicad-cli" assert self.manager._cached_cli_path == "/usr/bin/kicad-cli" assert self.manager._cache_validated is True @@ -61,9 +61,9 @@ class TestKiCadCLIManager: def test_find_kicad_cli_not_found(self, mock_detect): """Test CLI detection failure.""" mock_detect.return_value = None - + result = self.manager.find_kicad_cli() - + assert result is None assert self.manager._cached_cli_path is None assert self.manager._cache_validated is False @@ -74,9 +74,9 @@ class TestKiCadCLIManager: """Test CLI detection with validation failure.""" mock_detect.return_value = "/usr/bin/kicad-cli" mock_validate.return_value = False - + result = self.manager.find_kicad_cli() - + assert result is None assert self.manager._cached_cli_path is None assert self.manager._cache_validated is False @@ -85,10 +85,10 @@ class TestKiCadCLIManager: """Test that cached CLI path is returned.""" self.manager._cached_cli_path = "/cached/path" self.manager._cache_validated = True - + with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect: result = self.manager.find_kicad_cli() - + assert result == "/cached/path" mock_detect.assert_not_called() @@ -96,15 +96,15 @@ class TestKiCadCLIManager: """Test force refresh ignores cache.""" self.manager._cached_cli_path = "/cached/path" self.manager._cache_validated = True - + with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect, \ patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path') as mock_validate: - + mock_detect.return_value = "/new/path" mock_validate.return_value = True - + result = self.manager.find_kicad_cli(force_refresh=True) - + assert result == "/new/path" mock_detect.assert_called_once() @@ -112,42 +112,42 @@ class TestKiCadCLIManager: def test_get_cli_path_success(self, mock_find): """Test successful CLI path retrieval.""" mock_find.return_value = "/usr/bin/kicad-cli" - + result = self.manager.get_cli_path() - + assert result == "/usr/bin/kicad-cli" @patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') def test_get_cli_path_not_required(self, mock_find): """Test CLI path retrieval when not required.""" mock_find.return_value = None - + result = self.manager.get_cli_path(required=False) - + assert result is None @patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') def test_get_cli_path_required_raises(self, mock_find): """Test that exception is raised when CLI required but not found.""" mock_find.return_value = None - + with pytest.raises(KiCadCLIError) as exc_info: self.manager.get_cli_path(required=True) - + assert "KiCad CLI not found" in str(exc_info.value) @patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') def test_is_available_true(self, mock_find): """Test is_available returns True when CLI found.""" mock_find.return_value = "/usr/bin/kicad-cli" - + assert self.manager.is_available() is True @patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') def test_is_available_false(self, mock_find): """Test is_available returns False when CLI not found.""" mock_find.return_value = None - + assert self.manager.is_available() is False @patch('kicad_mcp.utils.kicad_cli.subprocess.run') @@ -159,9 +159,9 @@ class TestKiCadCLIManager: mock_result.returncode = 0 mock_result.stdout = "KiCad 7.0.0\n" mock_run.return_value = mock_result - + version = self.manager.get_version() - + assert version == "KiCad 7.0.0" mock_run.assert_called_once() @@ -169,9 +169,9 @@ class TestKiCadCLIManager: def test_get_version_cli_not_found(self, mock_find): """Test version retrieval when CLI not found.""" mock_find.return_value = None - + version = self.manager.get_version() - + assert version is None @patch('kicad_mcp.utils.kicad_cli.subprocess.run') @@ -180,9 +180,9 @@ class TestKiCadCLIManager: """Test version retrieval with subprocess error.""" mock_find.return_value = "/usr/bin/kicad-cli" mock_run.side_effect = subprocess.SubprocessError("Test error") - + version = self.manager.get_version() - + assert version is None @patch('kicad_mcp.utils.kicad_cli.os.environ.get') @@ -193,9 +193,9 @@ class TestKiCadCLIManager: mock_env_get.return_value = "/custom/kicad-cli" mock_isfile.return_value = True mock_access.return_value = True - + result = self.manager._detect_cli_path() - + assert result == "/custom/kicad-cli" @patch('kicad_mcp.utils.kicad_cli.os.environ.get') @@ -204,9 +204,9 @@ class TestKiCadCLIManager: """Test CLI detection from system PATH.""" mock_env_get.return_value = None mock_which.return_value = "/usr/bin/kicad-cli" - + result = self.manager._detect_cli_path() - + assert result == "/usr/bin/kicad-cli" @patch('kicad_mcp.utils.kicad_cli.os.environ.get') @@ -219,9 +219,9 @@ class TestKiCadCLIManager: mock_which.return_value = None mock_isfile.side_effect = lambda x: x == "/usr/local/bin/kicad-cli" mock_access.return_value = True - + result = self.manager._detect_cli_path() - + assert result == "/usr/local/bin/kicad-cli" def test_get_cli_executable_name_windows(self): @@ -243,7 +243,7 @@ class TestKiCadCLIManager: with patch('platform.system', return_value='Darwin'): manager = KiCadCLIManager() paths = manager._get_common_installation_paths() - + assert "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli" in paths assert "/opt/homebrew/bin/kicad-cli" in paths @@ -252,7 +252,7 @@ class TestKiCadCLIManager: with patch('platform.system', return_value='Windows'): manager = KiCadCLIManager() paths = manager._get_common_installation_paths() - + assert r"C:\Program Files\KiCad\bin\kicad-cli.exe" in paths assert r"C:\Program Files (x86)\KiCad\bin\kicad-cli.exe" in paths @@ -261,7 +261,7 @@ class TestKiCadCLIManager: with patch('platform.system', return_value='Linux'): manager = KiCadCLIManager() paths = manager._get_common_installation_paths() - + assert "/usr/bin/kicad-cli" in paths assert "/snap/kicad/current/usr/bin/kicad-cli" in paths @@ -271,9 +271,9 @@ class TestKiCadCLIManager: mock_result = Mock() mock_result.returncode = 0 mock_run.return_value = mock_result - + result = self.manager._validate_cli_path("/usr/bin/kicad-cli") - + assert result is True @patch('kicad_mcp.utils.kicad_cli.subprocess.run') @@ -282,18 +282,18 @@ class TestKiCadCLIManager: mock_result = Mock() mock_result.returncode = 1 mock_run.return_value = mock_result - + result = self.manager._validate_cli_path("/usr/bin/kicad-cli") - + assert result is False @patch('kicad_mcp.utils.kicad_cli.subprocess.run') def test_validate_cli_path_exception(self, mock_run): """Test CLI validation with exception.""" mock_run.side_effect = subprocess.SubprocessError("Test error") - + result = self.manager._validate_cli_path("/usr/bin/kicad-cli") - + assert result is False @@ -309,7 +309,7 @@ class TestGlobalFunctions: """Test that get_cli_manager returns singleton instance.""" manager1 = get_cli_manager() manager2 = get_cli_manager() - + assert manager1 is manager2 assert isinstance(manager1, KiCadCLIManager) @@ -319,9 +319,9 @@ class TestGlobalFunctions: mock_manager = Mock() mock_manager.find_kicad_cli.return_value = "/usr/bin/kicad-cli" mock_get_manager.return_value = mock_manager - + result = find_kicad_cli(force_refresh=True) - + assert result == "/usr/bin/kicad-cli" mock_manager.find_kicad_cli.assert_called_once_with(True) @@ -331,9 +331,9 @@ class TestGlobalFunctions: mock_manager = Mock() mock_manager.get_cli_path.return_value = "/usr/bin/kicad-cli" mock_get_manager.return_value = mock_manager - + result = get_kicad_cli_path(required=False) - + assert result == "/usr/bin/kicad-cli" mock_manager.get_cli_path.assert_called_once_with(False) @@ -343,9 +343,9 @@ class TestGlobalFunctions: mock_manager = Mock() mock_manager.is_available.return_value = True mock_get_manager.return_value = mock_manager - + result = is_kicad_cli_available() - + assert result is True mock_manager.is_available.assert_called_once() @@ -355,9 +355,9 @@ class TestGlobalFunctions: mock_manager = Mock() mock_manager.get_version.return_value = "KiCad 7.0.0" mock_get_manager.return_value = mock_manager - + result = get_kicad_version() - + assert result == "KiCad 7.0.0" mock_manager.get_version.assert_called_once() @@ -368,29 +368,29 @@ class TestIntegration: def test_manager_lifecycle(self): """Test complete manager lifecycle.""" manager = KiCadCLIManager() - + # Initial state assert manager._cached_cli_path is None assert not manager._cache_validated - + # Simulate finding CLI with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect, \ patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path') as mock_validate: - + mock_detect.return_value = "/test/kicad-cli" mock_validate.return_value = True - + # First call should detect and cache path1 = manager.find_kicad_cli() assert path1 == "/test/kicad-cli" assert manager._cached_cli_path == "/test/kicad-cli" assert manager._cache_validated - + # Second call should use cache path2 = manager.find_kicad_cli() assert path2 == "/test/kicad-cli" assert mock_detect.call_count == 1 # Should only be called once - + # Force refresh should re-detect mock_detect.return_value = "/new/path" path3 = manager.find_kicad_cli(force_refresh=True) @@ -400,14 +400,14 @@ class TestIntegration: def test_error_propagation(self): """Test that errors are properly propagated.""" manager = KiCadCLIManager() - + with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') as mock_find: mock_find.return_value = None - + # Should not raise when required=False result = manager.get_cli_path(required=False) assert result is None - + # Should raise when required=True with pytest.raises(KiCadCLIError): - manager.get_cli_path(required=True) \ No newline at end of file + manager.get_cli_path(required=True)