Skip to content

Commit d6db22a

Browse files
authored
Add a compatibility layer for the asyncio changes in Python 3.14.0a4. (#558)
Makes RubiconEventLoop() the public API for getting a Rubicon event loop, adds warnings around the use of EventLoopPolicy, and gates some features with known deprecations.
1 parent ac461d6 commit d6db22a

File tree

6 files changed

+143
-94
lines changed

6 files changed

+143
-94
lines changed

changes/557.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The interface with EventLoopPolicy was updated to account for the eventual deprecation of that API in Python.

changes/557.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``RubiconEventLoop()`` is now exposed as an interface for creating a CoreFoundation compatible event loop.

changes/557.removal.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Python 3.14 deprecated the use of custom event loop policies, in favor of directly instantiating event loops. Instead of calling ``asyncio.new_event_loop()`` after installing an instance of ``rubicon.objc.eventloop.EventLoopPolicy``, you can call ``RubiconEventLoop()`` to instantiate an instance of an event loop and use that instance directly. This approach can be used on all versions of Python; on Python 3.13 and earlier, ``RubiconEventLoop()`` is a shim that performs the older event loop policy-based instantiation.

docs/how-to/async.rst

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,16 @@ However, you can't have two event loops running at the same time, so you need
2222
a way to integrate the two. Luckily, :mod:`asyncio` provides a way to customize
2323
it's event loop so it can be integrated with other event sources.
2424

25-
It does this using an Event Loop Policy. Rubicon provides an Core Foundation
26-
Event Loop Policy that inserts Core Foundation event handling into the asyncio
27-
event loop.
25+
It does this using a custom event loop. Rubicon provides a ``RubiconEventLoop``
26+
that inserts Core Foundation event handling into the asyncio event loop.
2827

2928
To use asyncio in a pure Core Foundation application, do the following::
3029

31-
# Import the Event Loop Policy
32-
from rubicon.objc.eventloop import EventLoopPolicy
33-
34-
# Install the event loop policy
35-
asyncio.set_event_loop_policy(EventLoopPolicy())
30+
# Import the Event Loop
31+
from rubicon.objc.eventloop import RubiconEventLoop
3632

3733
# Create an event loop, and run it!
38-
loop = asyncio.new_event_loop()
34+
loop = RubiconEventLoop()
3935
loop.run_forever()
4036

4137
The last call (``loop.run_forever()``) will, as the name suggests, run forever
@@ -50,11 +46,8 @@ CoreFoundation event loop - you need to start the full ``NSApplication``
5046
life cycle. To do this, you pass the application instance into the call to
5147
``loop.run_forever()``::
5248

53-
# Import the Event Loop Policy and lifecycle
54-
from rubicon.objc.eventloop import EventLoopPolicy, CocoaLifecycle
55-
56-
# Install the event loop policy
57-
asyncio.set_event_loop_policy(EventLoopPolicy())
49+
# Import the Event Loop and lifecycle
50+
from rubicon.objc.eventloop import RubiconEventLoop, CocoaLifecycle
5851

5952
# Get a handle to the shared NSApplication
6053
from ctypes import cdll, util
@@ -66,7 +59,7 @@ life cycle. To do this, you pass the application instance into the call to
6659
app = NSApplication.sharedApplication
6760

6861
# Create an event loop, and run it, using the NSApplication!
69-
loop = asyncio.new_event_loop()
62+
loop = RubiconEventLoop()
7063
loop.run_forever(lifecycle=CocoaLifecycle(app))
7164

7265
Again, this will run "forever" -- until either ``loop.stop()`` is called, or
@@ -79,14 +72,11 @@ If you're using UIKit and UIApplication on iOS, you need to use the iOS
7972
life cycle. To do this, you pass an ``iOSLifecycle`` object into the call to
8073
``loop.run_forever()``::
8174

82-
# Import the Event Loop Policy and lifecycle
83-
from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle
84-
85-
# Install the event loop policy
86-
asyncio.set_event_loop_policy(EventLoopPolicy())
75+
# Import the Event Loop and lifecycle
76+
from rubicon.objc.eventloop import RubiconEventLoop, iOSLifecycle
8777

8878
# Create an event loop, and run it, using the UIApplication!
89-
loop = asyncio.new_event_loop()
79+
loop = RubiconEventLoop()
9080
loop.run_forever(lifecycle=iOSLifecycle())
9181

9282
Again, this will run "forever" -- until either ``loop.stop()`` is called, or

src/rubicon/objc/eventloop.py

Lines changed: 118 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""PEP 3156 event loop based on CoreFoundation."""
22

33
import contextvars
4+
import inspect
45
import sys
56
import threading
7+
import warnings
68
from asyncio import (
7-
DefaultEventLoopPolicy,
89
coroutines,
910
events,
1011
tasks,
@@ -17,14 +18,33 @@
1718
from .types import CFIndex
1819

1920
if sys.version_info < (3, 14):
20-
from asyncio import SafeChildWatcher
21+
from asyncio import (
22+
AbstractEventLoopPolicy,
23+
DefaultEventLoopPolicy,
24+
SafeChildWatcher,
25+
set_event_loop_policy,
26+
)
27+
elif sys.version_info < (3, 16):
28+
# Python 3.14 finalized the deprecation of SafeChildWatcher. There's no
29+
# replacement API; the feature can be removed.
30+
#
31+
# Python 3.14 also started the deprecation of event loop policies, to be
32+
# finalized in Python 3.16; there was some symbol renaming to assist in
33+
# making the deprecation visible. See
34+
# https://github.com/python/cpython/issues/127949 for details.
35+
from asyncio import (
36+
_AbstractEventLoopPolicy as AbstractEventLoopPolicy,
37+
_DefaultEventLoopPolicy as DefaultEventLoopPolicy,
38+
)
2139

2240
__all__ = [
2341
"EventLoopPolicy",
2442
"CocoaLifecycle",
43+
"RubiconEventLoop",
2544
"iOSLifecycle",
2645
]
2746

47+
2848
###########################################################################
2949
# CoreFoundation types and constants needed for async handlers
3050
###########################################################################
@@ -421,7 +441,7 @@ def remove_writer(self, fd):
421441
######################################################################
422442
def _check_not_coroutine(self, callback, name):
423443
"""Check whether the given callback is a coroutine or not."""
424-
if coroutines.iscoroutine(callback) or coroutines.iscoroutinefunction(callback):
444+
if coroutines.iscoroutine(callback) or inspect.iscoroutinefunction(callback):
425445
raise TypeError(f"coroutines cannot be used with {name}()")
426446

427447
def is_running(self):
@@ -637,7 +657,8 @@ def _set_lifecycle(self, lifecycle):
637657
"You can't set a lifecycle on a loop that's already running."
638658
)
639659
self._lifecycle = lifecycle
640-
self._policy._lifecycle = lifecycle
660+
if sys.version_info < (3, 14):
661+
self._policy._lifecycle = lifecycle
641662

642663
def _add_callback(self, handle):
643664
"""Add a callback to be invoked ASAP.
@@ -656,81 +677,115 @@ def _add_callback(self, handle):
656677
self.call_soon(handle._callback, *handle._args)
657678

658679

659-
class EventLoopPolicy(events.AbstractEventLoopPolicy):
660-
"""Rubicon event loop policy.
680+
if sys.version_info < (3, 16):
661681

662-
In this policy, each thread has its own event loop. However, we only
663-
automatically create an event loop by default for the main thread;
664-
other threads by default have no event loop.
665-
"""
682+
class EventLoopPolicy(AbstractEventLoopPolicy):
683+
"""Rubicon event loop policy.
666684
667-
def __init__(self):
668-
self._lifecycle = None
669-
self._default_loop = None
670-
self._watcher_lock = threading.Lock()
671-
self._watcher = None
672-
self._policy = DefaultEventLoopPolicy()
673-
self._policy.new_event_loop = self.new_event_loop
674-
self.get_event_loop = self._policy.get_event_loop
675-
self.set_event_loop = self._policy.set_event_loop
685+
In this policy, each thread has its own event loop. However, we only
686+
automatically create an event loop by default for the main thread; other
687+
threads by default have no event loop.
676688
677-
def new_event_loop(self):
678-
"""Create a new event loop and return it."""
679-
if (
680-
not self._default_loop
681-
and threading.current_thread() == threading.main_thread()
682-
):
683-
loop = self.get_default_loop()
684-
else:
689+
**DEPRECATED** - Python 3.14 deprecated the concept of manually creating
690+
EventLoopPolicies. Create and use a ``RubiconEventLoop`` instance instead of
691+
installing an event loop policy and calling ``asyncio.new_event_loop()``.
692+
"""
693+
694+
def __init__(self):
695+
warnings.warn(
696+
"Custom EventLoopPolicy instances have been deprecated by Python 3.14. "
697+
"Create and use a `RubiconEventLoop` instance directly instead of "
698+
"installing an event loop policy and calling `asyncio.new_event_loop()`.",
699+
DeprecationWarning,
700+
stacklevel=2,
701+
)
702+
703+
self._lifecycle = None
704+
self._default_loop = None
705+
if sys.version_info < (3, 14):
706+
self._watcher_lock = threading.Lock()
707+
self._watcher = None
708+
self._policy = DefaultEventLoopPolicy()
709+
self._policy.new_event_loop = self.new_event_loop
710+
self.get_event_loop = self._policy.get_event_loop
711+
self.set_event_loop = self._policy.set_event_loop
712+
713+
def new_event_loop(self):
714+
"""Create a new event loop and return it."""
715+
if (
716+
not self._default_loop
717+
and threading.current_thread() == threading.main_thread()
718+
):
719+
loop = self.get_default_loop()
720+
else:
721+
loop = CFEventLoop(self._lifecycle)
722+
loop._policy = self
723+
724+
return loop
725+
726+
def get_default_loop(self):
727+
"""Get the default event loop."""
728+
if not self._default_loop:
729+
self._default_loop = self._new_default_loop()
730+
return self._default_loop
731+
732+
def _new_default_loop(self):
685733
loop = CFEventLoop(self._lifecycle)
686-
loop._policy = self
734+
loop._policy = self
735+
return loop
687736

688-
return loop
737+
if sys.version_info < (3, 14):
689738

690-
def get_default_loop(self):
691-
"""Get the default event loop."""
692-
if not self._default_loop:
693-
self._default_loop = self._new_default_loop()
694-
return self._default_loop
739+
def _init_watcher(self):
740+
with events._lock:
741+
if self._watcher is None: # pragma: no branch
742+
self._watcher = SafeChildWatcher()
743+
if threading.current_thread() == threading.main_thread():
744+
self._watcher.attach_loop(self._default_loop)
695745

696-
def _new_default_loop(self):
697-
loop = CFEventLoop(self._lifecycle)
698-
loop._policy = self
699-
return loop
746+
def get_child_watcher(self):
747+
"""Get the watcher for child processes.
700748
701-
if sys.version_info < (3, 14):
749+
If not yet set, a :class:`~asyncio.SafeChildWatcher` object is
750+
automatically created.
702751
703-
def _init_watcher(self):
704-
with events._lock:
705-
if self._watcher is None: # pragma: no branch
706-
self._watcher = SafeChildWatcher()
707-
if threading.current_thread() == threading.main_thread():
708-
self._watcher.attach_loop(self._default_loop)
752+
.. note::
753+
Child watcher support was removed in Python 3.14
754+
"""
755+
if self._watcher is None:
756+
self._init_watcher()
709757

710-
def get_child_watcher(self):
711-
"""Get the watcher for child processes.
758+
return self._watcher
712759

713-
If not yet set, a :class:`~asyncio.SafeChildWatcher` object is
714-
automatically created.
760+
def set_child_watcher(self, watcher):
761+
"""Set the watcher for child processes.
715762
716-
.. note::
717-
Child watcher support was removed in Python 3.14
718-
"""
719-
if self._watcher is None:
720-
self._init_watcher()
763+
.. note::
764+
Child watcher support was removed in Python 3.14
765+
"""
766+
if self._watcher is not None:
767+
self._watcher.close()
721768

722-
return self._watcher
769+
self._watcher = watcher
723770

724-
def set_child_watcher(self, watcher):
725-
"""Set the watcher for child processes.
726771

727-
.. note::
728-
Child watcher support was removed in Python 3.14
729-
"""
730-
if self._watcher is not None:
731-
self._watcher.close()
772+
if sys.version_info < (3, 14):
773+
774+
def RubiconEventLoop():
775+
"""Create a new Rubicon CFEventLoop instance."""
776+
# If they're using RubiconEventLoop(), they've done the necessary adaptation.
777+
with warnings.catch_warnings():
778+
warnings.filterwarnings(
779+
"ignore",
780+
message=r"^Custom EventLoopPolicy instances have been deprecated by Python 3.14",
781+
category=DeprecationWarning,
782+
)
783+
policy = EventLoopPolicy()
784+
set_event_loop_policy(policy)
785+
return policy.new_event_loop()
732786

733-
self._watcher = watcher
787+
else:
788+
RubiconEventLoop = CFEventLoop
734789

735790

736791
class CFLifecycle:

tests/test_async.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import sys
45
import time
56
import unittest
67

7-
from rubicon.objc.eventloop import EventLoopPolicy
8+
from rubicon.objc.eventloop import RubiconEventLoop
89

910

1011
# Some coroutines with known behavior for testing purposes.
@@ -21,11 +22,11 @@ async def stop_loop(loop, delay):
2122

2223
class AsyncRunTests(unittest.TestCase):
2324
def setUp(self):
24-
asyncio.set_event_loop_policy(EventLoopPolicy())
25-
self.loop = asyncio.new_event_loop()
25+
self.loop = RubiconEventLoop()
2626

2727
def tearDown(self):
28-
asyncio.set_event_loop_policy(None)
28+
if sys.version_info < (3, 14):
29+
asyncio.set_event_loop_policy(None)
2930
self.loop.close()
3031

3132
def test_run_until_complete(self):
@@ -59,11 +60,11 @@ def test_run_forever(self):
5960

6061
class AsyncCallTests(unittest.TestCase):
6162
def setUp(self):
62-
asyncio.set_event_loop_policy(EventLoopPolicy())
63-
self.loop = asyncio.new_event_loop()
63+
self.loop = RubiconEventLoop()
6464

6565
def tearDown(self):
66-
asyncio.set_event_loop_policy(None)
66+
if sys.version_info < (3, 14):
67+
asyncio.set_event_loop_policy(None)
6768
self.loop.close()
6869

6970
def test_call_soon(self):
@@ -158,11 +159,11 @@ async def echo_client(message):
158159

159160
class AsyncSubprocessTests(unittest.TestCase):
160161
def setUp(self):
161-
asyncio.set_event_loop_policy(EventLoopPolicy())
162-
self.loop = asyncio.new_event_loop()
162+
self.loop = RubiconEventLoop()
163163

164164
def tearDown(self):
165-
asyncio.set_event_loop_policy(None)
165+
if sys.version_info < (3, 14):
166+
asyncio.set_event_loop_policy(None)
166167
self.loop.close()
167168

168169
def test_subprocess(self):

0 commit comments

Comments
 (0)