"""
KiCad CLI integration module for the PCB API.
This module provides a generic interface to run any kicad-cli command with:
- Automatic detection of kicad-cli path on different platforms
- Both low-level (run any command) and high-level methods (specific commands)
- JSON output parsing when available
- Proper error handling with custom exceptions
"""
import json
import logging
import os
import platform
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
logger = logging.getLogger(__name__)
[docs]
class KiCadCLIError(Exception):
"""Base exception for KiCad CLI errors."""
pass
class KiCadCLINotFoundError(KiCadCLIError):
"""Raised when kicad-cli executable cannot be found."""
pass
class KiCadCLICommandError(KiCadCLIError):
"""Raised when a kicad-cli command fails."""
def __init__(self, message: str, return_code: int, stdout: str, stderr: str):
super().__init__(message)
self.return_code = return_code
self.stdout = stdout
self.stderr = stderr
[docs]
@dataclass
class DRCResult:
"""Result of a DRC (Design Rule Check) operation."""
success: bool
violations: List[Dict[str, Any]]
warnings: List[Dict[str, Any]]
unconnected_items: List[Dict[str, Any]]
output_file: Optional[Path] = None
@property
def total_issues(self) -> int:
"""Total number of issues found."""
return len(self.violations) + len(self.warnings) + len(self.unconnected_items)
[docs]
class KiCadCLI:
"""
Generic interface to run KiCad CLI commands.
Provides both low-level command execution and high-level convenience methods
for common operations like DRC, export, etc.
"""
[docs]
def __init__(self, kicad_cli_path: Optional[str] = None):
"""
Initialize KiCad CLI interface.
Args:
kicad_cli_path: Optional explicit path to kicad-cli executable.
If not provided, will attempt auto-detection.
"""
self.kicad_cli_path = kicad_cli_path or self._find_kicad_cli()
if not self.kicad_cli_path:
raise KiCadCLINotFoundError(
"Could not find kicad-cli executable. Please install KiCad or provide explicit path."
)
logger.info(f"Using kicad-cli at: {self.kicad_cli_path}")
def _find_kicad_cli(self) -> Optional[str]:
"""
Automatically detect kicad-cli path on different platforms.
Returns:
Path to kicad-cli executable or None if not found.
"""
# First check if it's in PATH
cli_path = shutil.which("kicad-cli")
if cli_path:
return cli_path
# Platform-specific search paths
system = platform.system()
search_paths = []
if system == "Darwin": # macOS
search_paths = [
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
"/Applications/KiCad.app/Contents/MacOS/kicad-cli",
os.path.expanduser(
"~/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"
),
os.path.expanduser("~/Applications/KiCad.app/Contents/MacOS/kicad-cli"),
]
elif system == "Windows":
# Common Windows installation paths
program_files = [
os.environ.get("PROGRAMFILES", "C:\\Program Files"),
os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)"),
]
for pf in program_files:
search_paths.extend(
[
os.path.join(pf, "KiCad", "9.0", "bin", "kicad-cli.exe"),
os.path.join(pf, "KiCad", "8.0", "bin", "kicad-cli.exe"),
os.path.join(pf, "KiCad", "7.0", "bin", "kicad-cli.exe"),
os.path.join(pf, "KiCad", "bin", "kicad-cli.exe"),
]
)
elif system == "Linux":
search_paths = [
"/usr/bin/kicad-cli",
"/usr/local/bin/kicad-cli",
"/opt/kicad/bin/kicad-cli",
os.path.expanduser("~/.local/bin/kicad-cli"),
]
# Search for the executable
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
[docs]
def run_command(
self,
args: List[str],
cwd: Optional[Union[str, Path]] = None,
capture_output: bool = True,
check: bool = True,
) -> subprocess.CompletedProcess:
"""
Run a kicad-cli command with the given arguments.
This is the low-level interface that all high-level methods use.
Args:
args: Command arguments (without 'kicad-cli' prefix)
cwd: Working directory for the command
capture_output: Whether to capture stdout/stderr
check: Whether to raise exception on non-zero return code
Returns:
CompletedProcess instance with command results
Raises:
KiCadCLICommandError: If command fails and check=True
"""
cmd = [self.kicad_cli_path] + args
logger.debug(f"Running command: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=capture_output,
text=True,
check=False, # We'll handle errors ourselves
)
if check and result.returncode != 0:
raise KiCadCLICommandError(
f"Command failed with return code {result.returncode}",
return_code=result.returncode,
stdout=result.stdout if capture_output else "",
stderr=result.stderr if capture_output else "",
)
return result
except FileNotFoundError:
raise KiCadCLINotFoundError(
f"kicad-cli not found at: {self.kicad_cli_path}"
)
[docs]
def get_version(self) -> str:
"""
Get KiCad CLI version information.
Returns:
Version string
"""
result = self.run_command(["version"])
return result.stdout.strip()
[docs]
def run_drc(
self,
pcb_file: Union[str, Path],
output_file: Optional[Union[str, Path]] = None,
units: str = "mm",
severity: str = "error",
format: str = "json",
custom_rules_file: Optional[Union[str, Path]] = None,
) -> DRCResult:
"""
Run Design Rule Check on a PCB file.
Args:
pcb_file: Path to the PCB file
output_file: Optional output file for the report. If not provided,
will use pcb_file with .drc extension
units: Units for the report (mm, in, mils)
severity: Minimum severity to report (error, warning, info)
format: Output format (json, report)
custom_rules_file: Optional path to custom DRC rules file
Returns:
DRCResult object with violations, warnings, and unconnected items
Note:
Custom DRC rules via command line are not directly supported in current
KiCad versions. Rules are typically embedded in the PCB file or project.
The custom_rules_file parameter is included for future compatibility.
"""
pcb_path = Path(pcb_file)
if not pcb_path.exists():
raise FileNotFoundError(f"PCB file not found: {pcb_path}")
# Determine output file
if output_file is None:
output_file = pcb_path.with_suffix(".drc")
else:
output_file = Path(output_file)
# Build command arguments
args = [
"pcb",
"drc",
"--output",
str(output_file),
"--units",
units,
"--severity",
severity,
"--format",
format,
]
# Note: Current KiCad CLI doesn't support custom rules file parameter
# Rules must be embedded in the PCB file or project settings
if custom_rules_file:
logger.warning(
"Custom DRC rules file specified, but KiCad CLI currently uses rules "
"embedded in the PCB file. The custom_rules_file parameter is ignored."
)
args.append(str(pcb_path))
# Run DRC
try:
result = self.run_command(args, cwd=pcb_path.parent)
# Parse results based on format
if format == "json" and output_file.exists():
with open(output_file, "r") as f:
drc_data = json.load(f)
return DRCResult(
success=len(drc_data.get("violations", [])) == 0,
violations=drc_data.get("violations", []),
warnings=drc_data.get("warnings", []),
unconnected_items=drc_data.get("unconnected_items", []),
output_file=output_file,
)
else:
# For non-JSON formats, just check if file was created
return DRCResult(
success=True, # Command succeeded
violations=[],
warnings=[],
unconnected_items=[],
output_file=output_file if output_file.exists() else None,
)
except KiCadCLICommandError as e:
# DRC command may return non-zero if violations found
# Try to parse the output file anyway
if format == "json" and output_file.exists():
with open(output_file, "r") as f:
drc_data = json.load(f)
return DRCResult(
success=False,
violations=drc_data.get("violations", []),
warnings=drc_data.get("warnings", []),
unconnected_items=drc_data.get("unconnected_items", []),
output_file=output_file,
)
else:
raise
[docs]
def export_gerbers(
self,
pcb_file: Union[str, Path],
output_dir: Union[str, Path],
layers: Optional[List[str]] = None,
protel_extensions: bool = False,
) -> List[Path]:
"""
Export Gerber files from a PCB.
Args:
pcb_file: Path to the PCB file
output_dir: Directory to save Gerber files
layers: Optional list of layer names to export. If None, exports all copper and technical layers
protel_extensions: Use Protel filename extensions
Returns:
List of generated Gerber file paths
"""
pcb_path = Path(pcb_file)
output_path = Path(output_dir).resolve() # Make absolute to avoid cwd issues
output_path.mkdir(parents=True, exist_ok=True)
args = [
"pcb",
"export",
"gerbers",
"--output",
str(output_path),
]
if layers:
# KiCad expects comma-separated layer list, not multiple --layers args
layer_list = ",".join(layers)
args.extend(["--layers", layer_list])
if not protel_extensions:
# KiCad uses Protel extensions by default, --no-protel-ext disables them
args.append("--no-protel-ext")
args.append(str(pcb_path))
self.run_command(args, cwd=pcb_path.parent)
# Find generated files
gerber_files = list(output_path.glob("*.gbr")) + list(output_path.glob("*.g*"))
return sorted(gerber_files)
[docs]
def export_drill(
self,
pcb_file: Union[str, Path],
output_dir: Union[str, Path],
format: str = "excellon",
units: str = "mm",
mirror_y: bool = False,
minimal_header: bool = False,
) -> Tuple[Optional[Path], Optional[Path]]:
"""
Export drill files from a PCB.
Args:
pcb_file: Path to the PCB file
output_dir: Directory to save drill files
format: Drill file format (excellon, gerber)
units: Units for coordinates (mm, in)
mirror_y: Mirror Y coordinates
minimal_header: Use minimal header
Returns:
Tuple of (plated_holes_file, non_plated_holes_file)
"""
pcb_path = Path(pcb_file)
output_path = Path(output_dir).resolve() # Make absolute to avoid cwd issues
output_path.mkdir(parents=True, exist_ok=True)
args = [
"pcb",
"export",
"drill",
"--output",
str(output_path),
"--format",
format,
]
# Units argument name depends on format
if format == "excellon":
args.extend(["--excellon-units", units])
elif format == "gerber":
# Gerber drill format doesn't have units arg, uses precision instead
pass
if mirror_y:
if format == "excellon":
args.append("--excellon-mirror-y")
else:
args.append("--mirror-y")
if minimal_header:
if format == "excellon":
args.append("--excellon-min-header")
else:
args.append("--minimal-header")
args.append(str(pcb_path))
self.run_command(args, cwd=pcb_path.parent)
# Find generated files
base_name = pcb_path.stem
plated_file = output_path / f"{base_name}-PTH.drl"
non_plated_file = output_path / f"{base_name}-NPTH.drl"
return (
plated_file if plated_file.exists() else None,
non_plated_file if non_plated_file.exists() else None,
)
[docs]
def export_pos(
self,
pcb_file: Union[str, Path],
output_file: Union[str, Path],
side: str = "both",
format: str = "csv",
units: str = "mm",
use_drill_origin: bool = False,
smd_only: bool = False,
) -> Path:
"""
Export pick and place (position) file from a PCB.
Args:
pcb_file: Path to the PCB file
output_file: Output file path
side: Which side to export (front, back, both)
format: Output format (csv, ascii, gerber)
units: Units for coordinates (mm, in)
use_drill_origin: Use drill/place origin instead of page origin
smd_only: Only include SMD components
Returns:
Path to generated position file
"""
pcb_path = Path(pcb_file)
output_path = Path(output_file)
args = [
"pcb",
"export",
"pos",
"--output",
str(output_path),
"--side",
side,
"--format",
format,
"--units",
units,
]
if use_drill_origin:
args.append("--use-drill-origin")
if smd_only:
args.append("--smd-only")
args.append(str(pcb_path))
self.run_command(args, cwd=pcb_path.parent)
return output_path
[docs]
def export_svg(
self,
pcb_file: Union[str, Path],
output_file: Union[str, Path],
layers: Optional[List[str]] = None,
theme: Optional[str] = None,
black_and_white: bool = False,
page_size: Optional[str] = None,
) -> Path:
"""
Export PCB as SVG image.
Args:
pcb_file: Path to the PCB file
output_file: Output SVG file path
layers: List of layers to include
theme: Color theme to use
black_and_white: Export in black and white
page_size: Page size (A4, A3, etc.)
Returns:
Path to generated SVG file
"""
pcb_path = Path(pcb_file)
output_path = Path(output_file)
args = [
"pcb",
"export",
"svg",
"--output",
str(output_path),
]
if layers:
for layer in layers:
args.extend(["--layers", layer])
if theme:
args.extend(["--theme", theme])
if black_and_white:
args.append("--black-and-white")
if page_size:
args.extend(["--page-size", page_size])
args.append(str(pcb_path))
self.run_command(args, cwd=pcb_path.parent)
return output_path
# Convenience function for creating CLI instance
[docs]
def get_kicad_cli(kicad_cli_path: Optional[str] = None) -> KiCadCLI:
"""
Get a KiCad CLI instance with auto-detection.
Args:
kicad_cli_path: Optional explicit path to kicad-cli
Returns:
KiCadCLI instance
"""
return KiCadCLI(kicad_cli_path)