Skip to content

Commit a89cd56

Browse files
authored
Fix JuliaCall binary-directory discovery for symlinks and wrappers (#817)
* Fix JuliaCall binary-directory discovery for symlinks and wrappers * use juliapkg.bindir() to avoid bindir discovery --------- Co-authored-by: Christopher Rowley <github.com/cjdoris>
1 parent 46ce2df commit a89cd56

4 files changed

Lines changed: 100 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## Unreleased
4+
* Bug fixes.
5+
36
## 0.9.36 (2026-09-18)
47
* Bug fixes.
58

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ classifiers = [
1313
"Operating System :: OS Independent",
1414
]
1515
requires-python = ">=3.10, <4"
16-
dependencies = ["juliapkg >=0.1.26, <0.2"]
16+
dependencies = ["juliapkg >=0.1.27, <0.2"]
1717

1818
[dependency-groups]
1919
dev = [

‎pysrc/juliacall/__init__.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,22 @@ def args_from_config(config):
215215
CONFIG['project'] = project = juliapkg.project()
216216
if libpath is None:
217217
CONFIG['libpath'] = libpath = juliapkg.libjulia()
218+
if bindir is None:
219+
CONFIG['bindir'] = bindir = juliapkg.bindir()
218220
else:
219221
raise Exception("Both PYTHON_JULIACALL_PROJECT and PYTHON_JULIACALL_EXE must be set together, not only one of them.")
220222
if (libpath is not None) and (exepath is None):
221223
raise Exception("PYTHON_JULIACALL_EXE is required if PYTHON_JULIACALL_LIB is set.")
222224

223-
# Find the Julia library, if not specified.
224-
if libpath is None:
225+
# Discover the Julia library and binary directory if either is missing.
226+
if libpath is None or bindir is None:
225227
cmd = [exepath, '--project='+project, '--startup-file=no', '-O0', '--compile=min',
226228
'-e', 'import Libdl; print(abspath(Libdl.dlpath("libjulia")), "\\0", Sys.BINDIR)']
227-
libpath, found_bindir = subprocess.run(cmd, check=True, capture_output=True, encoding='utf8').stdout.split('\0')
228-
CONFIG['libpath'] = libpath
229+
found_libpath, found_bindir = subprocess.run(cmd, check=True, capture_output=True, encoding='utf8').stdout.split('\0')
230+
if libpath is None:
231+
CONFIG['libpath'] = libpath = found_libpath
229232
if bindir is None:
230233
CONFIG['bindir'] = bindir = found_bindir
231-
if bindir is None:
232-
bindir = os.path.dirname(exepath)
233234
assert os.path.exists(libpath)
234235
assert os.path.exists(bindir)
235236

‎pytest/test_issue_816.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import os
2+
from pathlib import Path
3+
import subprocess
4+
import sys
5+
6+
import pytest
7+
8+
9+
_CHILD = r"""
10+
import os
11+
from pathlib import Path
12+
import shlex
13+
import stat
14+
15+
import juliapkg
16+
17+
juliapkg.resolve()
18+
real_executable = juliapkg.executable()
19+
# Populate the normal JuliaPkg project and libjulia cache before changing only
20+
# the executable result that JuliaCall will see.
21+
juliapkg.project()
22+
juliapkg.libjulia()
23+
24+
launcher = Path(os.environ["JULIACALL_TEST_LAUNCHER"])
25+
mode = os.environ["JULIACALL_TEST_LAUNCHER_MODE"]
26+
if mode == "symlink":
27+
try:
28+
launcher.symlink_to(real_executable)
29+
except OSError as exc:
30+
print(f"symlink unsupported: {exc}")
31+
raise SystemExit(77)
32+
elif mode == "wrapper":
33+
launcher.write_text(
34+
"#!/bin/sh\nexec " + shlex.quote(real_executable) + ' "$@"\n',
35+
encoding="utf-8",
36+
)
37+
launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR)
38+
else:
39+
raise AssertionError(mode)
40+
41+
juliapkg.executable = lambda: str(launcher)
42+
43+
from juliacall import Main
44+
45+
assert Main.seval("1 + 1") == 2
46+
"""
47+
48+
49+
def _run_launcher_case(
50+
tmp_path: Path, mode: str
51+
) -> subprocess.CompletedProcess[str]:
52+
env = os.environ.copy()
53+
for name in (
54+
"PYTHON_JULIACALL_EXE",
55+
"PYTHON_JULIACALL_PROJECT",
56+
"PYTHON_JULIACALL_BINDIR",
57+
"PYTHON_JULIACALL_LIB",
58+
):
59+
env.pop(name, None)
60+
env["PYTHONPATH"] = str(Path(__file__).parents[1] / "pysrc")
61+
env["JULIACALL_TEST_LAUNCHER"] = str(
62+
tmp_path / "outside-julia-layout" / "julia"
63+
)
64+
env["JULIACALL_TEST_LAUNCHER_MODE"] = mode
65+
Path(env["JULIACALL_TEST_LAUNCHER"]).parent.mkdir()
66+
return subprocess.run(
67+
[sys.executable, "-c", _CHILD],
68+
cwd=Path(__file__).parents[1],
69+
env=env,
70+
capture_output=True,
71+
text=True,
72+
timeout=180,
73+
)
74+
75+
76+
@pytest.mark.parametrize("mode", ["symlink", "wrapper"])
77+
def test_issue_816_launcher_outside_julia_bindir(tmp_path, mode):
78+
"""https://github.com/JuliaPy/PythonCall.jl/issues/816"""
79+
if mode == "wrapper" and os.name != "posix":
80+
pytest.skip("the executable wrapper requires POSIX sh")
81+
82+
result = _run_launcher_case(tmp_path, mode)
83+
if mode == "symlink" and result.returncode == 77:
84+
pytest.skip(result.stdout.strip())
85+
assert result.returncode == 0, (
86+
f"child exited with {result.returncode}\n"
87+
f"stdout:\n{result.stdout}\n"
88+
f"stderr:\n{result.stderr}"
89+
)

0 commit comments

Comments
 (0)