summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Doc/library/asyncio-runner.rst121
-rw-r--r--Doc/library/asyncio-task.rst37
-rw-r--r--Doc/library/asyncio.rst1
-rw-r--r--Lib/asyncio/runners.py124
-rw-r--r--Lib/test/test_asyncio/test_runners.py133
-rw-r--r--Lib/unittest/async_case.py70
-rw-r--r--Misc/NEWS.d/next/Library/2022-03-18-22-46-18.bpo-47062.RNc99_.rst1
7 files changed, 381 insertions, 106 deletions
diff --git a/Doc/library/asyncio-runner.rst b/Doc/library/asyncio-runner.rst
new file mode 100644
index 0000000..2f4de9e
--- /dev/null
+++ b/Doc/library/asyncio-runner.rst
@@ -0,0 +1,121 @@
+.. currentmodule:: asyncio
+
+
+=======
+Runners
+=======
+
+**Source code:** :source:`Lib/asyncio/runners.py`
+
+
+This section outlines high-level asyncio primitives to run asyncio code.
+
+They are built on top of an :ref:`event loop <asyncio-event-loop>` with the aim
+to simplify async code usage for common wide-spread scenarios.
+
+.. contents::
+ :depth: 1
+ :local:
+
+
+
+Running an asyncio Program
+==========================
+
+.. function:: run(coro, *, debug=None)
+
+ Execute the :term:`coroutine` *coro* and return the result.
+
+ This function runs the passed coroutine, taking care of
+ managing the asyncio event loop, *finalizing asynchronous
+ generators*, and closing the threadpool.
+
+ This function cannot be called when another asyncio event loop is
+ running in the same thread.
+
+ If *debug* is ``True``, the event loop will be run in debug mode. ``False`` disables
+ debug mode explicitly. ``None`` is used to respect the global
+ :ref:`asyncio-debug-mode` settings.
+
+ This function always creates a new event loop and closes it at
+ the end. It should be used as a main entry point for asyncio
+ programs, and should ideally only be called once.
+
+ Example::
+
+ async def main():
+ await asyncio.sleep(1)
+ print('hello')
+
+ asyncio.run(main())
+
+ .. versionadded:: 3.7
+
+ .. versionchanged:: 3.9
+ Updated to use :meth:`loop.shutdown_default_executor`.
+
+ .. versionchanged:: 3.10
+
+ *debug* is ``None`` by default to respect the global debug mode settings.
+
+
+Runner context manager
+======================
+
+.. class:: Runner(*, debug=None, factory=None)
+
+ A context manager that simplifies *multiple* async function calls in the same
+ context.
+
+ Sometimes several top-level async functions should be called in the same :ref:`event
+ loop <asyncio-event-loop>` and :class:`contextvars.Context`.
+
+ If *debug* is ``True``, the event loop will be run in debug mode. ``False`` disables
+ debug mode explicitly. ``None`` is used to respect the global
+ :ref:`asyncio-debug-mode` settings.
+
+ *factory* could be used for overriding the loop creation.
+ :func:`asyncio.new_event_loop` is used if ``None``.
+
+ Basically, :func:`asyncio.run()` example can be rewritten with the runner usage::
+
+ async def main():
+ await asyncio.sleep(1)
+ print('hello')
+
+ with asyncio.Runner() as runner:
+ runner.run(main())
+
+ .. versionadded:: 3.11
+
+ .. method:: run(coro, *, context=None)
+
+ Run a :term:`coroutine <coroutine>` *coro* in the embedded loop.
+
+ Return the coroutine's result or raise its exception.
+
+ An optional keyword-only *context* argument allows specifying a
+ custom :class:`contextvars.Context` for the *coro* to run in.
+ The runner's default context is used if ``None``.
+
+ This function cannot be called when another asyncio event loop is
+ running in the same thread.
+
+ .. method:: close()
+
+ Close the runner.
+
+ Finalize asynchronous generators, shutdown default executor, close the event loop
+ and release embedded :class:`contextvars.Context`.
+
+ .. method:: get_loop()
+
+ Return the event loop associated with the runner instance.
+
+ .. note::
+
+ :class:`Runner` uses the lazy initialization strategy, its constructor doesn't
+ initialize underlying low-level structures.
+
+ Embedded *loop* and *context* are created at the :keyword:`with` body entering
+ or the first call of :meth:`run` or :meth:`get_loop`.
diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst
index 21a4cb582..c104ac5 100644
--- a/Doc/library/asyncio-task.rst
+++ b/Doc/library/asyncio-task.rst
@@ -204,43 +204,6 @@ A good example of a low-level function that returns a Future object
is :meth:`loop.run_in_executor`.
-Running an asyncio Program
-==========================
-
-.. function:: run(coro, *, debug=False)
-
- Execute the :term:`coroutine` *coro* and return the result.
-
- This function runs the passed coroutine, taking care of
- managing the asyncio event loop, *finalizing asynchronous
- generators*, and closing the threadpool.
-
- This function cannot be called when another asyncio event loop is
- running in the same thread.
-
- If *debug* is ``True``, the event loop will be run in debug mode.
-
- This function always creates a new event loop and closes it at
- the end. It should be used as a main entry point for asyncio
- programs, and should ideally only be called once.
-
- Example::
-
- async def main():
- await asyncio.sleep(1)
- print('hello')
-
- asyncio.run(main())
-
- .. versionadded:: 3.7
-
- .. versionchanged:: 3.9
- Updated to use :meth:`loop.shutdown_default_executor`.
-
- .. note::
- The source code for ``asyncio.run()`` can be found in
- :source:`Lib/asyncio/runners.py`.
-
Creating Tasks
==============
diff --git a/Doc/library/asyncio.rst b/Doc/library/asyncio.rst
index 94a8532..8b3a060 100644
--- a/Doc/library/asyncio.rst
+++ b/Doc/library/asyncio.rst
@@ -67,6 +67,7 @@ Additionally, there are **low-level** APIs for
:caption: High-level APIs
:maxdepth: 1
+ asyncio-runner.rst
asyncio-task.rst
asyncio-stream.rst
asyncio-sync.rst
diff --git a/Lib/asyncio/runners.py b/Lib/asyncio/runners.py
index 9a5e9a4..975509c 100644
--- a/Lib/asyncio/runners.py
+++ b/Lib/asyncio/runners.py
@@ -1,10 +1,112 @@
-__all__ = 'run',
+__all__ = ('Runner', 'run')
+import contextvars
+import enum
from . import coroutines
from . import events
from . import tasks
+class _State(enum.Enum):
+ CREATED = "created"
+ INITIALIZED = "initialized"
+ CLOSED = "closed"
+
+
+class Runner:
+ """A context manager that controls event loop life cycle.
+
+ The context manager always creates a new event loop,
+ allows to run async functions inside it,
+ and properly finalizes the loop at the context manager exit.
+
+ If debug is True, the event loop will be run in debug mode.
+ If factory is passed, it is used for new event loop creation.
+
+ asyncio.run(main(), debug=True)
+
+ is a shortcut for
+
+ with asyncio.Runner(debug=True) as runner:
+ runner.run(main())
+
+ The run() method can be called multiple times within the runner's context.
+
+ This can be useful for interactive console (e.g. IPython),
+ unittest runners, console tools, -- everywhere when async code
+ is called from existing sync framework and where the preferred single
+ asyncio.run() call doesn't work.
+
+ """
+
+ # Note: the class is final, it is not intended for inheritance.
+
+ def __init__(self, *, debug=None, factory=None):
+ self._state = _State.CREATED
+ self._debug = debug
+ self._factory = factory
+ self._loop = None
+ self._context = None
+
+ def __enter__(self):
+ self._lazy_init()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ def close(self):
+ """Shutdown and close event loop."""
+ if self._state is not _State.INITIALIZED:
+ return
+ try:
+ loop = self._loop
+ _cancel_all_tasks(loop)
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ loop.run_until_complete(loop.shutdown_default_executor())
+ finally:
+ loop.close()
+ self._loop = None
+ self._state = _State.CLOSED
+
+ def get_loop(self):
+ """Return embedded event loop."""
+ self._lazy_init()
+ return self._loop
+
+ def run(self, coro, *, context=None):
+ """Run a coroutine inside the embedded event loop."""
+ if not coroutines.iscoroutine(coro):
+ raise ValueError("a coroutine was expected, got {!r}".format(coro))
+
+ if events._get_running_loop() is not None:
+ # fail fast with short traceback
+ raise RuntimeError(
+ "Runner.run() cannot be called from a running event loop")
+
+ self._lazy_init()
+
+ if context is None:
+ context = self._context
+ task = self._loop.create_task(coro, context=context)
+ return self._loop.run_until_complete(task)
+
+ def _lazy_init(self):
+ if self._state is _State.CLOSED:
+ raise RuntimeError("Runner is closed")
+ if self._state is _State.INITIALIZED:
+ return
+ if self._factory is None:
+ self._loop = events.new_event_loop()
+ else:
+ self._loop = self._factory()
+ if self._debug is not None:
+ self._loop.set_debug(self._debug)
+ self._context = contextvars.copy_context()
+ self._state = _State.INITIALIZED
+
+
+
def run(main, *, debug=None):
"""Execute the coroutine and return the result.
@@ -30,26 +132,12 @@ def run(main, *, debug=None):
asyncio.run(main())
"""
if events._get_running_loop() is not None:
+ # fail fast with short traceback
raise RuntimeError(
"asyncio.run() cannot be called from a running event loop")
- if not coroutines.iscoroutine(main):
- raise ValueError("a coroutine was expected, got {!r}".format(main))
-
- loop = events.new_event_loop()
- try:
- events.set_event_loop(loop)
- if debug is not None:
- loop.set_debug(debug)
- return loop.run_until_complete(main)
- finally:
- try:
- _cancel_all_tasks(loop)
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.run_until_complete(loop.shutdown_default_executor())
- finally:
- events.set_event_loop(None)
- loop.close()
+ with Runner(debug=debug) as runner:
+ return runner.run(main)
def _cancel_all_tasks(loop):
diff --git a/Lib/test/test_asyncio/test_runners.py b/Lib/test/test_asyncio/test_runners.py
index 1122736..2919412 100644
--- a/Lib/test/test_asyncio/test_runners.py
+++ b/Lib/test/test_asyncio/test_runners.py
@@ -1,4 +1,7 @@
import asyncio
+import contextvars
+import gc
+import re
import unittest
from unittest import mock
@@ -186,5 +189,135 @@ class RunTests(BaseTest):
self.assertFalse(spinner.ag_running)
+class RunnerTests(BaseTest):
+
+ def test_non_debug(self):
+ with asyncio.Runner(debug=False) as runner:
+ self.assertFalse(runner.get_loop().get_debug())
+
+ def test_debug(self):
+ with asyncio.Runner(debug=True) as runner:
+ self.assertTrue(runner.get_loop().get_debug())
+
+ def test_custom_factory(self):
+ loop = mock.Mock()
+ with asyncio.Runner(factory=lambda: loop) as runner:
+ self.assertIs(runner.get_loop(), loop)
+
+ def test_run(self):
+ async def f():
+ await asyncio.sleep(0)
+ return 'done'
+
+ with asyncio.Runner() as runner:
+ self.assertEqual('done', runner.run(f()))
+ loop = runner.get_loop()
+
+ with self.assertRaisesRegex(
+ RuntimeError,
+ "Runner is closed"
+ ):
+ runner.get_loop()
+
+ self.assertTrue(loop.is_closed())
+
+ def test_run_non_coro(self):
+ with asyncio.Runner() as runner:
+ with self.assertRaisesRegex(
+ ValueError,
+ "a coroutine was expected"
+ ):
+ runner.run(123)
+
+ def test_run_future(self):
+ with asyncio.Runner() as runner:
+ with self.assertRaisesRegex(
+ ValueError,
+ "a coroutine was expected"
+ ):
+ fut = runner.get_loop().create_future()
+ runner.run(fut)
+
+ def test_explicit_close(self):
+ runner = asyncio.Runner()
+ loop = runner.get_loop()
+ runner.close()
+ with self.assertRaisesRegex(
+ RuntimeError,
+ "Runner is closed"
+ ):
+ runner.get_loop()
+
+ self.assertTrue(loop.is_closed())
+
+ def test_double_close(self):
+ runner = asyncio.Runner()
+ loop = runner.get_loop()
+
+ runner.close()
+ self.assertTrue(loop.is_closed())
+
+ # the second call is no-op
+ runner.close()
+ self.assertTrue(loop.is_closed())
+
+ def test_second_with_block_raises(self):
+ ret = []
+
+ async def f(arg):
+ ret.append(arg)
+
+ runner = asyncio.Runner()
+ with runner:
+ runner.run(f(1))
+
+ with self.assertRaisesRegex(
+ RuntimeError,
+ "Runner is closed"
+ ):
+ with runner:
+ runner.run(f(2))
+
+ self.assertEqual([1], ret)
+
+ def test_run_keeps_context(self):
+ cvar = contextvars.ContextVar("cvar", default=-1)
+
+ async def f(val):
+ old = cvar.get()
+ await asyncio.sleep(0)
+ cvar.set(val)
+ return old
+
+ async def get_context():
+ return contextvars.copy_context()
+
+ with asyncio.Runner() as runner:
+ self.assertEqual(-1, runner.run(f(1)))
+ self.assertEqual(1, runner.run(f(2)))
+
+ self.assertEqual({cvar: 2}, dict(runner.run(get_context())))
+
+ def test_recursine_run(self):
+ async def g():
+ pass
+
+ async def f():
+ runner.run(g())
+
+ with asyncio.Runner() as runner:
+ with self.assertWarnsRegex(
+ RuntimeWarning,
+ "coroutine .+ was never awaited",
+ ):
+ with self.assertRaisesRegex(
+ RuntimeError,
+ re.escape(
+ "Runner.run() cannot be called from a running event loop"
+ ),
+ ):
+ runner.run(f())
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/Lib/unittest/async_case.py b/Lib/unittest/async_case.py
index 25adc3d..85b938f 100644
--- a/Lib/unittest/async_case.py
+++ b/Lib/unittest/async_case.py
@@ -34,7 +34,7 @@ class IsolatedAsyncioTestCase(TestCase):
def __init__(self, methodName='runTest'):
super().__init__(methodName)
- self._asyncioTestLoop = None
+ self._asyncioRunner = None
self._asyncioTestContext = contextvars.copy_context()
async def asyncSetUp(self):
@@ -75,76 +75,44 @@ class IsolatedAsyncioTestCase(TestCase):
self._callMaybeAsync(function, *args, **kwargs)
def _callAsync(self, func, /, *args, **kwargs):
- assert self._asyncioTestLoop is not None, 'asyncio test loop is not initialized'
+ assert self._asyncioRunner is not None, 'asyncio runner is not initialized'
assert inspect.iscoroutinefunction(func), f'{func!r} is not an async function'
- task = self._asyncioTestLoop.create_task(
+ return self._asyncioRunner.run(
func(*args, **kwargs),
- context=self._asyncioTestContext,
+ context=self._asyncioTestContext
)
- return self._asyncioTestLoop.run_until_complete(task)
def _callMaybeAsync(self, func, /, *args, **kwargs):
- assert self._asyncioTestLoop is not None, 'asyncio test loop is not initialized'
+ assert self._asyncioRunner is not None, 'asyncio runner is not initialized'
if inspect.iscoroutinefunction(func):
- task = self._asyncioTestLoop.create_task(
+ return self._asyncioRunner.run(
func(*args, **kwargs),
context=self._asyncioTestContext,
)
- return self._asyncioTestLoop.run_until_complete(task)
else:
return self._asyncioTestContext.run(func, *args, **kwargs)
- def _setupAsyncioLoop(self):
- assert self._asyncioTestLoop is None, 'asyncio test loop already initialized'
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- loop.set_debug(True)
- self._asyncioTestLoop = loop
+ def _setupAsyncioRunner(self):
+ assert self._asyncioRunner is None, 'asyncio runner is already initialized'
+ runner = asyncio.Runner(debug=True)
+ self._asyncioRunner = runner
- def _tearDownAsyncioLoop(self):
- assert self._asyncioTestLoop is not None, 'asyncio test loop is not initialized'
- loop = self._asyncioTestLoop
- self._asyncioTestLoop = None
-
- try:
- # cancel all tasks
- to_cancel = asyncio.all_tasks(loop)
- if not to_cancel:
- return
-
- for task in to_cancel:
- task.cancel()
-
- loop.run_until_complete(
- asyncio.gather(*to_cancel, return_exceptions=True))
-
- for task in to_cancel:
- if task.cancelled():
- continue
- if task.exception() is not None:
- loop.call_exception_handler({
- 'message': 'unhandled exception during test shutdown',
- 'exception': task.exception(),
- 'task': task,
- })
- # shutdown asyncgens
- loop.run_until_complete(loop.shutdown_asyncgens())
- finally:
- asyncio.set_event_loop(None)
- loop.close()
+ def _tearDownAsyncioRunner(self):
+ runner = self._asyncioRunner
+ runner.close()
def run(self, result=None):
- self._setupAsyncioLoop()
+ self._setupAsyncioRunner()
try:
return super().run(result)
finally:
- self._tearDownAsyncioLoop()
+ self._tearDownAsyncioRunner()
def debug(self):
- self._setupAsyncioLoop()
+ self._setupAsyncioRunner()
super().debug()
- self._tearDownAsyncioLoop()
+ self._tearDownAsyncioRunner()
def __del__(self):
- if self._asyncioTestLoop is not None:
- self._tearDownAsyncioLoop()
+ if self._asyncioRunner is not None:
+ self._tearDownAsyncioRunner()
diff --git a/Misc/NEWS.d/next/Library/2022-03-18-22-46-18.bpo-47062.RNc99_.rst b/Misc/NEWS.d/next/Library/2022-03-18-22-46-18.bpo-47062.RNc99_.rst
new file mode 100644
index 0000000..7d5bfc1
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-03-18-22-46-18.bpo-47062.RNc99_.rst
@@ -0,0 +1 @@
+Implement :class:`asyncio.Runner` context manager.