Prefer native freeroute CLI over the FreeRouting JAR for routing
Some checks are pending
CI / Build Package (push) Blocked by required conditions
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run

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.
This commit is contained in:
Ryan Malloy 2026-07-12 13:49:55 -06:00
parent dbbb627278
commit f837b76547
2 changed files with 131 additions and 1 deletions

View File

@ -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 <directory>``,
freeroute writes a single ``-do <file>``, 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,

View File

@ -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))