diff --git a/newsfragments/160.bugfix.rst b/newsfragments/160.bugfix.rst new file mode 100644 index 00000000..c3ef392f --- /dev/null +++ b/newsfragments/160.bugfix.rst @@ -0,0 +1,2 @@ +Fix ``import trio_asyncio`` on Python 3.14+, where ``asyncio`` renamed +``BaseDefaultEventLoopPolicy`` and removed ``AbstractChildWatcher``. diff --git a/tests/test_py314_compat.py b/tests/test_py314_compat.py new file mode 100644 index 00000000..e12c7ef5 --- /dev/null +++ b/tests/test_py314_compat.py @@ -0,0 +1,50 @@ +"""Import / policy compatibility across asyncio API changes (esp. Python 3.14).""" + +import asyncio +import sys + +import pytest + +import trio_asyncio +from trio_asyncio._loop import ( + TrioChildWatcher, + TrioPolicy, + _BasePolicy, + _ChildWatcherBase, + _PlatformDefaultPolicy, + _trio_policy, +) + + +def test_package_imports(): + assert trio_asyncio.__version__ + + +def test_trio_policy_types(): + assert isinstance(_trio_policy, TrioPolicy) + assert issubclass(TrioPolicy, _BasePolicy) + assert issubclass(TrioPolicy, _PlatformDefaultPolicy) + # Outside Trio context, get_event_loop_policy() returns the pre-import + # policy by design; the installed singleton is still TrioPolicy. + assert isinstance(_trio_policy, _BasePolicy) + + +def test_trio_child_watcher_constructible(): + watcher = TrioChildWatcher() + assert isinstance(watcher, _ChildWatcherBase) + watcher.close() + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="3.14+ asyncio renames") +def test_uses_underscored_asyncio_names_on_3_14(): + # BaseDefaultEventLoopPolicy was renamed; AbstractChildWatcher removed. + assert not hasattr(asyncio.events, "BaseDefaultEventLoopPolicy") + assert _BasePolicy is asyncio.events._BaseDefaultEventLoopPolicy + assert not hasattr(asyncio, "AbstractChildWatcher") + assert _ChildWatcherBase is object + + +@pytest.mark.trio +async def test_open_loop_still_works(): + async with trio_asyncio.open_loop() as loop: + assert asyncio.get_running_loop() is loop diff --git a/trio_asyncio/_loop.py b/trio_asyncio/_loop.py index 58edfc5f..a1cb252d 100644 --- a/trio_asyncio/_loop.py +++ b/trio_asyncio/_loop.py @@ -119,7 +119,42 @@ def _in_trio_context_other_than_sync_loop(): return False -class _TrioPolicy(asyncio.events.BaseDefaultEventLoopPolicy): +# Compatibility shims for asyncio API changes across Python versions: +# - 3.14 renamed BaseDefaultEventLoopPolicy -> _BaseDefaultEventLoopPolicy +# (https://github.com/python/cpython/pull/128216); see issue #160. +# - 3.14 removed AbstractChildWatcher (deprecated since 3.12); see issue #152. +# - 3.14 deprecated the public policy helpers and prefers underscored names. +try: + _BasePolicy = asyncio.events.BaseDefaultEventLoopPolicy +except AttributeError: # pragma: no cover + _BasePolicy = asyncio.events._BaseDefaultEventLoopPolicy + +try: + if sys.platform == "win32": # pragma: no cover + from asyncio.windows_events import ( + _DefaultEventLoopPolicy as _PlatformDefaultPolicy, + ) + else: + from asyncio.unix_events import ( + _DefaultEventLoopPolicy as _PlatformDefaultPolicy, + ) +except ImportError: + # Python < 3.14 exports the public name only. + _PlatformDefaultPolicy = asyncio.DefaultEventLoopPolicy + +try: + _AbstractEventLoopPolicy = asyncio.events._AbstractEventLoopPolicy +except AttributeError: + _AbstractEventLoopPolicy = asyncio.AbstractEventLoopPolicy + +if sys.platform == "win32": # pragma: no cover + _ChildWatcherBase = object +else: + # Falls back to object once AbstractChildWatcher is removed (3.14+). + _ChildWatcherBase = getattr(asyncio, "AbstractChildWatcher", object) + + +class _TrioPolicy(_BasePolicy): @staticmethod def _loop_factory(): raise RuntimeError("Event loop creations shouldn't get here") @@ -222,9 +257,7 @@ def _new_policy_set(new_policy): raise RuntimeError("You can't set the Trio loop policy manually") if _in_trio_context_other_than_sync_loop(): raise RuntimeError("You can't change the event loop policy in Trio context") - if new_policy is not None and not isinstance( - new_policy, asyncio.AbstractEventLoopPolicy - ): + if new_policy is not None and not isinstance(new_policy, _AbstractEventLoopPolicy): # Raise the type of error that the CPython test suite expects raise_type = TypeError if sys.version_info >= (3, 11) else AssertionError raise raise_type( @@ -234,12 +267,21 @@ def _new_policy_set(new_policy): _faked_policy.policy = new_policy -_orig_policy_get = _aio_event.get_event_loop_policy -_orig_policy_set = _aio_event.set_event_loop_policy +# Prefer the non-deprecated private helpers on 3.14+ so import-time policy +# install does not emit DeprecationWarning under filterwarnings=error. +_orig_policy_get = getattr( + _aio_event, "_get_event_loop_policy", _aio_event.get_event_loop_policy +) +_orig_policy_set = getattr( + _aio_event, "_set_event_loop_policy", _aio_event.set_event_loop_policy +) _aio_event.get_event_loop_policy = _new_policy_get _aio_event.set_event_loop_policy = _new_policy_set asyncio.get_event_loop_policy = _new_policy_get asyncio.set_event_loop_policy = _new_policy_set +if hasattr(_aio_event, "_get_event_loop_policy"): + _aio_event._get_event_loop_policy = _new_policy_get + _aio_event._set_event_loop_policy = _new_policy_set ##### @@ -317,10 +359,13 @@ def _new_current_task(loop=None): ##### -class TrioPolicy(_TrioPolicy, asyncio.DefaultEventLoopPolicy): +class TrioPolicy(_TrioPolicy, _PlatformDefaultPolicy): """This is the loop policy that's active whenever we're in a Trio context.""" def _init_watcher(self): + # 3.14+ DefaultEventLoopPolicy no longer owns a child watcher. + if not hasattr(self, "_watcher"): + self._watcher = None with asyncio.events._lock: if self._watcher is None: # pragma: no branch self._watcher = TrioChildWatcher() @@ -343,7 +388,15 @@ def set_child_watcher(self, watcher): watcher.close() watcher = TrioChildWatcher() watcher.attach_loop(loop) - super().set_child_watcher(watcher) + parent_set = getattr(super(), "set_child_watcher", None) + if parent_set is not None: + parent_set(watcher) + else: + # Child-watcher policy APIs were removed in Python 3.14. + old = getattr(self, "_watcher", None) + if old is not None: + old.close() + self._watcher = watcher _original_policy = _orig_policy_get() @@ -356,9 +409,7 @@ def set_child_watcher(self, watcher): current_loop = ContextVar("trio_aio_loop", default=None) -class TrioChildWatcher( - asyncio.AbstractChildWatcher if sys.platform != "win32" else object -): +class TrioChildWatcher(_ChildWatcherBase): """Watches for child processes to exit using Trio APIs. All TrioChildWatchers behave identically, so there's no reason to construct @@ -367,7 +418,7 @@ class TrioChildWatcher( ``asyncio.get_event_loop_policy """ - # AbstractChildWatcher not available under Windows + # AbstractChildWatcher is unavailable on Windows and removed in 3.14+ def __init__(self): super().__init__() self._callbacks = {} # pid => handler