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 logging
import os import os
from pathlib import Path from pathlib import Path
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@ -24,6 +25,18 @@ from kipy.board_types import BoardLayer
from .ipc_client import kicad_ipc_session from .ipc_client import kicad_ipc_session
from .ses_apply import apply_ses_to_board 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__) logger = logging.getLogger(__name__)
@ -338,8 +351,24 @@ class FreeRoutingEngine:
Returns: Returns:
Tuple of (success, output_ses_path) 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: 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 {})} config = {**self.routing_config, **(routing_config or {})}
@ -394,6 +423,38 @@ class FreeRoutingEngine:
logger.error(f"Error running FreeRouting: {e}") logger.error(f"Error running FreeRouting: {e}")
return False, None 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( def import_ses_to_kicad(
self, self,
board_path: str, 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))