From 20612be14f245e87a9cadee119b24461ae8afc57 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 12 Dec 2025 17:24:16 +0000 Subject: [PATCH 1/4] Fix AsyncRunner failure on Python 3.14 due to asyncio.get_event_loop() removal In Python 3.14, asyncio.get_event_loop() raises RuntimeError when no event loop is running. Replace with a version-agnostic approach that: 1. Tries asyncio.get_running_loop() first 2. Falls back to creating a new event loop if none exists This works on Python 3.7+ and handles all cases correctly. Fixes #489 --- adaptive/runner.py | 13 ++++++++++++- adaptive/tests/test_runner.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/adaptive/runner.py b/adaptive/runner.py index b2e5ec9a..53cac6fa 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -626,7 +626,18 @@ def __init__( raise_if_retries_exceeded=raise_if_retries_exceeded, allow_running_forever=True, ) - self.ioloop = ioloop or asyncio.get_event_loop() + if ioloop is not None: + self.ioloop = ioloop + else: + try: + self.ioloop = asyncio.get_running_loop() + except RuntimeError: + # No running event loop exists (e.g., running outside of async context). + # Create a new event loop. This is needed for Python 3.10+ where + # asyncio.get_event_loop() is deprecated when no loop is running, + # and Python 3.14+ where it raises RuntimeError. + self.ioloop = asyncio.new_event_loop() + asyncio.set_event_loop(self.ioloop) # When the learned function is 'async def', we run it # directly on the event loop, and not in the executor. diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py index 0bb68c59..dd6e3562 100644 --- a/adaptive/tests/test_runner.py +++ b/adaptive/tests/test_runner.py @@ -1,3 +1,4 @@ +import asyncio import platform import sys import time @@ -263,3 +264,35 @@ def counting_ask(self, n, tell_pending=True): finally: # Restore original method Learner1D.ask = original_ask + + +def test_async_runner_without_event_loop(): + """Test that AsyncRunner works when no event loop exists. + + In Python 3.10+, asyncio.get_event_loop() was deprecated when no running + event loop exists. In Python 3.12+ it emits a DeprecationWarning, and in + Python 3.14+ it raises a RuntimeError. + + This test ensures AsyncRunner properly handles the case when no event + loop exists by creating one. + + Regression test for: https://github.com/python-adaptive/adaptive/issues/489 + """ + + def run_in_thread(): + """Run AsyncRunner in a thread with no event loop.""" + # Ensure no event loop exists in this thread + with pytest.raises(RuntimeError, match="no.*event loop"): + asyncio.get_running_loop() + + # AsyncRunner should still work - it should create its own event loop + learner = Learner1D(linear, (-1, 1)) + runner = AsyncRunner(learner, npoints_goal=10, executor=SequentialExecutor()) + runner.block_until_done() + assert learner.npoints >= 10 + + import threading + + thread = threading.Thread(target=run_in_thread) + thread.start() + thread.join() From cfc886b15c9ff1574309c7225e14bc15028b251a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 12 Dec 2025 17:25:38 +0000 Subject: [PATCH 2/4] Add Python 3.14 to CI matrix --- .github/workflows/nox.yml | 2 +- adaptive/tests/test_runner.py | 31 +++++++++++-------------------- noxfile.py | 2 +- pyproject.toml | 1 + 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index a03b2d7b..69dc0051 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py index dd6e3562..9441d2d0 100644 --- a/adaptive/tests/test_runner.py +++ b/adaptive/tests/test_runner.py @@ -266,33 +266,24 @@ def counting_ask(self, n, tell_pending=True): Learner1D.ask = original_ask -def test_async_runner_without_event_loop(): - """Test that AsyncRunner works when no event loop exists. +def test_async_runner_without_running_event_loop(): + """Test that AsyncRunner works when no event loop is running. In Python 3.10+, asyncio.get_event_loop() was deprecated when no running event loop exists. In Python 3.12+ it emits a DeprecationWarning, and in Python 3.14+ it raises a RuntimeError. This test ensures AsyncRunner properly handles the case when no event - loop exists by creating one. + loop is running by creating one. Regression test for: https://github.com/python-adaptive/adaptive/issues/489 """ + # Ensure no event loop is currently running + with pytest.raises(RuntimeError, match="no running event loop"): + asyncio.get_running_loop() - def run_in_thread(): - """Run AsyncRunner in a thread with no event loop.""" - # Ensure no event loop exists in this thread - with pytest.raises(RuntimeError, match="no.*event loop"): - asyncio.get_running_loop() - - # AsyncRunner should still work - it should create its own event loop - learner = Learner1D(linear, (-1, 1)) - runner = AsyncRunner(learner, npoints_goal=10, executor=SequentialExecutor()) - runner.block_until_done() - assert learner.npoints >= 10 - - import threading - - thread = threading.Thread(target=run_in_thread) - thread.start() - thread.join() + # AsyncRunner should still work - it should create its own event loop + learner = Learner1D(linear, (-1, 1)) + runner = AsyncRunner(learner, npoints_goal=10, executor=SequentialExecutor()) + runner.block_until_done() + assert learner.npoints >= 10 diff --git a/noxfile.py b/noxfile.py index 71a2217a..06b816fc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,7 @@ nox.options.default_venv_backend = "uv" -python = ["3.11", "3.12", "3.13"] +python = ["3.11", "3.12", "3.13", "3.14"] num_cpus = os.cpu_count() or 1 xdist = ("-n", "auto") if num_cpus > 2 else () diff --git a/pyproject.toml b/pyproject.toml index 5c7186e9..e3f4d28f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "scipy", From ab86b6c3296f9dd5f3baf941e7b287fad4a348f8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 12 Dec 2025 17:27:59 +0000 Subject: [PATCH 3/4] Remove redundant test - CI on Python 3.14 is sufficient --- adaptive/tests/test_runner.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py index 9441d2d0..0bb68c59 100644 --- a/adaptive/tests/test_runner.py +++ b/adaptive/tests/test_runner.py @@ -1,4 +1,3 @@ -import asyncio import platform import sys import time @@ -264,26 +263,3 @@ def counting_ask(self, n, tell_pending=True): finally: # Restore original method Learner1D.ask = original_ask - - -def test_async_runner_without_running_event_loop(): - """Test that AsyncRunner works when no event loop is running. - - In Python 3.10+, asyncio.get_event_loop() was deprecated when no running - event loop exists. In Python 3.12+ it emits a DeprecationWarning, and in - Python 3.14+ it raises a RuntimeError. - - This test ensures AsyncRunner properly handles the case when no event - loop is running by creating one. - - Regression test for: https://github.com/python-adaptive/adaptive/issues/489 - """ - # Ensure no event loop is currently running - with pytest.raises(RuntimeError, match="no running event loop"): - asyncio.get_running_loop() - - # AsyncRunner should still work - it should create its own event loop - learner = Learner1D(linear, (-1, 1)) - runner = AsyncRunner(learner, npoints_goal=10, executor=SequentialExecutor()) - runner.block_until_done() - assert learner.npoints >= 10 From d045d7703b8a7197d37167db2e749fc9b2ae4060 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 12 Dec 2025 17:36:25 +0000 Subject: [PATCH 4/4] Refactor: extract _get_or_create_event_loop helper function --- adaptive/runner.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/adaptive/runner.py b/adaptive/runner.py index 53cac6fa..1ab058a1 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -626,18 +626,7 @@ def __init__( raise_if_retries_exceeded=raise_if_retries_exceeded, allow_running_forever=True, ) - if ioloop is not None: - self.ioloop = ioloop - else: - try: - self.ioloop = asyncio.get_running_loop() - except RuntimeError: - # No running event loop exists (e.g., running outside of async context). - # Create a new event loop. This is needed for Python 3.10+ where - # asyncio.get_event_loop() is deprecated when no loop is running, - # and Python 3.14+ where it raises RuntimeError. - self.ioloop = asyncio.new_event_loop() - asyncio.set_event_loop(self.ioloop) + self.ioloop = ioloop if ioloop is not None else _get_or_create_event_loop() # When the learned function is 'async def', we run it # directly on the event loop, and not in the executor. @@ -998,7 +987,22 @@ def replay_log( getattr(learner, method)(*args) -# -- Internal executor-related, things +# -- Internal executor-related things + + +def _get_or_create_event_loop() -> asyncio.AbstractEventLoop: + """Get the running event loop or create a new one. + + In Python 3.10+, asyncio.get_event_loop() is deprecated when no loop is running. + In Python 3.14+, it raises RuntimeError instead of creating a new loop. + This function provides a compatible way to get or create an event loop. + """ + try: + return asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop def _ensure_executor(executor: ExecutorTypes | None) -> concurrent.Executor: