Skip to content

Commit 4b441fc

Browse files
committed
improved search for palace executable and better defaults in run_local
1 parent 54653f8 commit 4b441fc

2 files changed

Lines changed: 242 additions & 29 deletions

File tree

src/gsim/palace/base.py

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,13 +1354,19 @@ def run_local(
13541354
13551355
Args:
13561356
palace_sif_path: Path to Palace Apptainer SIF file.
1357-
Only used when ``use_apptainer=True``.
1358-
If None, uses PALACE_SIF environment variable.
1357+
Only used when ``use_apptainer=True`` and no executable is
1358+
selected.
1359+
If None, tries PALACE_SIF, then local defaults:
1360+
``./Palace.sif``, ``./palace.sif``,
1361+
``./bin/Palace.sif``, ``./bin/palace.sif``.
13591362
palace_executable: Path to Palace executable.
1360-
Only used when ``use_apptainer=False``.
1361-
If None, uses PALACE_EXECUTABLE environment variable or "palace".
1363+
If provided, runs Palace directly and overrides
1364+
``use_apptainer``.
1365+
If None, tries PALACE_EXECUTABLE, then ``./bin/palace``,
1366+
then ``palace`` on PATH.
13621367
use_apptainer: If True (default), run via Apptainer using SIF file.
13631368
If False, run Palace executable directly.
1369+
Ignored when ``palace_executable`` is explicitly provided.
13641370
num_processes: Number of MPI processes. If None (default),
13651371
uses all available CPUs.
13661372
num_threads: Number of OpenMP threads to use for OpenMP builds, default is 1
@@ -1387,13 +1393,14 @@ def run_local(
13871393
>>> # Using Apptainer with explicit path
13881394
>>> results = sim.run_local(palace_sif_path="/path/to/Palace.sif")
13891395
>>>
1390-
>>> # Using direct Palace installation
1391-
>>> results = sim.run_local(use_apptainer=False)
1396+
>>> # Auto-discovery with no options:
1397+
>>> # tries local SIF first, then executable defaults
1398+
>>> results = sim.run_local()
13921399
>>>
13931400
>>> # Using direct Palace with custom executable path
1394-
>>> results = sim.run_local(
1395-
... use_apptainer=False, palace_executable="/usr/local/bin/palace"
1396-
... )
1401+
>>> results = sim.run_local(palace_executable="/usr/local/bin/palace")
1402+
>>> # Using a locally built Palace binary
1403+
>>> results = sim.run_local(palace_executable="./bin/palace")
13971404
>>> # For DrivenSim: `results` is SParams -> results.s21.db
13981405
>>> # For eigen / electrostatic: `results` is dict[str, Path]
13991406
"""
@@ -1423,20 +1430,70 @@ def run_local(
14231430
f"Mesh file not found: {mesh_path}. Call mesh() first."
14241431
)
14251432

1426-
# Determine Palace command based on use_apptainer flag
1427-
if use_apptainer:
1433+
# Auto-discovery when arguments are omitted.
1434+
search_roots = [Path.cwd(), output_dir, output_dir.parent]
1435+
dedup_roots: list[Path] = []
1436+
for root in search_roots:
1437+
resolved = root.expanduser().resolve()
1438+
if resolved not in dedup_roots:
1439+
dedup_roots.append(resolved)
1440+
1441+
if palace_sif_path is None:
1442+
palace_sif_path = os.environ.get("PALACE_SIF")
1443+
if palace_sif_path is None:
1444+
sif_rel_candidates = [
1445+
Path("Palace.sif"),
1446+
Path("palace.sif"),
1447+
Path("bin/Palace.sif"),
1448+
Path("bin/palace.sif"),
1449+
]
1450+
for root in dedup_roots:
1451+
for rel in sif_rel_candidates:
1452+
candidate = (root / rel).resolve()
1453+
if candidate.exists():
1454+
palace_sif_path = str(candidate)
1455+
if verbose:
1456+
logger.info(
1457+
"Auto-discovered Palace SIF: %s", palace_sif_path
1458+
)
1459+
break
1460+
if palace_sif_path is not None:
1461+
break
1462+
1463+
if palace_executable is None:
1464+
palace_executable = os.environ.get("PALACE_EXECUTABLE")
1465+
1466+
if palace_executable is None and palace_sif_path is None:
1467+
for root in dedup_roots:
1468+
candidate = (root / "bin" / "palace").resolve()
1469+
if candidate.exists() and os.access(candidate, os.X_OK):
1470+
palace_executable = str(candidate)
1471+
if verbose:
1472+
logger.info(
1473+
"Auto-discovered Palace executable: %s", palace_executable
1474+
)
1475+
break
1476+
1477+
if palace_executable is None and palace_sif_path is None:
1478+
palace_executable = "palace"
1479+
1480+
# palace_executable takes precedence for a simpler API.
1481+
run_with_apptainer = use_apptainer and palace_executable is None
1482+
1483+
if palace_executable is not None and use_apptainer and verbose:
1484+
logger.info(
1485+
"palace_executable was provided; running directly and ignoring "
1486+
"use_apptainer=True."
1487+
)
1488+
1489+
# Determine Palace command based on effective execution mode.
1490+
if run_with_apptainer:
14281491
# Determine Palace SIF path from environment variable or parameter
14291492
if palace_sif_path is None:
1430-
palace_sif_path = os.environ.get("PALACE_SIF")
1431-
if palace_sif_path is None:
1432-
raise ValueError(
1433-
"Palace SIF path not specified. Either set PALACE_SIF "
1434-
"environment variable or pass palace_sif_path parameter."
1435-
)
1436-
if verbose:
1437-
logger.info(
1438-
"Using PALACE_SIF from environment: %s", palace_sif_path
1439-
)
1493+
raise ValueError(
1494+
"Palace SIF path not specified. Either set PALACE_SIF, pass "
1495+
"palace_sif_path, or provide a Palace executable."
1496+
)
14401497

14411498
sif_path = Path(palace_sif_path).expanduser().resolve()
14421499

@@ -1472,20 +1529,40 @@ def run_local(
14721529
palace_executable,
14731530
)
14741531

1475-
exe_path = Path(palace_executable).expanduser()
1532+
exe_candidate = Path(palace_executable).expanduser()
1533+
is_path_like = (
1534+
exe_candidate.is_absolute()
1535+
or exe_candidate.parent != Path()
1536+
or str(palace_executable).startswith("~")
1537+
)
14761538

1477-
# Check if executable exists
1478-
if not exe_path.exists():
1479-
# Try resolving to see if it's in PATH
1480-
resolved = shutil.which(str(exe_path))
1481-
if resolved is None:
1539+
if is_path_like:
1540+
# Normalize local paths to absolute paths because subprocess
1541+
# runs with cwd=output_dir.
1542+
exe_path = exe_candidate.resolve()
1543+
if not exe_path.exists():
14821544
raise FileNotFoundError(
14831545
f"Palace executable not found: {exe_path}. "
14841546
"Install Palace directly or provide correct path via "
14851547
"palace_executable parameter."
14861548
)
1549+
else:
1550+
# Resolve command names (e.g. "palace") via PATH.
1551+
resolved = shutil.which(str(exe_candidate))
1552+
if resolved is None:
1553+
raise FileNotFoundError(
1554+
f"Palace executable not found: {exe_candidate}. "
1555+
"Install Palace directly or provide correct path via "
1556+
"palace_executable parameter."
1557+
)
14871558
exe_path = Path(resolved)
14881559

1560+
if not os.access(exe_path, os.X_OK):
1561+
raise FileNotFoundError(
1562+
f"Palace executable is not executable: {exe_path}. "
1563+
"Update file permissions or provide a valid executable path."
1564+
)
1565+
14891566
cmd = [
14901567
str(exe_path),
14911568
"-np",
@@ -1497,7 +1574,7 @@ def run_local(
14971574
cmd.extend(["config.json"])
14981575

14991576
if verbose:
1500-
if use_apptainer:
1577+
if run_with_apptainer:
15011578
logger.info("Running Palace simulation in %s via Apptainer", output_dir)
15021579
else:
15031580
logger.info("Running Palace simulation in %s directly", output_dir)
@@ -1528,7 +1605,7 @@ def run_local(
15281605
error_msg += f"\n\nStderr:\n{e.stderr}"
15291606
raise RuntimeError(error_msg) from e
15301607
except FileNotFoundError as e:
1531-
if use_apptainer:
1608+
if run_with_apptainer:
15321609
raise RuntimeError(
15331610
"Apptainer not found. Install Apptainer to run local simulations "
15341611
"with use_apptainer=True."

tests/palace/test_run_local.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Tests for local Palace execution wiring."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from types import SimpleNamespace
7+
from typing import cast
8+
9+
from gsim.palace import DrivenSim
10+
11+
12+
def test_run_local_accepts_relative_local_executable(monkeypatch, tmp_path):
13+
"""A relative executable should work without passing use_apptainer=False."""
14+
output_dir = tmp_path / "sim"
15+
postpro_dir = output_dir / "output" / "palace"
16+
output_dir.mkdir(parents=True)
17+
postpro_dir.mkdir(parents=True)
18+
19+
# Required inputs checked by run_local before launching Palace.
20+
(output_dir / "config.json").write_text("{}")
21+
(output_dir / "palace.msh").write_text("mesh")
22+
23+
# Simulate a locally built Palace binary under the current working directory.
24+
local_bin_dir = tmp_path / "bin"
25+
local_bin_dir.mkdir()
26+
local_palace = local_bin_dir / "palace"
27+
local_palace.write_text("#!/bin/sh\nexit 0\n")
28+
local_palace.chmod(0o755)
29+
30+
monkeypatch.chdir(tmp_path)
31+
32+
captured: dict[str, object] = {}
33+
34+
def _fake_run(cmd, cwd, _check, _capture_output, _text):
35+
captured["cmd"] = cmd
36+
captured["cwd"] = cwd
37+
assert _check is True
38+
assert _capture_output is True
39+
assert _text is True
40+
return SimpleNamespace(stdout="", stderr="")
41+
42+
monkeypatch.setattr("subprocess.run", _fake_run)
43+
44+
sim = DrivenSim()
45+
sim.set_output_dir(output_dir)
46+
47+
result = sim.run_local(
48+
palace_executable="./bin/palace",
49+
num_processes=1,
50+
verbose=False,
51+
)
52+
53+
assert isinstance(result, dict)
54+
cmd = cast(list[str], captured["cmd"])
55+
assert isinstance(cmd, list)
56+
assert Path(cmd[0]) == local_palace.resolve()
57+
assert Path(cmd[0]).is_absolute()
58+
assert captured["cwd"] == output_dir
59+
60+
61+
def test_run_local_no_args_discovers_bin_palace(monkeypatch, tmp_path):
62+
"""run_local() without options should discover ./bin/palace."""
63+
output_dir = tmp_path / "sim"
64+
postpro_dir = output_dir / "output" / "palace"
65+
output_dir.mkdir(parents=True)
66+
postpro_dir.mkdir(parents=True)
67+
(output_dir / "config.json").write_text("{}")
68+
(output_dir / "palace.msh").write_text("mesh")
69+
70+
local_bin_dir = tmp_path / "bin"
71+
local_bin_dir.mkdir()
72+
local_palace = local_bin_dir / "palace"
73+
local_palace.write_text("#!/bin/sh\nexit 0\n")
74+
local_palace.chmod(0o755)
75+
76+
monkeypatch.chdir(tmp_path)
77+
monkeypatch.delenv("PALACE_SIF", raising=False)
78+
monkeypatch.delenv("PALACE_EXECUTABLE", raising=False)
79+
80+
captured: dict[str, object] = {}
81+
82+
def _fake_run(cmd, cwd, _check, _capture_output, _text):
83+
captured["cmd"] = cmd
84+
captured["cwd"] = cwd
85+
return SimpleNamespace(stdout="", stderr="")
86+
87+
monkeypatch.setattr("subprocess.run", _fake_run)
88+
89+
sim = DrivenSim()
90+
sim.set_output_dir(output_dir)
91+
result = sim.run_local(num_processes=1, verbose=False)
92+
93+
assert isinstance(result, dict)
94+
cmd = cast(list[str], captured["cmd"])
95+
assert isinstance(cmd, list)
96+
assert Path(cmd[0]) == local_palace.resolve()
97+
assert captured["cwd"] == output_dir
98+
99+
100+
def test_run_local_no_args_prefers_local_sif(monkeypatch, tmp_path):
101+
"""run_local() should use Apptainer when a local SIF is auto-discovered."""
102+
output_dir = tmp_path / "sim"
103+
postpro_dir = output_dir / "output" / "palace"
104+
output_dir.mkdir(parents=True)
105+
postpro_dir.mkdir(parents=True)
106+
(output_dir / "config.json").write_text("{}")
107+
(output_dir / "palace.msh").write_text("mesh")
108+
109+
local_sif = tmp_path / "Palace.sif"
110+
local_sif.write_text("fake")
111+
112+
monkeypatch.chdir(tmp_path)
113+
monkeypatch.delenv("PALACE_SIF", raising=False)
114+
monkeypatch.delenv("PALACE_EXECUTABLE", raising=False)
115+
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/apptainer")
116+
117+
captured: dict[str, object] = {}
118+
119+
def _fake_run(cmd, cwd, _check, _capture_output, _text):
120+
captured["cmd"] = cmd
121+
captured["cwd"] = cwd
122+
return SimpleNamespace(stdout="", stderr="")
123+
124+
monkeypatch.setattr("subprocess.run", _fake_run)
125+
126+
sim = DrivenSim()
127+
sim.set_output_dir(output_dir)
128+
result = sim.run_local(num_processes=1, verbose=False)
129+
130+
assert isinstance(result, dict)
131+
cmd = cast(list[str], captured["cmd"])
132+
assert isinstance(cmd, list)
133+
assert cmd[0] == "apptainer"
134+
assert cmd[1] == "run"
135+
assert Path(cmd[2]) == local_sif.resolve()
136+
assert captured["cwd"] == output_dir

0 commit comments

Comments
 (0)