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 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,
|
||||||
|
|||||||
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