Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
* Bug fix: on Linux, coordinate pytest's diagnostic plugin with explicitly enabled Julia signal handling to prevent shutdown crashes.
* Bug fix: avoid precompilation cache miss when `check_bounds` option set.
* Bug fix: `juliacall` set the environment variable `JULIA_PYTHONCALL_EXECUTABLE`
instead of `JULIA_PYTHONCALL_EXE`, so child Julia processes resolved a new
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ classifiers = [
requires-python = ">=3.10, <4"
dependencies = ["juliapkg >=0.1.26, <0.2"]

[project.entry-points.pytest11]
juliacall = "juliacall_pytest"

[dependency-groups]
dev = [
"flake8>=5.0",
Expand All @@ -25,4 +28,4 @@ dev = [
]

[tool.hatch.build.targets.wheel]
packages = ["pysrc/juliacall"]
packages = ["pysrc/juliacall", "pysrc/juliacall_pytest"]
36 changes: 36 additions & 0 deletions pysrc/juliacall_pytest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import os
import sys

import pytest


@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests(early_config):
handle_signals = sys._xoptions.get("juliacall-handle-signals")
if handle_signals is None:
handle_signals = os.environ.get("PYTHON_JULIACALL_HANDLE_SIGNALS")
if sys.platform != "linux" or handle_signals != "yes":
return
if not early_config.pluginmanager.hasplugin("faulthandler"):
return
try:
exit_on_timeout = early_config.getini("faulthandler_exit_on_timeout")
except ValueError as error:
if not isinstance(error.__cause__, KeyError) or error.__cause__.args != ("faulthandler_exit_on_timeout",):
raise
# Older pytest versions have no exit-on-timeout option.
exit_on_timeout = False
if exit_on_timeout and float(early_config.getini("faulthandler_timeout")) > 0:
raise pytest.UsageError(
"Julia signal handling conflicts with pytest's faulthandler_exit_on_timeout. "
"Use an external process timeout instead."
)
early_config.pluginmanager.set_blocked("faulthandler")
early_config.issue_config_time_warning(
pytest.PytestConfigWarning(
"Julia signal handling is enabled: disabling pytest's faulthandler plugin "
"and its timeout diagnostics to preserve Julia's signal handlers. "
"Julia fatal diagnostics remain enabled. Use -p no:juliacall to opt out."
),
stacklevel=2,
)
67 changes: 67 additions & 0 deletions pytest/test_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import os
import subprocess
import sys

import pytest


@pytest.mark.skipif(sys.platform != "linux", reason="Linux signal handling")
def test_pytest_shutdown(tmp_path):
test_file = tmp_path / "test_shutdown.py"
test_file.write_text('''
import atexit
from juliacall import Main as jl

atexit.register(lambda: print("PYTHON_ATEXIT", flush=True))
jl.seval("""
atexit(() -> println("JULIA_ATEXIT"))
const retained = Ref(0)
finalizer(x -> println("JULIA_FINALIZER"), retained)
""")

def test_gc():
jl.seval("Threads.@threads for i in 1:100; zeros(10000); GC.gc(); end")
''')
env = dict(os.environ, PYTHON_JULIACALL_HANDLE_SIGNALS="yes", PYTHON_JULIACALL_THREADS="6")
env.pop("PYTEST_ADDOPTS", None)
result = subprocess.run(
[sys.executable, "-m", "pytest", "-s", "-q", str(test_file)],
env=env, capture_output=True, text=True, timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr
for marker in ("PYTHON_ATEXIT", "JULIA_ATEXIT", "JULIA_FINALIZER"):
assert marker in result.stdout


@pytest.mark.skipif(sys.platform != "linux", reason="Linux signal handling")
@pytest.mark.parametrize("setting,xoption,plugins,enabled", [
(None, None, [], True),
("no", None, [], True),
("yes", None, [], False),
("yes", "no", [], True),
("no", "yes", [], False),
("yes", "", [], True),
("yes", "invalid", [], True),
("yes", None, ["-p", "no:juliacall"], True),
])
def test_pytest_configuration(tmp_path, setting, xoption, plugins, enabled):
test_file = tmp_path / "test_configuration.py"
test_file.write_text(f'''
import faulthandler
import sys

def test_configuration():
assert "juliacall" not in sys.modules
assert faulthandler.is_enabled() is {enabled!r}
''')
env = dict(os.environ)
for name in ("PYTEST_ADDOPTS", "PYTHONFAULTHANDLER", "PYTHON_JULIACALL_HANDLE_SIGNALS"):
env.pop(name, None)
if setting is not None:
env["PYTHON_JULIACALL_HANDLE_SIGNALS"] = setting
command = [sys.executable]
if xoption is not None:
command += ["-X", "juliacall-handle-signals" + ("=" + xoption if xoption else "")]
command += ["-m", "pytest", "-q", *plugins, str(test_file)]
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
Loading