Skip to content

Commit

Permalink
Add a compatibility layer for the asyncio changes in Python 3.14.0a4. (
Browse files Browse the repository at this point in the history
…#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.
  • Loading branch information
freakboy3742 authored Jan 29, 2025
1 parent ac461d6 commit d6db22a
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 94 deletions.
1 change: 1 addition & 0 deletions changes/557.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The interface with EventLoopPolicy was updated to account for the eventual deprecation of that API in Python.
1 change: 1 addition & 0 deletions changes/557.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``RubiconEventLoop()`` is now exposed as an interface for creating a CoreFoundation compatible event loop.
1 change: 1 addition & 0 deletions changes/557.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
32 changes: 11 additions & 21 deletions docs/how-to/async.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,16 @@ However, you can't have two event loops running at the same time, so you need
a way to integrate the two. Luckily, :mod:`asyncio` provides a way to customize
it's event loop so it can be integrated with other event sources.

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

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

# Import the Event Loop Policy
from rubicon.objc.eventloop import EventLoopPolicy

# Install the event loop policy
asyncio.set_event_loop_policy(EventLoopPolicy())
# Import the Event Loop
from rubicon.objc.eventloop import RubiconEventLoop

# Create an event loop, and run it!
loop = asyncio.new_event_loop()
loop = RubiconEventLoop()
loop.run_forever()

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

# Import the Event Loop Policy and lifecycle
from rubicon.objc.eventloop import EventLoopPolicy, CocoaLifecycle

# Install the event loop policy
asyncio.set_event_loop_policy(EventLoopPolicy())
# Import the Event Loop and lifecycle
from rubicon.objc.eventloop import RubiconEventLoop, CocoaLifecycle

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

# Create an event loop, and run it, using the NSApplication!
loop = asyncio.new_event_loop()
loop = RubiconEventLoop()
loop.run_forever(lifecycle=CocoaLifecycle(app))

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

# Import the Event Loop Policy and lifecycle
from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle

# Install the event loop policy
asyncio.set_event_loop_policy(EventLoopPolicy())
# Import the Event Loop and lifecycle
from rubicon.objc.eventloop import RubiconEventLoop, iOSLifecycle

# Create an event loop, and run it, using the UIApplication!
loop = asyncio.new_event_loop()
loop = RubiconEventLoop()
loop.run_forever(lifecycle=iOSLifecycle())

Again, this will run "forever" -- until either ``loop.stop()`` is called, or
Expand Down
181 changes: 118 additions & 63 deletions src/rubicon/objc/eventloop.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""PEP 3156 event loop based on CoreFoundation."""

import contextvars
import inspect
import sys
import threading
import warnings
from asyncio import (
DefaultEventLoopPolicy,
coroutines,
events,
tasks,
Expand All @@ -17,14 +18,33 @@
from .types import CFIndex

if sys.version_info < (3, 14):
from asyncio import SafeChildWatcher
from asyncio import (
AbstractEventLoopPolicy,
DefaultEventLoopPolicy,
SafeChildWatcher,
set_event_loop_policy,
)
elif sys.version_info < (3, 16):
# Python 3.14 finalized the deprecation of SafeChildWatcher. There's no
# replacement API; the feature can be removed.
#
# Python 3.14 also started the deprecation of event loop policies, to be
# finalized in Python 3.16; there was some symbol renaming to assist in
# making the deprecation visible. See
# https://github.com/python/cpython/issues/127949 for details.
from asyncio import (
_AbstractEventLoopPolicy as AbstractEventLoopPolicy,
_DefaultEventLoopPolicy as DefaultEventLoopPolicy,
)

__all__ = [
"EventLoopPolicy",
"CocoaLifecycle",
"RubiconEventLoop",
"iOSLifecycle",
]


###########################################################################
# CoreFoundation types and constants needed for async handlers
###########################################################################
Expand Down Expand Up @@ -421,7 +441,7 @@ def remove_writer(self, fd):
######################################################################
def _check_not_coroutine(self, callback, name):
"""Check whether the given callback is a coroutine or not."""
if coroutines.iscoroutine(callback) or coroutines.iscoroutinefunction(callback):
if coroutines.iscoroutine(callback) or inspect.iscoroutinefunction(callback):
raise TypeError(f"coroutines cannot be used with {name}()")

def is_running(self):
Expand Down Expand Up @@ -637,7 +657,8 @@ def _set_lifecycle(self, lifecycle):
"You can't set a lifecycle on a loop that's already running."
)
self._lifecycle = lifecycle
self._policy._lifecycle = lifecycle
if sys.version_info < (3, 14):
self._policy._lifecycle = lifecycle

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


class EventLoopPolicy(events.AbstractEventLoopPolicy):
"""Rubicon event loop policy.
if sys.version_info < (3, 16):

In this policy, each thread has its own event loop. However, we only
automatically create an event loop by default for the main thread;
other threads by default have no event loop.
"""
class EventLoopPolicy(AbstractEventLoopPolicy):
"""Rubicon event loop policy.
def __init__(self):
self._lifecycle = None
self._default_loop = None
self._watcher_lock = threading.Lock()
self._watcher = None
self._policy = DefaultEventLoopPolicy()
self._policy.new_event_loop = self.new_event_loop
self.get_event_loop = self._policy.get_event_loop
self.set_event_loop = self._policy.set_event_loop
In this policy, each thread has its own event loop. However, we only
automatically create an event loop by default for the main thread; other
threads by default have no event loop.
def new_event_loop(self):
"""Create a new event loop and return it."""
if (
not self._default_loop
and threading.current_thread() == threading.main_thread()
):
loop = self.get_default_loop()
else:
**DEPRECATED** - Python 3.14 deprecated the concept of manually creating
EventLoopPolicies. Create and use a ``RubiconEventLoop`` instance instead of
installing an event loop policy and calling ``asyncio.new_event_loop()``.
"""

def __init__(self):
warnings.warn(
"Custom EventLoopPolicy instances have been deprecated by Python 3.14. "
"Create and use a `RubiconEventLoop` instance directly instead of "
"installing an event loop policy and calling `asyncio.new_event_loop()`.",
DeprecationWarning,
stacklevel=2,
)

self._lifecycle = None
self._default_loop = None
if sys.version_info < (3, 14):
self._watcher_lock = threading.Lock()
self._watcher = None
self._policy = DefaultEventLoopPolicy()
self._policy.new_event_loop = self.new_event_loop
self.get_event_loop = self._policy.get_event_loop
self.set_event_loop = self._policy.set_event_loop

def new_event_loop(self):
"""Create a new event loop and return it."""
if (
not self._default_loop
and threading.current_thread() == threading.main_thread()
):
loop = self.get_default_loop()
else:
loop = CFEventLoop(self._lifecycle)
loop._policy = self

return loop

def get_default_loop(self):
"""Get the default event loop."""
if not self._default_loop:
self._default_loop = self._new_default_loop()
return self._default_loop

def _new_default_loop(self):
loop = CFEventLoop(self._lifecycle)
loop._policy = self
loop._policy = self
return loop

return loop
if sys.version_info < (3, 14):

def get_default_loop(self):
"""Get the default event loop."""
if not self._default_loop:
self._default_loop = self._new_default_loop()
return self._default_loop
def _init_watcher(self):
with events._lock:
if self._watcher is None: # pragma: no branch
self._watcher = SafeChildWatcher()
if threading.current_thread() == threading.main_thread():
self._watcher.attach_loop(self._default_loop)

def _new_default_loop(self):
loop = CFEventLoop(self._lifecycle)
loop._policy = self
return loop
def get_child_watcher(self):
"""Get the watcher for child processes.
if sys.version_info < (3, 14):
If not yet set, a :class:`~asyncio.SafeChildWatcher` object is
automatically created.
def _init_watcher(self):
with events._lock:
if self._watcher is None: # pragma: no branch
self._watcher = SafeChildWatcher()
if threading.current_thread() == threading.main_thread():
self._watcher.attach_loop(self._default_loop)
.. note::
Child watcher support was removed in Python 3.14
"""
if self._watcher is None:
self._init_watcher()

def get_child_watcher(self):
"""Get the watcher for child processes.
return self._watcher

If not yet set, a :class:`~asyncio.SafeChildWatcher` object is
automatically created.
def set_child_watcher(self, watcher):
"""Set the watcher for child processes.
.. note::
Child watcher support was removed in Python 3.14
"""
if self._watcher is None:
self._init_watcher()
.. note::
Child watcher support was removed in Python 3.14
"""
if self._watcher is not None:
self._watcher.close()

return self._watcher
self._watcher = watcher

def set_child_watcher(self, watcher):
"""Set the watcher for child processes.

.. note::
Child watcher support was removed in Python 3.14
"""
if self._watcher is not None:
self._watcher.close()
if sys.version_info < (3, 14):

def RubiconEventLoop():
"""Create a new Rubicon CFEventLoop instance."""
# If they're using RubiconEventLoop(), they've done the necessary adaptation.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"^Custom EventLoopPolicy instances have been deprecated by Python 3.14",
category=DeprecationWarning,
)
policy = EventLoopPolicy()
set_event_loop_policy(policy)
return policy.new_event_loop()

self._watcher = watcher
else:
RubiconEventLoop = CFEventLoop


class CFLifecycle:
Expand Down
21 changes: 11 additions & 10 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from __future__ import annotations

import asyncio
import sys
import time
import unittest

from rubicon.objc.eventloop import EventLoopPolicy
from rubicon.objc.eventloop import RubiconEventLoop


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

class AsyncRunTests(unittest.TestCase):
def setUp(self):
asyncio.set_event_loop_policy(EventLoopPolicy())
self.loop = asyncio.new_event_loop()
self.loop = RubiconEventLoop()

def tearDown(self):
asyncio.set_event_loop_policy(None)
if sys.version_info < (3, 14):
asyncio.set_event_loop_policy(None)
self.loop.close()

def test_run_until_complete(self):
Expand Down Expand Up @@ -59,11 +60,11 @@ def test_run_forever(self):

class AsyncCallTests(unittest.TestCase):
def setUp(self):
asyncio.set_event_loop_policy(EventLoopPolicy())
self.loop = asyncio.new_event_loop()
self.loop = RubiconEventLoop()

def tearDown(self):
asyncio.set_event_loop_policy(None)
if sys.version_info < (3, 14):
asyncio.set_event_loop_policy(None)
self.loop.close()

def test_call_soon(self):
Expand Down Expand Up @@ -158,11 +159,11 @@ async def echo_client(message):

class AsyncSubprocessTests(unittest.TestCase):
def setUp(self):
asyncio.set_event_loop_policy(EventLoopPolicy())
self.loop = asyncio.new_event_loop()
self.loop = RubiconEventLoop()

def tearDown(self):
asyncio.set_event_loop_policy(None)
if sys.version_info < (3, 14):
asyncio.set_event_loop_policy(None)
self.loop.close()

def test_subprocess(self):
Expand Down

0 comments on commit d6db22a

Please sign in to comment.