Repository: enthought/traits-futures

Issue:
Allow event_loop to be specified when creating AsyncioContext
Currently the `AsyncioContext` uses `asyncio.get_event_loop` to get the event loop to use. Instead, it should be possible to pass in an event loop (and use `asyncio.get_event_loop` as a fallback if no event loop is specified).

This would also allow us to go back to avoiding test interactions via the event loop (see #330), but in a cleaner way than before: we can create a dedicated event loop for  each test and avoid touching the global state at all - we wouldn't use `set_event_loop` to set the event loop associated to the thread.

Discussion:
mdickinson: > There's a remaining example that still uses `get_event_loop`, but I'll address that separately.

On second thoughts, this PR leaves that example actually broken: it simply hangs when run, so that should be fixed as part of this PR. This may require a bit of reworking.

mdickinson: > that should be fixed as part of this PR. This may require a bit of reworking.

Done. Ready for review.
mdickinson: CI failure was due to out-of-date copyright years; should be fixed in #494.

Patch:
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 674089b..4480ebc 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -20,6 +20,8 @@ Features
 
 * Support Qt6-based toolkits PyQt6 and PySide6. (This is dependent on
   corresponding support in Pyface.) (#488)
+* Allow an ``asyncio`` event loop to be specified when creating an
+  instance of ``AsyncioEventLoop``. (#492)
 
 Changes
 ~~~~~~~
@@ -28,6 +30,14 @@ Changes
   entry point group have been removed. Their behaviour was ill-defined, and
   dependent on ``asyncio.get_event_loop``, which is deprecated in Python.
   (#490)
+* ``AsyncioEventLoop`` now creates a new ``asyncio`` event loop using
+  ``asyncio.new_event_loop`` if no event loop is explicitly passed in, instead
+  of trying to get the currently-set event loop using the (now deprecated)
+  ``asyncio.get_event_loop`` function. A new ``AsyncioEventLoop.close`` method
+  is available to close the event loop owned by ``AsyncioEventLoop``.
+  Moreover, in a future version of Traits Futures it will be required to
+  pass an explicit event loop. Instantiating an ``AsyncioEventLoop`` without
+  an ``asyncio`` event loop is deprecated. (#492)
 
 Documentation
 ~~~~~~~~~~~~~
diff --git a/docs/source/guide/examples/headless.py b/docs/source/guide/examples/headless.py
index ad9b983..136b318 100644
--- a/docs/source/guide/examples/headless.py
+++ b/docs/source/guide/examples/headless.py
@@ -14,6 +14,7 @@ Running Traits Futures without a GUI, using the asyncio event loop.
 
 import asyncio
 import random
+import sys
 
 from traits_futures.api import (
     AsyncioEventLoop,
@@ -47,9 +48,13 @@ async def future_wrapper(traits_future):
         traits_future = event.object
         asyncio_future.set_result(traits_future.result)
 
-    # Once we can assume a minimum Python version of 3.7, this should
-    # be changed to use get_running_event_loop instead of get_event_loop.
-    asyncio_future = asyncio.get_event_loop().create_future()
+    if sys.version_info < (3, 7):
+        # We want to use get_running_loop, but it's new in Python 3.7.
+        # This branch can be dropped once we can assume a minimum Python
+        # version of 3.7.
+        asyncio_future = asyncio.get_event_loop().create_future()
+    else:
+        asyncio_future = asyncio.get_running_loop().create_future()
 
     traits_future.observe(set_result, "done")
 
@@ -64,9 +69,11 @@ def print_progress(event):
 
 
 if __name__ == "__main__":
-    traits_executor = TraitsExecutor(event_loop=AsyncioEventLoop())
+    asyncio_event_loop = asyncio.new_event_loop()
+    traits_executor = TraitsExecutor(
+        event_loop=AsyncioEventLoop(event_loop=asyncio_event_loop)
+    )
     traits_future = submit_iteration(traits_executor, approximate_pi)
     traits_future.observe(print_progress, "result_event")
 
-    # For Python 3.7 and later, just use asyncio.run.
-    asyncio.get_event_loop().run_until_complete(future_wrapper(traits_future))
+    asyncio_event_loop.run_until_complete(future_wrapper(traits_future))
diff --git a/traits_futures/asyncio/event_loop.py b/traits_futures/asyncio/event_loop.py
index ee2f981..3830d7e 100644
--- a/traits_futures/asyncio/event_loop.py
+++ b/traits_futures/asyncio/event_loop.py
@@ -9,9 +9,10 @@
 # Thanks for using Enthought open source!
 
 """
-IEventLoop implementation for the main-thread asyncio event loop.
+IEventLoop implementation wrapping an asyncio event loop.
 """
 import asyncio
+import warnings
 
 from traits_futures.asyncio.event_loop_helper import EventLoopHelper
 from traits_futures.asyncio.pingee import Pingee
@@ -21,11 +22,36 @@ from traits_futures.i_event_loop import IEventLoop
 @IEventLoop.register
 class AsyncioEventLoop:
     """
-    IEventLoop implementation for the main-thread asyncio event loop.
+    IEventLoop implementation wrapping an asyncio event loop.
+
+    Parameters
+    ----------
+    event_loop : asyncio.AbstractEventLoop, optional
+        The asyncio event loop to wrap. If not provided, a new
+        event loop will be created and used.
     """
 
-    def __init__(self):
-        self._event_loop = asyncio.get_event_loop()
+    def __init__(self, *, event_loop=None):
+        own_event_loop = event_loop is None
+        if own_event_loop:
+            warnings.warn(
+                (
+                    "The event_loop parameter to AsyncioEventLoop will "
+                    "become required in a future version of Traits Futures"
+                ),
+                DeprecationWarning,
+            )
+            event_loop = asyncio.new_event_loop()
+
+        self._own_event_loop = own_event_loop
+        self._event_loop = event_loop
+
+    def close(self):
+        """
+        Free any resources allocated by this object.
+        """
+        if self._own_event_loop:
+            self._event_loop.close()
 
     def pingee(self, on_ping):
         """


Test patch:
diff --git a/traits_futures/asyncio/tests/test_asyncio_event_loop.py b/traits_futures/asyncio/tests/test_asyncio_event_loop.py
index 03f8468..8265ed8 100644
--- a/traits_futures/asyncio/tests/test_asyncio_event_loop.py
+++ b/traits_futures/asyncio/tests/test_asyncio_event_loop.py
@@ -12,7 +12,7 @@
 Tests for the asyncio event loop.
 """
 
-
+import asyncio
 import unittest
 
 from traits_futures.asyncio.event_loop import AsyncioEventLoop
@@ -20,6 +20,35 @@ from traits_futures.tests.i_event_loop_tests import IEventLoopTests
 
 
 class TestAsyncioEventLoop(IEventLoopTests, unittest.TestCase):
+    def event_loop_factory(self):
+        """
+        Factory for the event loop.
+
+        Returns
+        -------
+        event_loop: IEventLoop
+        """
+        asyncio_event_loop = asyncio.new_event_loop()
+        self.addCleanup(asyncio_event_loop.close)
+        return AsyncioEventLoop(event_loop=asyncio_event_loop)
+
+    def test_asyncio_event_loop_closed(self):
+        with self.assertWarns(DeprecationWarning):
+            event_loop = AsyncioEventLoop()
+        # Dig out the underlying asyncio event loop.
+        asyncio_event_loop = event_loop._event_loop
+        self.assertFalse(asyncio_event_loop.is_closed())
+        event_loop.close()
+        self.assertTrue(asyncio_event_loop.is_closed())
 
-    #: Factory for instances of the event loop.
-    event_loop_factory = AsyncioEventLoop
+    def test_creation_from_asyncio_event_loop(self):
+        asyncio_event_loop = asyncio.new_event_loop()
+        event_loop = AsyncioEventLoop(event_loop=asyncio_event_loop)
+        self.assertEqual(event_loop._event_loop, asyncio_event_loop)
+        try:
+            self.assertFalse(asyncio_event_loop.is_closed())
+            # Closing our wrapper shouldn't close the asyncio event loop.
+            event_loop.close()
+            self.assertFalse(asyncio_event_loop.is_closed())
+        finally:
+            asyncio_event_loop.close()
diff --git a/traits_futures/asyncio/tests/test_event_loop_helper.py b/traits_futures/asyncio/tests/test_event_loop_helper.py
index 5c5e3d3..3525223 100644
--- a/traits_futures/asyncio/tests/test_event_loop_helper.py
+++ b/traits_futures/asyncio/tests/test_event_loop_helper.py
@@ -12,6 +12,7 @@
 Tests for the asyncio implementation of IEventLoopHelper.
 """
 
+import asyncio
 import unittest
 
 from traits_futures.asyncio.event_loop import AsyncioEventLoop
@@ -21,6 +22,14 @@ from traits_futures.tests.i_event_loop_helper_tests import (
 
 
 class TestEventLoopHelper(IEventLoopHelperTests, unittest.TestCase):
+    def event_loop_factory(self):
+        """
+        Factory for the event loop.
 
-    #: Zero-parameter callable that creates a suitable IEventLoop instance.
-    event_loop_factory = AsyncioEventLoop
+        Returns
+        -------
+        event_loop: IEventLoop
+        """
+        asyncio_event_loop = asyncio.new_event_loop()
+        self.addCleanup(asyncio_event_loop.close)
+        return AsyncioEventLoop(event_loop=asyncio_event_loop)
diff --git a/traits_futures/asyncio/tests/test_pingee.py b/traits_futures/asyncio/tests/test_pingee.py
index bdfd3cf..302cdb1 100644
--- a/traits_futures/asyncio/tests/test_pingee.py
+++ b/traits_futures/asyncio/tests/test_pingee.py
@@ -12,6 +12,7 @@
 Tests for the asyncio implementations of IPingee and IPinger.
 """
 
+import asyncio
 import unittest
 
 from traits_futures.asyncio.event_loop import AsyncioEventLoop
@@ -20,5 +21,14 @@ from traits_futures.tests.i_pingee_tests import IPingeeTests
 
 
 class TestPingee(TestAssistant, IPingeeTests, unittest.TestCase):
+    def event_loop_factory(self):
+        """
+        Factory for the event loop.
 
-    event_loop_factory = AsyncioEventLoop
+        Returns
+        -------
+        event_loop: IEventLoop
+        """
+        asyncio_event_loop = asyncio.new_event_loop()
+        self.addCleanup(asyncio_event_loop.close)
+        return AsyncioEventLoop(event_loop=asyncio_event_loop)
diff --git a/traits_futures/testing/test_assistant.py b/traits_futures/testing/test_assistant.py
index 97495c5..06af3d4 100644
--- a/traits_futures/testing/test_assistant.py
+++ b/traits_futures/testing/test_assistant.py
@@ -13,6 +13,8 @@ Test support, providing the ability to run the event loop from within tests.
 """
 
 
+import asyncio
+
 from traits.api import Bool, HasStrictTraits
 
 from traits_futures.asyncio.event_loop import AsyncioEventLoop
@@ -44,10 +46,17 @@ class TestAssistant:
     Most of the logic is devolved to a toolkit-specific EventLoopHelper class.
     """
 
-    #: Factory for the event loop. This should be a zero-argument callable
-    #: that provides an IEventLoop instance. Override in subclasses to
-    #: run tests with a particular toolkit.
-    event_loop_factory = AsyncioEventLoop
+    def event_loop_factory(self):
+        """
+        Factory for the event loop.
+
+        Returns
+        -------
+        event_loop: IEventLoop
+        """
+        asyncio_event_loop = asyncio.new_event_loop()
+        self.addCleanup(asyncio_event_loop.close)
+        return AsyncioEventLoop(event_loop=asyncio_event_loop)
 
     def setUp(self):
         self._event_loop = self.event_loop_factory()