From 7f1b072d00620ef34c43643b94fd1be56a13b814 Mon Sep 17 00:00:00 2001 From: Jeff Laflamme Date: Fri, 20 Mar 2026 12:01:19 +0700 Subject: [PATCH] Add Docker/Podman support for Freerouting autorouter Auto-detects runtime: Java 21+ direct or Docker/Podman fallback. Discovers docker/podman via PATH instead of hardcoding. README includes one-time setup with JAR download + Docker pull. 31 tests covering both execution modes. --- README.md | 18 +- python/commands/freerouting.py | 301 +++++++++++++++++++++++----- python/tests/test_freerouting.py | 334 +++++++++++++++++++++++-------- 3 files changed, 518 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 53ea8b2..6c5fb35 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,23 @@ Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap. Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter. -**Prerequisites:** Java 11+ and `freerouting.jar` (place at `~/.kicad-mcp/freerouting.jar` or set `FREEROUTING_JAR` env var). +**Setup (one-time):** + +```bash +# 1. Download the Freerouting JAR +mkdir -p ~/.kicad-mcp +curl -L -o ~/.kicad-mcp/freerouting.jar \ + https://github.com/freerouting/freerouting/releases/download/v2.0.1/freerouting-2.0.1-executable.jar + +# 2. Runtime — pick ONE: +# Option A: Docker (recommended, no Java install needed) +docker pull eclipse-temurin:21-jre + +# Option B: Install Java 21+ locally +# (Ubuntu/Debian) sudo apt install openjdk-21-jre +``` + +The autorouter auto-detects which runtime is available (Java 21+ direct, or Docker/Podman fallback). ```text Check if Freerouting is ready on my system. diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index 079cf96..a3b2268 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -3,6 +3,10 @@ Freerouting autoroute integration for KiCAD MCP Server. Exports the board to Specctra DSN format, runs Freerouting CLI, and imports the routed SES file back into the board. + +Supports two execution modes: + - Direct: java -jar freerouting.jar (requires Java 21+) + - Docker: docker run eclipse-temurin:21-jre (requires Docker) """ import os @@ -11,23 +15,26 @@ import shutil import time import logging from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List logger = logging.getLogger("kicad_interface") # Default Freerouting JAR location DEFAULT_FREEROUTING_JAR = os.environ.get( "FREEROUTING_JAR", - os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"), + os.path.join( + os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar" + ), ) +DOCKER_IMAGE = "eclipse-temurin:21-jre" + def _find_java() -> Optional[str]: """Find java executable on the system.""" java = shutil.which("java") if java: return java - # Check common locations for candidate in [ "/usr/bin/java", "/usr/local/bin/java", @@ -38,12 +45,118 @@ def _find_java() -> Optional[str]: return None +def _find_docker() -> Optional[str]: + """Find docker executable on the system.""" + return shutil.which("docker") or shutil.which("podman") + + +def _docker_available() -> bool: + """Check if Docker/Podman is available and running.""" + docker = _find_docker() + if not docker: + return False + try: + proc = subprocess.run( + [docker, "info"], + capture_output=True, + timeout=10, + ) + return proc.returncode == 0 + except Exception: + return False + + +def _java_version_ok(java_exe: str) -> bool: + """Check if local Java is version 21+.""" + try: + proc = subprocess.run( + [java_exe, "-version"], + capture_output=True, + text=True, + timeout=10, + ) + output = proc.stderr or proc.stdout + # Parse version like: openjdk version "17.0.18" + for line in output.split("\n"): + if "version" in line: + ver = line.split('"')[1] if '"' in line else "" + major = int(ver.split(".")[0]) + return major >= 21 + except Exception: + pass + return False + + +def _build_freerouting_cmd( + jar_path: str, + dsn_path: str, + ses_path: str, + passes: int, + use_docker: bool, +) -> List[str]: + """Build the command to run Freerouting.""" + if use_docker: + docker_exe = _find_docker() + board_dir = os.path.dirname(dsn_path) + dsn_name = os.path.basename(dsn_path) + ses_name = os.path.basename(ses_path) + jar_name = os.path.basename(jar_path) + return [ + docker_exe, "run", "--rm", + "-v", f"{jar_path}:/app/{jar_name}:ro", + "-v", f"{board_dir}:/work", + DOCKER_IMAGE, + "java", "-jar", f"/app/{jar_name}", + "-de", f"/work/{dsn_name}", + "-do", f"/work/{ses_name}", + "-mp", str(passes), + ] + else: + java_exe = _find_java() + return [ + java_exe, "-jar", jar_path, + "-de", dsn_path, "-do", ses_path, + "-mp", str(passes), + ] + + class FreeroutingCommands: """Handles Freerouting autoroute operations.""" def __init__(self, board=None): self.board = board + def _resolve_execution_mode( + self, jar_path: str + ) -> Dict[str, Any]: + """Determine how to run Freerouting: direct or docker. + + Returns dict with 'mode', 'use_docker', or 'error'. + """ + java_exe = _find_java() + if java_exe and _java_version_ok(java_exe): + return {"mode": "direct", "use_docker": False} + + if _docker_available(): + return {"mode": "docker", "use_docker": True} + + if java_exe: + return { + "mode": "error", + "error": ( + f"Java found at {java_exe} but version < 21. " + "Freerouting 2.x requires Java 21+. " + "Install Java 21+ or Docker." + ), + } + return { + "mode": "error", + "error": ( + "Neither Java 21+ nor Docker found. " + "Install one of them to use Freerouting." + ), + } + def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Freerouting autorouter on the current board. @@ -77,25 +190,17 @@ class FreeroutingCommands: return { "success": False, "message": "No board file path available", - "errorDetails": "Provide boardPath or open a project first", - } - - jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR) - timeout = params.get("timeout", 300) - passes = params.get("maxPasses") - - # Validate java - java_exe = _find_java() - if not java_exe: - return { - "success": False, - "message": "Java not found", "errorDetails": ( - "Freerouting requires Java. Install Java 11+ or set " - "JAVA_HOME environment variable." + "Provide boardPath or open a project first" ), } + jar_path = params.get( + "freeroutingJar", DEFAULT_FREEROUTING_JAR + ) + timeout = params.get("timeout", 300) + passes = params.get("maxPasses", 20) + # Validate Freerouting JAR if not os.path.isfile(jar_path): return { @@ -103,11 +208,22 @@ class FreeroutingCommands: "message": "Freerouting JAR not found", "errorDetails": ( f"Expected at: {jar_path}. Download from " - "https://github.com/freerouting/freerouting/releases " - "or set FREEROUTING_JAR env var." + "https://github.com/freerouting/freerouting/" + "releases or set FREEROUTING_JAR env var." ), } + # Determine execution mode + exec_mode = self._resolve_execution_mode(jar_path) + if exec_mode["mode"] == "error": + return { + "success": False, + "message": "No suitable Java runtime", + "errorDetails": exec_mode["error"], + } + + use_docker = exec_mode["use_docker"] + # Set up file paths board_dir = os.path.dirname(board_path) board_stem = Path(board_path).stem @@ -122,7 +238,9 @@ class FreeroutingCommands: return { "success": False, "message": "DSN export failed", - "errorDetails": f"ExportSpecctraDSN returned: {result}", + "errorDetails": ( + f"ExportSpecctraDSN returned: {result}" + ), } except Exception as e: return { @@ -142,13 +260,14 @@ class FreeroutingCommands: logger.info(f"DSN exported: {dsn_size} bytes") # Step 2: Run Freerouting - cmd = [ - java_exe, "-jar", jar_path, - "-de", dsn_path, "-do", ses_path, - "-mp", str(passes or 20), - ] + cmd = _build_freerouting_cmd( + jar_path, dsn_path, ses_path, passes, use_docker + ) - logger.info(f"Running Freerouting: {' '.join(cmd)}") + mode_label = "docker" if use_docker else "direct" + logger.info( + f"Running Freerouting ({mode_label}): {' '.join(cmd)}" + ) start_time = time.time() try: @@ -164,15 +283,23 @@ class FreeroutingCommands: if proc.returncode != 0: return { "success": False, - "message": f"Freerouting exited with code {proc.returncode}", + "message": ( + f"Freerouting exited with code " + f"{proc.returncode}" + ), "errorDetails": proc.stderr or proc.stdout, "elapsed_seconds": elapsed, + "mode": mode_label, } except subprocess.TimeoutExpired: return { "success": False, - "message": f"Freerouting timed out after {timeout}s", - "errorDetails": "Increase timeout or reduce board complexity", + "message": ( + f"Freerouting timed out after {timeout}s" + ), + "errorDetails": ( + "Increase timeout or reduce board complexity" + ), } except Exception as e: return { @@ -186,7 +313,10 @@ class FreeroutingCommands: return { "success": False, "message": "Freerouting did not produce SES output", - "errorDetails": f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}", + "errorDetails": ( + f"Expected at: {ses_path}. " + f"Stdout: {proc.stdout[:500]}" + ), "elapsed_seconds": elapsed, } @@ -201,7 +331,9 @@ class FreeroutingCommands: return { "success": False, "message": "SES import failed", - "errorDetails": f"ImportSpecctraSES returned: {result}", + "errorDetails": ( + f"ImportSpecctraSES returned: {result}" + ), "elapsed_seconds": elapsed, } except Exception as e: @@ -216,7 +348,9 @@ class FreeroutingCommands: try: self.board.Save(board_path) except Exception as e: - logger.warning(f"Board save after autoroute failed: {e}") + logger.warning( + f"Board save after autoroute failed: {e}" + ) # Collect stats tracks = self.board.GetTracks() @@ -231,6 +365,7 @@ class FreeroutingCommands: return { "success": True, "message": f"Autoroute completed in {elapsed}s", + "mode": mode_label, "dsn_path": dsn_path, "ses_path": ses_path, "elapsed_seconds": elapsed, @@ -238,11 +373,13 @@ class FreeroutingCommands: "tracks": track_count, "vias": via_count, }, - "freerouting_stdout": proc.stdout[:1000] if proc.stdout else "", + "freerouting_stdout": ( + proc.stdout[:1000] if proc.stdout else "" + ), } def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export the board to Specctra DSN format only (no routing).""" + """Export the board to Specctra DSN format only.""" try: import pcbnew except ImportError: @@ -259,26 +396,36 @@ class FreeroutingCommands: "errorDetails": "Load or create a board first", } - board_path = params.get("boardPath") or self.board.GetFileName() + board_path = ( + params.get("boardPath") or self.board.GetFileName() + ) output_path = params.get("outputPath") if not output_path: if board_path: - output_path = os.path.splitext(board_path)[0] + ".dsn" + output_path = ( + os.path.splitext(board_path)[0] + ".dsn" + ) else: return { "success": False, "message": "No output path", - "errorDetails": "Provide outputPath or have a board file open", + "errorDetails": ( + "Provide outputPath or have a board open" + ), } try: - result = pcbnew.ExportSpecctraDSN(self.board, output_path) + result = pcbnew.ExportSpecctraDSN( + self.board, output_path + ) if result is not True and result != 0: return { "success": False, "message": "DSN export failed", - "errorDetails": f"ExportSpecctraDSN returned: {result}", + "errorDetails": ( + f"ExportSpecctraDSN returned: {result}" + ), } except Exception as e: return { @@ -287,7 +434,11 @@ class FreeroutingCommands: "errorDetails": str(e), } - file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0 + file_size = ( + os.path.getsize(output_path) + if os.path.isfile(output_path) + else 0 + ) return { "success": True, "message": f"Exported DSN to {output_path}", @@ -318,7 +469,9 @@ class FreeroutingCommands: return { "success": False, "message": "Missing sesPath parameter", - "errorDetails": "Provide the path to the .ses file", + "errorDetails": ( + "Provide the path to the .ses file" + ), } if not os.path.isfile(ses_path): @@ -329,12 +482,16 @@ class FreeroutingCommands: } try: - result = pcbnew.ImportSpecctraSES(self.board, ses_path) + result = pcbnew.ImportSpecctraSES( + self.board, ses_path + ) if result is not True and result != 0: return { "success": False, "message": "SES import failed", - "errorDetails": f"ImportSpecctraSES returned: {result}", + "errorDetails": ( + f"ImportSpecctraSES returned: {result}" + ), } except Exception as e: return { @@ -343,17 +500,24 @@ class FreeroutingCommands: "errorDetails": str(e), } - # Save after import - board_path = params.get("boardPath") or self.board.GetFileName() + board_path = ( + params.get("boardPath") or self.board.GetFileName() + ) if board_path: try: self.board.Save(board_path) except Exception as e: - logger.warning(f"Board save after SES import failed: {e}") + logger.warning( + f"Board save after SES import failed: {e}" + ) tracks = self.board.GetTracks() - track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA") - via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA") + track_count = sum( + 1 for t in tracks if t.GetClass() != "PCB_VIA" + ) + via_count = sum( + 1 for t in tracks if t.GetClass() == "PCB_VIA" + ) return { "success": True, @@ -364,12 +528,18 @@ class FreeroutingCommands: }, } - def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Check if Freerouting and Java are available.""" - jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR) + def check_freerouting( + self, params: Dict[str, Any] + ) -> Dict[str, Any]: + """Check if Freerouting and Java/Docker are available.""" + jar_path = params.get( + "freeroutingJar", DEFAULT_FREEROUTING_JAR + ) + # Check local Java java_exe = _find_java() java_version = None + java_21_ok = False if java_exe: try: proc = subprocess.run( @@ -378,11 +548,27 @@ class FreeroutingCommands: text=True, timeout=10, ) - java_version = (proc.stderr or proc.stdout).strip().split("\n")[0] + java_version = ( + (proc.stderr or proc.stdout) + .strip() + .split("\n")[0] + ) + java_21_ok = _java_version_ok(java_exe) except Exception: pass + # Check Docker/Podman + docker_exe = _find_docker() + has_docker = _docker_available() + jar_exists = os.path.isfile(jar_path) + ready = jar_exists and (java_21_ok or has_docker) + + mode = "none" + if java_21_ok: + mode = "direct" + elif has_docker: + mode = "docker" return { "success": True, @@ -391,10 +577,17 @@ class FreeroutingCommands: "found": java_exe is not None, "path": java_exe, "version": java_version, + "java_21_ok": java_21_ok, + }, + "docker": { + "available": has_docker, + "path": docker_exe, + "image": DOCKER_IMAGE, }, "freerouting": { "jar_found": jar_exists, "jar_path": jar_path, }, - "ready": java_exe is not None and jar_exists, + "execution_mode": mode, + "ready": ready, } diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index 110d338..c338c80 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -5,8 +5,9 @@ Covers: - FreeroutingCommands.check_freerouting (dependency detection) - FreeroutingCommands.export_dsn (DSN export via pcbnew) - FreeroutingCommands.import_ses (SES import via pcbnew) - - FreeroutingCommands.autoroute (full pipeline) - - Error handling: missing board, missing java, missing JAR, timeouts + - FreeroutingCommands.autoroute (full pipeline, direct + docker) + - Error handling: missing board, no runtime, missing JAR, timeouts + - _find_java, _docker_available, _java_version_ok helpers """ import sys @@ -15,10 +16,15 @@ from unittest.mock import MagicMock, patch import pytest -from commands.freerouting import FreeroutingCommands, _find_java +from commands.freerouting import ( + FreeroutingCommands, + _find_java, + _find_docker, + _docker_available, + _java_version_ok, +) -# Ensure the pcbnew mock from conftest is available at module level -# so methods that do `import pcbnew` get the mock. +# pcbnew mock from conftest pcbnew_mock = sys.modules["pcbnew"] @@ -31,7 +37,6 @@ pcbnew_mock = sys.modules["pcbnew"] def reset_pcbnew_mock(): """Reset pcbnew mock before each test.""" pcbnew_mock.reset_mock() - # Clear side_effects that persist through reset_mock pcbnew_mock.ExportSpecctraDSN.side_effect = None pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock() pcbnew_mock.ImportSpecctraSES.side_effect = None @@ -41,65 +46,88 @@ def reset_pcbnew_mock(): @pytest.fixture def mock_board(): - """Create a mock pcbnew.BOARD with minimal interface.""" board = MagicMock() - board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" + board.GetFileName.return_value = ( + "/tmp/test_project/test.kicad_pcb" + ) board.GetTracks.return_value = [] return board @pytest.fixture def cmds(mock_board): - """FreeroutingCommands with a mock board.""" return FreeroutingCommands(board=mock_board) @pytest.fixture def cmds_no_board(): - """FreeroutingCommands without a board.""" return FreeroutingCommands(board=None) +def _patch_direct_java(): + """Patch to simulate Java 21+ available locally.""" + return patch.object( + FreeroutingCommands, + "_resolve_execution_mode", + return_value={"mode": "direct", "use_docker": False}, + ) + + +def _patch_docker_mode(): + """Patch to simulate Docker execution mode.""" + return patch.object( + FreeroutingCommands, + "_resolve_execution_mode", + return_value={"mode": "docker", "use_docker": True}, + ) + + +def _patch_no_runtime(): + """Patch to simulate no Java and no Docker.""" + return patch.object( + FreeroutingCommands, + "_resolve_execution_mode", + return_value={ + "mode": "error", + "error": "Neither Java 21+ nor Docker found.", + }, + ) + + # --------------------------------------------------------------------------- # check_freerouting # --------------------------------------------------------------------------- class TestCheckFreerouting: - def test_java_not_found(self, cmds): + def test_no_java_no_docker(self, cmds): with patch( "commands.freerouting._find_java", return_value=None + ), patch( + "commands.freerouting._docker_available", + return_value=False, ): result = cmds.check_freerouting( {"freeroutingJar": "/nonexistent.jar"} ) assert result["success"] is True assert result["java"]["found"] is False - assert result["freerouting"]["jar_found"] is False + assert result["docker"]["available"] is False assert result["ready"] is False + assert result["execution_mode"] == "none" - def test_java_found_no_jar(self, cmds): - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ), patch( - "commands.freerouting.subprocess.run" - ) as mock_run: - mock_run.return_value = MagicMock( - stderr='openjdk version "17.0.1"', stdout="" - ) - result = cmds.check_freerouting( - {"freeroutingJar": "/nonexistent.jar"} - ) - assert result["java"]["found"] is True - assert result["java"]["path"] == "/usr/bin/java" - assert result["freerouting"]["jar_found"] is False - assert result["ready"] is False - - def test_all_ready(self, cmds, tmp_path): + def test_java_too_old_docker_available(self, cmds, tmp_path): jar = tmp_path / "freerouting.jar" jar.touch() with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), patch( + "commands.freerouting._java_version_ok", + return_value=False, + ), patch( + "commands.freerouting._docker_available", + return_value=True, ), patch( "commands.freerouting.subprocess.run" ) as mock_run: @@ -110,6 +138,31 @@ class TestCheckFreerouting: {"freeroutingJar": str(jar)} ) assert result["ready"] is True + assert result["execution_mode"] == "docker" + + def test_java_21_direct(self, cmds, tmp_path): + jar = tmp_path / "freerouting.jar" + jar.touch() + with patch( + "commands.freerouting._find_java", + return_value="/usr/bin/java", + ), patch( + "commands.freerouting._java_version_ok", + return_value=True, + ), patch( + "commands.freerouting._docker_available", + return_value=False, + ), patch( + "commands.freerouting.subprocess.run" + ) as mock_run: + mock_run.return_value = MagicMock( + stderr='openjdk version "21.0.1"', stdout="" + ) + result = cmds.check_freerouting( + {"freeroutingJar": str(jar)} + ) + assert result["ready"] is True + assert result["execution_mode"] == "direct" # --------------------------------------------------------------------------- @@ -129,15 +182,11 @@ class TestExportDsn: cmds.board.GetFileName.return_value = board_path pcbnew_mock.ExportSpecctraDSN.return_value = True - # Simulate DSN file creation Path(dsn_path).write_text("(pcb test)") result = cmds.export_dsn({}) assert result["success"] is True assert result["path"] == dsn_path - pcbnew_mock.ExportSpecctraDSN.assert_called_once_with( - cmds.board, dsn_path - ) def test_export_custom_path(self, cmds, tmp_path): output = str(tmp_path / "custom.dsn") @@ -149,7 +198,9 @@ class TestExportDsn: assert result["path"] == output def test_export_failure(self, cmds): - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( + "DSN error" + ) result = cmds.export_dsn({}) assert result["success"] is False assert "DSN error" in result["errorDetails"] @@ -162,7 +213,9 @@ class TestExportDsn: class TestImportSes: def test_no_board(self, cmds_no_board): - result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) + result = cmds_no_board.import_ses( + {"sesPath": "/tmp/test.ses"} + ) assert result["success"] is False assert "No board" in result["message"] @@ -172,7 +225,9 @@ class TestImportSes: assert "Missing sesPath" in result["message"] def test_ses_file_not_found(self, cmds): - result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) + result = cmds.import_ses( + {"sesPath": "/nonexistent/test.ses"} + ) assert result["success"] is False assert "not found" in result["message"] @@ -185,15 +240,14 @@ class TestImportSes: result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is True - pcbnew_mock.ImportSpecctraSES.assert_called_once_with( - cmds.board, str(ses_file) - ) def test_import_failure(self, cmds, tmp_path): ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") - pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error") + pcbnew_mock.ImportSpecctraSES.side_effect = Exception( + "SES error" + ) result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is False assert "SES error" in result["errorDetails"] @@ -210,21 +264,20 @@ class TestAutoroute: assert result["success"] is False assert "No board" in result["message"] - def test_no_java(self, cmds): - with patch( - "commands.freerouting._find_java", return_value=None - ): - result = cmds.autoroute({}) + def test_no_runtime(self, cmds, tmp_path): + jar = tmp_path / "freerouting.jar" + jar.touch() + with _patch_no_runtime(): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) assert result["success"] is False - assert "Java not found" in result["message"] + assert "No suitable Java runtime" in result["message"] def test_no_jar(self, cmds): - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute( - {"freeroutingJar": "/nonexistent/freerouting.jar"} - ) + result = cmds.autoroute( + {"freeroutingJar": "/nonexistent/freerouting.jar"} + ) assert result["success"] is False assert "JAR not found" in result["message"] @@ -237,15 +290,17 @@ class TestAutoroute: "export fail" ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute({"freeroutingJar": str(jar)}) + with _patch_direct_java(): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) assert result["success"] is False assert "DSN export failed" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_freerouting_timeout(self, mock_run, cmds, tmp_path): + def test_freerouting_timeout( + self, mock_run, cmds, tmp_path + ): import subprocess jar = tmp_path / "freerouting.jar" @@ -264,9 +319,7 @@ class TestAutoroute: cmd="", timeout=10 ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): + with _patch_direct_java(): result = cmds.autoroute( {"freeroutingJar": str(jar), "timeout": 10} ) @@ -274,7 +327,9 @@ class TestAutoroute: assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success(self, mock_run, cmds, tmp_path): + def test_full_success_direct( + self, mock_run, cmds, tmp_path + ): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -286,37 +341,72 @@ class TestAutoroute: cmds.board.GetFileName.return_value = str(board_file) - # DSN export creates file pcbnew_mock.ExportSpecctraDSN.side_effect = ( lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] ) - # Freerouting creates SES file mock_run.return_value = MagicMock( returncode=0, stdout="Routing completed", stderr="" ) ses_file.write_text("(session)") - - # SES import succeeds pcbnew_mock.ImportSpecctraSES.return_value = True - # Board tracks after import track = MagicMock() track.GetClass.return_value = "PCB_TRACK" via = MagicMock() via.GetClass.return_value = "PCB_VIA" cmds.board.GetTracks.return_value = [track, track, via] - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute({"freeroutingJar": str(jar)}) + with _patch_direct_java(): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) assert result["success"] is True + assert result["mode"] == "direct" assert result["board_stats"]["tracks"] == 2 assert result["board_stats"]["vias"] == 1 assert "elapsed_seconds" in result - pcbnew_mock.ExportSpecctraDSN.assert_called_once() - pcbnew_mock.ImportSpecctraSES.assert_called_once() + + @patch("commands.freerouting.subprocess.run") + def test_full_success_docker( + self, mock_run, cmds, tmp_path + ): + jar = tmp_path / "freerouting.jar" + jar.touch() + board_dir = tmp_path / "project" + board_dir.mkdir() + board_file = board_dir / "test.kicad_pcb" + board_file.touch() + dsn_file = board_dir / "test.dsn" + ses_file = board_dir / "test.ses" + + cmds.board.GetFileName.return_value = str(board_file) + + pcbnew_mock.ExportSpecctraDSN.side_effect = ( + lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] + ) + mock_run.return_value = MagicMock( + returncode=0, stdout="Routing completed", stderr="" + ) + ses_file.write_text("(session)") + pcbnew_mock.ImportSpecctraSES.return_value = True + + cmds.board.GetTracks.return_value = [MagicMock()] + + with _patch_docker_mode(), patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) + + assert result["success"] is True + assert result["mode"] == "docker" + # Verify a container runtime was invoked + call_args = mock_run.call_args[0][0] + assert "run" in call_args + assert "--rm" in call_args @patch("commands.freerouting.subprocess.run") def test_freerouting_nonzero_exit( @@ -338,17 +428,17 @@ class TestAutoroute: returncode=1, stdout="", stderr="OutOfMemoryError" ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute({"freeroutingJar": str(jar)}) + with _patch_direct_java(): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) assert result["success"] is False assert "exited with code 1" in result["message"] # --------------------------------------------------------------------------- -# _find_java helper +# Helper functions # --------------------------------------------------------------------------- @@ -362,6 +452,90 @@ class TestFindJava: def test_none_when_not_found(self): with patch( - "commands.freerouting.shutil.which", return_value=None + "commands.freerouting.shutil.which", + return_value=None, ), patch("os.path.isfile", return_value=False): assert _find_java() is None + + +class TestFindDocker: + def test_finds_docker(self): + with patch( + "commands.freerouting.shutil.which", + side_effect=lambda x: "/usr/bin/docker" + if x == "docker" + else None, + ): + assert _find_docker() == "/usr/bin/docker" + + def test_finds_podman(self): + with patch( + "commands.freerouting.shutil.which", + side_effect=lambda x: "/usr/bin/podman" + if x == "podman" + else None, + ): + assert _find_docker() == "/usr/bin/podman" + + def test_none_when_not_found(self): + with patch( + "commands.freerouting.shutil.which", + return_value=None, + ): + assert _find_docker() is None + + +class TestDockerAvailable: + def test_docker_found(self): + with patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), patch( + "commands.freerouting.subprocess.run" + ) as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert _docker_available() is True + + def test_docker_not_installed(self): + with patch( + "commands.freerouting._find_docker", + return_value=None, + ): + assert _docker_available() is False + + def test_docker_not_running(self): + with patch( + "commands.freerouting._find_docker", + return_value="/usr/bin/docker", + ), patch( + "commands.freerouting.subprocess.run" + ) as mock_run: + mock_run.return_value = MagicMock(returncode=1) + assert _docker_available() is False + + +class TestJavaVersionOk: + def test_java_21(self): + with patch( + "commands.freerouting.subprocess.run" + ) as mock_run: + mock_run.return_value = MagicMock( + stderr='openjdk version "21.0.1"', stdout="" + ) + assert _java_version_ok("/usr/bin/java") is True + + def test_java_17(self): + with patch( + "commands.freerouting.subprocess.run" + ) as mock_run: + mock_run.return_value = MagicMock( + stderr='openjdk version "17.0.18"', stdout="" + ) + assert _java_version_ok("/usr/bin/java") is False + + def test_java_error(self): + with patch( + "commands.freerouting.subprocess.run", + side_effect=Exception("not found"), + ): + assert _java_version_ok("/usr/bin/java") is False