chore: normalize all tracked files to LF line endings

Mechanical application of the `.gitattributes` rules from the prior commit.
All 50 files differ only in line endings — verified by
`git diff --cached --ignore-all-space` being empty.

Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML,
templates, and shell scripts. After: every text file is LF (except the
Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes).

This eliminates the noisy-diff failure mode seen in PR #102, where a
small logic change produced a 918-line diff due to whole-file CRLF→LF
conversion.
This commit is contained in:
Eugene Mikhantyev
2026-04-18 15:23:00 +01:00
parent 2c35ff40a7
commit bfc25639c2
50 changed files with 18167 additions and 18167 deletions

View File

@@ -1 +1 @@
"""Utility modules for KiCAD MCP Server"""
"""Utility modules for KiCAD MCP Server"""

View File

@@ -1,349 +1,349 @@
"""
KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import ctypes
import logging
import os
import platform
import subprocess
import time
from ctypes import wintypes
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class KiCADProcessManager:
"""Manages KiCAD process detection and launching"""
@staticmethod
def _windows_list_processes() -> List[dict]:
"""List running processes on Windows using Toolhelp API."""
processes: List[dict] = []
try:
TH32CS_SNAPPROCESS = 0x00000002
try:
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
except AttributeError:
ulong_ptr = (
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
)
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ProcessID", wintypes.DWORD),
("th32DefaultHeapID", ulong_ptr),
("th32ModuleID", wintypes.DWORD),
("cntThreads", wintypes.DWORD),
("th32ParentProcessID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG),
("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
]
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW
CloseHandle = ctypes.windll.kernel32.CloseHandle
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snapshot == wintypes.HANDLE(-1).value:
return processes
entry = PROCESSENTRY32W()
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
if Process32FirstW(snapshot, ctypes.byref(entry)):
while True:
processes.append(
{
"pid": str(entry.th32ProcessID),
"name": entry.szExeFile,
"command": entry.szExeFile,
}
)
if not Process32NextW(snapshot, ctypes.byref(entry)):
break
CloseHandle(snapshot)
except Exception as e:
logger.error(f"Error listing Windows processes: {e}")
return processes
@staticmethod
def is_running() -> bool:
"""
Check if KiCAD is currently running
Returns:
True if KiCAD process found, False otherwise
"""
system = platform.system()
try:
if system == "Linux":
# Check for actual pcbnew/kicad binaries (not python scripts)
# Use exact process name matching to avoid matching our own kicad_interface.py
result = subprocess.run(
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
)
if result.returncode == 0:
return True
# Also check with -f for full path matching, but exclude our script
result = subprocess.run(
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
except:
pass
return False
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
elif system == "Windows":
processes = KiCADProcessManager._windows_list_processes()
for proc in processes:
name = (proc.get("name") or "").lower()
if name in ("pcbnew.exe", "kicad.exe"):
return True
return False
else:
logger.warning(f"Process detection not implemented for {system}")
return False
except Exception as e:
logger.error(f"Error checking if KiCAD is running: {e}")
return False
@staticmethod
def get_executable_path() -> Optional[Path]:
"""
Get path to KiCAD executable
Returns:
Path to pcbnew/kicad executable, or None if not found
"""
system = platform.system()
# Try to find executable in PATH first
for cmd in ["pcbnew", "kicad"]:
result = subprocess.run(
["which", cmd] if system != "Windows" else ["where", cmd],
capture_output=True,
text=True,
encoding="mbcs" if system == "Windows" else None,
errors="ignore" if system == "Windows" else None,
timeout=5 if system == "Windows" else None,
)
if result.returncode == 0:
exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {exe_path}")
return Path(exe_path)
# Platform-specific default paths
if system == "Linux":
candidates = [
Path("/usr/bin/pcbnew"),
Path("/usr/local/bin/pcbnew"),
Path("/usr/bin/kicad"),
]
elif system == "Darwin": # macOS
candidates = [
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
]
elif system == "Windows":
candidates = [
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
]
else:
candidates = []
for path in candidates:
if path.exists():
logger.info(f"Found KiCAD executable: {path}")
return path
logger.warning("Could not find KiCAD executable")
return None
@staticmethod
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
"""
Launch KiCAD PCB Editor
Args:
project_path: Optional path to .kicad_pcb file to open
wait_for_start: Wait for process to start before returning
Returns:
True if launch successful, False otherwise
"""
try:
# Check if already running
if KiCADProcessManager.is_running():
logger.info("KiCAD is already running")
return True
# Find executable
exe_path = KiCADProcessManager.get_executable_path()
if not exe_path:
logger.error("Cannot launch KiCAD: executable not found")
return False
# Build command
cmd = [str(exe_path)]
if project_path:
cmd.append(str(project_path))
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
# Launch process in background
system = platform.system()
if system == "Windows":
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Wait for process to start
if wait_for_start:
logger.info("Waiting for KiCAD to start...")
for i in range(10): # Wait up to 5 seconds
time.sleep(0.5)
if KiCADProcessManager.is_running():
logger.info("✓ KiCAD started successfully")
return True
logger.warning("KiCAD process not detected after launch")
# Return True anyway, it might be starting
return True
return True
except Exception as e:
logger.error(f"Error launching KiCAD: {e}")
return False
@staticmethod
def get_process_info() -> List[dict]:
"""
Get information about running KiCAD processes
Returns:
List of process info dicts with pid, name, and command
"""
system = platform.system()
processes = []
try:
if system in ["Linux", "Darwin"]:
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
for line in result.stdout.split("\n"):
# Only match actual KiCAD binaries, not our MCP server processes
if (
("pcbnew" in line.lower() or "kicad" in line.lower())
and "kicad_interface.py" not in line
and "grep" not in line
):
# More specific check: must have /pcbnew or /kicad in the path
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
parts = line.split()
if len(parts) >= 11:
processes.append(
{
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:]),
}
)
elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes():
name = (proc.get("name") or "").lower()
if "pcbnew" in name or "kicad" in name:
processes.append(proc)
except Exception as e:
logger.error(f"Error getting process info: {e}")
return processes
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
"""
Check if KiCAD is running and optionally launch it
Args:
project_path: Optional path to .kicad_pcb file to open
auto_launch: If True, launch KiCAD if not running
Returns:
Dict with status information
"""
manager = KiCADProcessManager()
is_running = manager.is_running()
if is_running:
processes = manager.get_process_info()
return {
"running": True,
"launched": False,
"processes": processes,
"message": "KiCAD is already running",
}
if not auto_launch:
return {
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
logger.info("KiCAD not detected, attempting to launch...")
success = manager.launch(project_path)
return {
"running": success,
"launched": success,
"processes": manager.get_process_info() if success else [],
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
"project": str(project_path) if project_path else None,
}
"""
KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import ctypes
import logging
import os
import platform
import subprocess
import time
from ctypes import wintypes
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class KiCADProcessManager:
"""Manages KiCAD process detection and launching"""
@staticmethod
def _windows_list_processes() -> List[dict]:
"""List running processes on Windows using Toolhelp API."""
processes: List[dict] = []
try:
TH32CS_SNAPPROCESS = 0x00000002
try:
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
except AttributeError:
ulong_ptr = (
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
)
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ProcessID", wintypes.DWORD),
("th32DefaultHeapID", ulong_ptr),
("th32ModuleID", wintypes.DWORD),
("cntThreads", wintypes.DWORD),
("th32ParentProcessID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG),
("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
]
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW
CloseHandle = ctypes.windll.kernel32.CloseHandle
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snapshot == wintypes.HANDLE(-1).value:
return processes
entry = PROCESSENTRY32W()
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
if Process32FirstW(snapshot, ctypes.byref(entry)):
while True:
processes.append(
{
"pid": str(entry.th32ProcessID),
"name": entry.szExeFile,
"command": entry.szExeFile,
}
)
if not Process32NextW(snapshot, ctypes.byref(entry)):
break
CloseHandle(snapshot)
except Exception as e:
logger.error(f"Error listing Windows processes: {e}")
return processes
@staticmethod
def is_running() -> bool:
"""
Check if KiCAD is currently running
Returns:
True if KiCAD process found, False otherwise
"""
system = platform.system()
try:
if system == "Linux":
# Check for actual pcbnew/kicad binaries (not python scripts)
# Use exact process name matching to avoid matching our own kicad_interface.py
result = subprocess.run(
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
)
if result.returncode == 0:
return True
# Also check with -f for full path matching, but exclude our script
result = subprocess.run(
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
except:
pass
return False
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
elif system == "Windows":
processes = KiCADProcessManager._windows_list_processes()
for proc in processes:
name = (proc.get("name") or "").lower()
if name in ("pcbnew.exe", "kicad.exe"):
return True
return False
else:
logger.warning(f"Process detection not implemented for {system}")
return False
except Exception as e:
logger.error(f"Error checking if KiCAD is running: {e}")
return False
@staticmethod
def get_executable_path() -> Optional[Path]:
"""
Get path to KiCAD executable
Returns:
Path to pcbnew/kicad executable, or None if not found
"""
system = platform.system()
# Try to find executable in PATH first
for cmd in ["pcbnew", "kicad"]:
result = subprocess.run(
["which", cmd] if system != "Windows" else ["where", cmd],
capture_output=True,
text=True,
encoding="mbcs" if system == "Windows" else None,
errors="ignore" if system == "Windows" else None,
timeout=5 if system == "Windows" else None,
)
if result.returncode == 0:
exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {exe_path}")
return Path(exe_path)
# Platform-specific default paths
if system == "Linux":
candidates = [
Path("/usr/bin/pcbnew"),
Path("/usr/local/bin/pcbnew"),
Path("/usr/bin/kicad"),
]
elif system == "Darwin": # macOS
candidates = [
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
]
elif system == "Windows":
candidates = [
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
]
else:
candidates = []
for path in candidates:
if path.exists():
logger.info(f"Found KiCAD executable: {path}")
return path
logger.warning("Could not find KiCAD executable")
return None
@staticmethod
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
"""
Launch KiCAD PCB Editor
Args:
project_path: Optional path to .kicad_pcb file to open
wait_for_start: Wait for process to start before returning
Returns:
True if launch successful, False otherwise
"""
try:
# Check if already running
if KiCADProcessManager.is_running():
logger.info("KiCAD is already running")
return True
# Find executable
exe_path = KiCADProcessManager.get_executable_path()
if not exe_path:
logger.error("Cannot launch KiCAD: executable not found")
return False
# Build command
cmd = [str(exe_path)]
if project_path:
cmd.append(str(project_path))
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
# Launch process in background
system = platform.system()
if system == "Windows":
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Wait for process to start
if wait_for_start:
logger.info("Waiting for KiCAD to start...")
for i in range(10): # Wait up to 5 seconds
time.sleep(0.5)
if KiCADProcessManager.is_running():
logger.info("✓ KiCAD started successfully")
return True
logger.warning("KiCAD process not detected after launch")
# Return True anyway, it might be starting
return True
return True
except Exception as e:
logger.error(f"Error launching KiCAD: {e}")
return False
@staticmethod
def get_process_info() -> List[dict]:
"""
Get information about running KiCAD processes
Returns:
List of process info dicts with pid, name, and command
"""
system = platform.system()
processes = []
try:
if system in ["Linux", "Darwin"]:
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
for line in result.stdout.split("\n"):
# Only match actual KiCAD binaries, not our MCP server processes
if (
("pcbnew" in line.lower() or "kicad" in line.lower())
and "kicad_interface.py" not in line
and "grep" not in line
):
# More specific check: must have /pcbnew or /kicad in the path
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
parts = line.split()
if len(parts) >= 11:
processes.append(
{
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:]),
}
)
elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes():
name = (proc.get("name") or "").lower()
if "pcbnew" in name or "kicad" in name:
processes.append(proc)
except Exception as e:
logger.error(f"Error getting process info: {e}")
return processes
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
"""
Check if KiCAD is running and optionally launch it
Args:
project_path: Optional path to .kicad_pcb file to open
auto_launch: If True, launch KiCAD if not running
Returns:
Dict with status information
"""
manager = KiCADProcessManager()
is_running = manager.is_running()
if is_running:
processes = manager.get_process_info()
return {
"running": True,
"launched": False,
"processes": processes,
"message": "KiCAD is already running",
}
if not auto_launch:
return {
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
logger.info("KiCAD not detected, attempting to launch...")
success = manager.launch(project_path)
return {
"running": success,
"launched": success,
"processes": manager.get_process_info() if success else [],
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
"project": str(project_path) if project_path else None,
}

View File

@@ -1,325 +1,325 @@
"""
Platform detection and path utilities for cross-platform compatibility
This module provides helpers for detecting the current platform and
getting appropriate paths for KiCAD, configuration, logs, etc.
"""
import logging
import os
import platform
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class PlatformHelper:
"""Platform detection and path resolution utilities"""
@staticmethod
def is_windows() -> bool:
"""Check if running on Windows"""
return platform.system() == "Windows"
@staticmethod
def is_linux() -> bool:
"""Check if running on Linux"""
return platform.system() == "Linux"
@staticmethod
def is_macos() -> bool:
"""Check if running on macOS"""
return platform.system() == "Darwin"
@staticmethod
def get_platform_name() -> str:
"""Get human-readable platform name"""
system = platform.system()
if system == "Darwin":
return "macOS"
return system
@staticmethod
def get_kicad_python_paths() -> List[Path]:
"""
Get potential KiCAD Python dist-packages paths for current platform
Returns:
List of potential paths to check (in priority order)
"""
paths = []
if PlatformHelper.is_windows():
# Windows: Check Program Files
program_files = [
Path("C:/Program Files/KiCad"),
Path("C:/Program Files (x86)/KiCad"),
]
for pf in program_files:
# Check multiple KiCAD versions
for version in ["9.0", "9.1", "10.0", "8.0"]:
path = pf / version / "lib" / "python3" / "dist-packages"
if path.exists():
paths.append(path)
elif PlatformHelper.is_linux():
# Linux: Check common installation paths
candidates = [
Path("/usr/lib/kicad/lib/python3/dist-packages"),
Path("/usr/share/kicad/scripting/plugins"),
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
]
# Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
candidates.extend(
[
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
]
)
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
# This is where pcbnew.py typically lives on modern systems
candidates.extend(
[
Path(f"/usr/lib/python3/dist-packages"),
Path(f"/usr/lib/python{py_version}/dist-packages"),
Path(f"/usr/local/lib/python3/dist-packages"),
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
]
)
paths = [p for p in candidates if p.exists()]
elif PlatformHelper.is_macos():
# macOS: Check multiple KiCAD application bundle locations
kicad_app_paths = [
Path("/Applications/KiCad/KiCad.app"),
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
]
# Check Python framework paths in each KiCAD installation
for kicad_app in kicad_app_paths:
if kicad_app.exists():
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
path = (
kicad_app
/ "Contents"
/ "Frameworks"
/ "Python.framework"
/ "Versions"
/ version
/ "lib"
/ f"python{version}"
/ "site-packages"
)
if path.exists():
paths.append(path)
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
homebrew_paths = [
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
Path("/opt/homebrew/lib/python3.11/site-packages"),
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"),
]
for hp in homebrew_paths:
pcbnew_path = hp / "pcbnew.py"
if pcbnew_path.exists():
paths.append(hp)
if not paths:
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
else:
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
return paths
@staticmethod
def get_kicad_python_path() -> Optional[Path]:
"""
Get the first valid KiCAD Python path
Returns:
Path to KiCAD Python dist-packages, or None if not found
"""
paths = PlatformHelper.get_kicad_python_paths()
return paths[0] if paths else None
@staticmethod
def get_kicad_library_search_paths() -> List[str]:
"""
Get platform-appropriate KiCAD symbol library search paths
Returns:
List of glob patterns for finding .kicad_sym files
"""
patterns = []
if PlatformHelper.is_windows():
patterns = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
]
elif PlatformHelper.is_linux():
patterns = [
"/usr/share/kicad/symbols/*.kicad_sym",
"/usr/local/share/kicad/symbols/*.kicad_sym",
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
]
elif PlatformHelper.is_macos():
patterns = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
str(
Path.home()
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
),
]
# Add user library paths for all platforms
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
return patterns
@staticmethod
def get_config_dir() -> Path:
r"""
Get appropriate configuration directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
- macOS: ~/Library/Application Support/kicad-mcp
Returns:
Path to configuration directory
"""
if PlatformHelper.is_windows():
return Path.home() / ".kicad-mcp"
elif PlatformHelper.is_linux():
# Use XDG Base Directory specification
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
xdg_config_path = Path(xdg_config).expanduser()
if xdg_config_path.is_absolute():
return xdg_config_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
return Path.home() / ".config" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
else:
# Fallback for unknown platforms
return Path.home() / ".kicad-mcp"
@staticmethod
def get_log_dir() -> Path:
"""
Get appropriate log directory for current platform
Returns:
Path to log directory
"""
config_dir = PlatformHelper.get_config_dir()
return config_dir / "logs"
@staticmethod
def get_cache_dir() -> Path:
r"""
Get appropriate cache directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp\cache
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
- macOS: ~/Library/Caches/kicad-mcp
Returns:
Path to cache directory
"""
if PlatformHelper.is_windows():
return PlatformHelper.get_config_dir() / "cache"
elif PlatformHelper.is_linux():
xdg_cache = os.environ.get("XDG_CACHE_HOME")
if xdg_cache:
xdg_cache_path = Path(xdg_cache).expanduser()
if xdg_cache_path.is_absolute():
return xdg_cache_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
return Path.home() / ".cache" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Caches" / "kicad-mcp"
else:
return PlatformHelper.get_config_dir() / "cache"
@staticmethod
def ensure_directories() -> None:
"""Create all necessary directories if they don't exist"""
dirs_to_create = [
PlatformHelper.get_config_dir(),
PlatformHelper.get_log_dir(),
PlatformHelper.get_cache_dir(),
]
for directory in dirs_to_create:
directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
@staticmethod
def get_python_executable() -> Path:
"""Get path to current Python executable"""
return Path(sys.executable)
@staticmethod
def add_kicad_to_python_path() -> bool:
"""
Add KiCAD Python paths to sys.path
Returns:
True if at least one path was added, False otherwise
"""
paths_added = False
for path in PlatformHelper.get_kicad_python_paths():
if str(path) not in sys.path:
sys.path.insert(0, str(path))
logger.info(f"Added to Python path: {path}")
paths_added = True
return paths_added
# Convenience function for quick platform detection
def detect_platform() -> dict:
"""
Detect platform and return useful information
Returns:
Dictionary with platform information
"""
return {
"system": platform.system(),
"platform": PlatformHelper.get_platform_name(),
"is_windows": PlatformHelper.is_windows(),
"is_linux": PlatformHelper.is_linux(),
"is_macos": PlatformHelper.is_macos(),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"python_executable": str(PlatformHelper.get_python_executable()),
"config_dir": str(PlatformHelper.get_config_dir()),
"log_dir": str(PlatformHelper.get_log_dir()),
"cache_dir": str(PlatformHelper.get_cache_dir()),
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
}
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
print(json.dumps(info, indent=2))
"""
Platform detection and path utilities for cross-platform compatibility
This module provides helpers for detecting the current platform and
getting appropriate paths for KiCAD, configuration, logs, etc.
"""
import logging
import os
import platform
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class PlatformHelper:
"""Platform detection and path resolution utilities"""
@staticmethod
def is_windows() -> bool:
"""Check if running on Windows"""
return platform.system() == "Windows"
@staticmethod
def is_linux() -> bool:
"""Check if running on Linux"""
return platform.system() == "Linux"
@staticmethod
def is_macos() -> bool:
"""Check if running on macOS"""
return platform.system() == "Darwin"
@staticmethod
def get_platform_name() -> str:
"""Get human-readable platform name"""
system = platform.system()
if system == "Darwin":
return "macOS"
return system
@staticmethod
def get_kicad_python_paths() -> List[Path]:
"""
Get potential KiCAD Python dist-packages paths for current platform
Returns:
List of potential paths to check (in priority order)
"""
paths = []
if PlatformHelper.is_windows():
# Windows: Check Program Files
program_files = [
Path("C:/Program Files/KiCad"),
Path("C:/Program Files (x86)/KiCad"),
]
for pf in program_files:
# Check multiple KiCAD versions
for version in ["9.0", "9.1", "10.0", "8.0"]:
path = pf / version / "lib" / "python3" / "dist-packages"
if path.exists():
paths.append(path)
elif PlatformHelper.is_linux():
# Linux: Check common installation paths
candidates = [
Path("/usr/lib/kicad/lib/python3/dist-packages"),
Path("/usr/share/kicad/scripting/plugins"),
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
]
# Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
candidates.extend(
[
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
]
)
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
# This is where pcbnew.py typically lives on modern systems
candidates.extend(
[
Path(f"/usr/lib/python3/dist-packages"),
Path(f"/usr/lib/python{py_version}/dist-packages"),
Path(f"/usr/local/lib/python3/dist-packages"),
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
]
)
paths = [p for p in candidates if p.exists()]
elif PlatformHelper.is_macos():
# macOS: Check multiple KiCAD application bundle locations
kicad_app_paths = [
Path("/Applications/KiCad/KiCad.app"),
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
]
# Check Python framework paths in each KiCAD installation
for kicad_app in kicad_app_paths:
if kicad_app.exists():
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
path = (
kicad_app
/ "Contents"
/ "Frameworks"
/ "Python.framework"
/ "Versions"
/ version
/ "lib"
/ f"python{version}"
/ "site-packages"
)
if path.exists():
paths.append(path)
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
homebrew_paths = [
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
Path("/opt/homebrew/lib/python3.11/site-packages"),
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"),
]
for hp in homebrew_paths:
pcbnew_path = hp / "pcbnew.py"
if pcbnew_path.exists():
paths.append(hp)
if not paths:
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
else:
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
return paths
@staticmethod
def get_kicad_python_path() -> Optional[Path]:
"""
Get the first valid KiCAD Python path
Returns:
Path to KiCAD Python dist-packages, or None if not found
"""
paths = PlatformHelper.get_kicad_python_paths()
return paths[0] if paths else None
@staticmethod
def get_kicad_library_search_paths() -> List[str]:
"""
Get platform-appropriate KiCAD symbol library search paths
Returns:
List of glob patterns for finding .kicad_sym files
"""
patterns = []
if PlatformHelper.is_windows():
patterns = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
]
elif PlatformHelper.is_linux():
patterns = [
"/usr/share/kicad/symbols/*.kicad_sym",
"/usr/local/share/kicad/symbols/*.kicad_sym",
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
]
elif PlatformHelper.is_macos():
patterns = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
str(
Path.home()
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
),
]
# Add user library paths for all platforms
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
return patterns
@staticmethod
def get_config_dir() -> Path:
r"""
Get appropriate configuration directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
- macOS: ~/Library/Application Support/kicad-mcp
Returns:
Path to configuration directory
"""
if PlatformHelper.is_windows():
return Path.home() / ".kicad-mcp"
elif PlatformHelper.is_linux():
# Use XDG Base Directory specification
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
xdg_config_path = Path(xdg_config).expanduser()
if xdg_config_path.is_absolute():
return xdg_config_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
return Path.home() / ".config" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
else:
# Fallback for unknown platforms
return Path.home() / ".kicad-mcp"
@staticmethod
def get_log_dir() -> Path:
"""
Get appropriate log directory for current platform
Returns:
Path to log directory
"""
config_dir = PlatformHelper.get_config_dir()
return config_dir / "logs"
@staticmethod
def get_cache_dir() -> Path:
r"""
Get appropriate cache directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp\cache
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
- macOS: ~/Library/Caches/kicad-mcp
Returns:
Path to cache directory
"""
if PlatformHelper.is_windows():
return PlatformHelper.get_config_dir() / "cache"
elif PlatformHelper.is_linux():
xdg_cache = os.environ.get("XDG_CACHE_HOME")
if xdg_cache:
xdg_cache_path = Path(xdg_cache).expanduser()
if xdg_cache_path.is_absolute():
return xdg_cache_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
return Path.home() / ".cache" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Caches" / "kicad-mcp"
else:
return PlatformHelper.get_config_dir() / "cache"
@staticmethod
def ensure_directories() -> None:
"""Create all necessary directories if they don't exist"""
dirs_to_create = [
PlatformHelper.get_config_dir(),
PlatformHelper.get_log_dir(),
PlatformHelper.get_cache_dir(),
]
for directory in dirs_to_create:
directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
@staticmethod
def get_python_executable() -> Path:
"""Get path to current Python executable"""
return Path(sys.executable)
@staticmethod
def add_kicad_to_python_path() -> bool:
"""
Add KiCAD Python paths to sys.path
Returns:
True if at least one path was added, False otherwise
"""
paths_added = False
for path in PlatformHelper.get_kicad_python_paths():
if str(path) not in sys.path:
sys.path.insert(0, str(path))
logger.info(f"Added to Python path: {path}")
paths_added = True
return paths_added
# Convenience function for quick platform detection
def detect_platform() -> dict:
"""
Detect platform and return useful information
Returns:
Dictionary with platform information
"""
return {
"system": platform.system(),
"platform": PlatformHelper.get_platform_name(),
"is_windows": PlatformHelper.is_windows(),
"is_linux": PlatformHelper.is_linux(),
"is_macos": PlatformHelper.is_macos(),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"python_executable": str(PlatformHelper.get_python_executable()),
"config_dir": str(PlatformHelper.get_config_dir()),
"log_dir": str(PlatformHelper.get_log_dir()),
"cache_dir": str(PlatformHelper.get_cache_dir()),
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
}
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
print(json.dumps(info, indent=2))