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
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:
parent
dbbb627278
commit
f837b76547
@ -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,
|
||||
|
||||
69
tests/test_freeroute_backend.py
Normal file
69
tests/test_freeroute_backend.py
Normal 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))
|
||||
Loading…
x
Reference in New Issue
Block a user