From f837b76547205d1f367cd23a7f04c318f84f82b0 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 12 Jul 2026 13:49:55 -0600 Subject: [PATCH] Prefer native freeroute CLI over the FreeRouting JAR for routing Add find_freeroute_cli() + _run_freeroute_cli(): run_freerouting now tries the Java-free freeroute autorouter first (freeroute's CLI is -de/-do compatible) and falls back to the JAR for boards freeroute cannot yet route. freeroute is invoked as a subprocess, not imported, keeping its GPL license cleanly separated from mckicad (MIT). Raises a clear error only when neither backend is available. End-to-end verified: DSN -> freeroute -> SES -> native applier = 141 segments on the Arduino_Mega board, fully headless and Java-free. --- src/mckicad/utils/freerouting.py | 63 ++++++++++++++++++++++++++++- tests/test_freeroute_backend.py | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/test_freeroute_backend.py diff --git a/src/mckicad/utils/freerouting.py b/src/mckicad/utils/freerouting.py index 8ae3d5e..7a6b249 100644 --- a/src/mckicad/utils/freerouting.py +++ b/src/mckicad/utils/freerouting.py @@ -13,6 +13,7 @@ import glob import logging import os from pathlib import Path +import shutil import subprocess import sys import tempfile @@ -24,6 +25,18 @@ from kipy.board_types import BoardLayer from .ipc_client import kicad_ipc_session from .ses_apply import apply_ses_to_board + +def find_freeroute_cli() -> str | None: + """Locate the native, Java-free ``freeroute`` autorouter CLI, if installed. + + Preferred over the FreeRouting JAR (no JVM, headless). Set ``FREEROUTE_CLI`` + to override the discovered path. Returns None if freeroute is not installed. + """ + override = os.environ.get("FREEROUTE_CLI") + if override and os.path.isfile(override): + return override + return shutil.which("freeroute") + logger = logging.getLogger(__name__) @@ -338,8 +351,24 @@ class FreeRoutingEngine: Returns: Tuple of (success, output_ses_path) """ + # Prefer the native, Java-free freeroute CLI when installed. The + # FreeRouting JAR is the fallback for boards freeroute cannot yet route + # (dense / DRC-clean routing). freeroute is invoked as a subprocess + # (not imported) so its GPL license stays cleanly separated from mckicad. + freeroute_cli = find_freeroute_cli() + if freeroute_cli: + ok, ses_path = self._run_freeroute_cli(freeroute_cli, dsn_path, output_directory) + if ok: + return True, ses_path + logger.warning("freeroute produced no route; falling back to the FreeRouting JAR") + if not self.freerouting_jar_path: - raise FreeRoutingError("FreeRouting JAR path not configured") + self.freerouting_jar_path = self.find_freerouting_jar() + if not self.freerouting_jar_path: + raise FreeRoutingError( + "No autorouter available: the native freeroute CLI was not found " + "and no FreeRouting JAR is configured" + ) config = {**self.routing_config, **(routing_config or {})} @@ -394,6 +423,38 @@ class FreeRoutingEngine: logger.error(f"Error running FreeRouting: {e}") return False, None + def _run_freeroute_cli( + self, freeroute_cli: str, dsn_path: str, output_directory: str + ) -> tuple[bool, str | None]: + """Route with the native freeroute CLI (a drop-in for the JAR's -de/-do). + + Returns (success, ses_path). Unlike the JAR's ``-do ``, + freeroute writes a single ``-do ``, so the SES path is explicit. + """ + name = os.path.splitext(os.path.basename(dsn_path))[0] + ses_path = os.path.join(output_directory, f"{name}.ses") + try: + logger.info(f"Routing with native freeroute: {freeroute_cli}") + result = subprocess.run( + [freeroute_cli, "-de", dsn_path, "-do", ses_path], + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode == 0 and os.path.isfile(ses_path): + logger.info(f"freeroute completed: {ses_path}") + return True, ses_path + logger.warning( + f"freeroute exited {result.returncode}: {(result.stderr or '')[:200]}" + ) + return False, None + except subprocess.TimeoutExpired: + logger.error("freeroute timed out") + return False, None + except Exception as e: + logger.error(f"Error running freeroute: {e}") + return False, None + def import_ses_to_kicad( self, board_path: str, diff --git a/tests/test_freeroute_backend.py b/tests/test_freeroute_backend.py new file mode 100644 index 0000000..a6847bd --- /dev/null +++ b/tests/test_freeroute_backend.py @@ -0,0 +1,69 @@ +"""The router prefers the native freeroute CLI and falls back to the JAR.""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from mckicad.utils.freerouting import FreeRoutingEngine, FreeRoutingError + + +def _jar_run_writes_ses(cmd, **_kwargs): + """Simulate the FreeRouting JAR: write a .ses into the -do output directory.""" + outdir = cmd[cmd.index("-do") + 1] + with open(os.path.join(outdir, "board.ses"), "w") as f: + f.write("(session board)") + return MagicMock(returncode=0, stdout="", stderr="") + + +def test_prefers_freeroute_when_available(tmp_path): + eng = FreeRoutingEngine(freerouting_jar_path="/fake/freerouting.jar") + with ( + patch("mckicad.utils.freerouting.find_freeroute_cli", return_value="/usr/bin/freeroute"), + patch.object(eng, "_run_freeroute_cli", return_value=(True, "/out/board.ses")) as fr, + patch("mckicad.utils.freerouting.subprocess.run") as jar_run, + ): + ok, ses = eng.run_freerouting("/tmp/board.dsn", "/out") + + assert ok and ses == "/out/board.ses" + fr.assert_called_once() + jar_run.assert_not_called() # the JAR is never invoked when freeroute succeeds + + +def test_falls_back_to_jar_when_freeroute_fails(tmp_path): + jar = tmp_path / "freerouting.jar" + jar.write_text("x") + eng = FreeRoutingEngine(freerouting_jar_path=str(jar)) + with ( + patch("mckicad.utils.freerouting.find_freeroute_cli", return_value="/usr/bin/freeroute"), + patch.object(eng, "_run_freeroute_cli", return_value=(False, None)), + patch("mckicad.utils.freerouting.subprocess.run", side_effect=_jar_run_writes_ses), + ): + ok, ses = eng.run_freerouting("/tmp/board.dsn", str(tmp_path)) + + assert ok and ses.endswith("board.ses") + + +def test_uses_jar_when_freeroute_absent(tmp_path): + jar = tmp_path / "freerouting.jar" + jar.write_text("x") + eng = FreeRoutingEngine(freerouting_jar_path=str(jar)) + with ( + patch("mckicad.utils.freerouting.find_freeroute_cli", return_value=None), + patch("mckicad.utils.freerouting.subprocess.run", side_effect=_jar_run_writes_ses), + ): + ok, ses = eng.run_freerouting("/tmp/board.dsn", str(tmp_path)) + + assert ok + + +def test_raises_when_no_router_available(tmp_path): + eng = FreeRoutingEngine() + with ( + patch("mckicad.utils.freerouting.find_freeroute_cli", return_value=None), + patch.object(eng, "find_freerouting_jar", return_value=None), + pytest.raises(FreeRoutingError), + ): + eng.run_freerouting("/tmp/board.dsn", str(tmp_path))