From 2a40279691d67250c15018e42cda65a870e1a039 Mon Sep 17 00:00:00 2001 From: fjetter Date: Tue, 8 Jun 2021 19:50:34 +0200 Subject: [PATCH 01/25] Add timeout options --- pytest_asyncio/plugin.py | 45 ++++++++++++++++++++++++++++++++++++++-- tests/test_simple.py | 12 +++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 7665ff4d..dd8ee5be 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -34,6 +34,13 @@ def pytest_configure(config): ) +def pytest_addoption(parser): + group = parser.getgroup("asyncio") + help_ = "Timeout in seconds after which the test coroutine shall be cancelled and marked as failed" + group.addoption("--asyncio-timeout", dest="asyncio_timeout", type=float, help=help_) + parser.addini("asyncio_timeout", help=help_) + + @pytest.mark.tryfirst def pytest_pycollect_makeitem(collector, name, obj): """A pytest hook to collect asyncio coroutines.""" @@ -163,6 +170,33 @@ async def setup(): yield +def get_timeout(obj): + """ + Get the timeout for the provided test function. + + Priority: + + * Marker keyword arguments `asyncio_timeout` and `timeout` + * CLI + * INI file + """ + marker = obj.get_closest_marker("asyncio") + timeout = marker.kwargs.get("asyncio_timeout", marker.kwargs.get("timeout")) + + if not timeout: + timeout = obj._request.config.getvalue("asyncio_timeout") + if not timeout: + timeout = obj._request.config.getini("asyncio_timeout") + + if timeout: + try: + return float(timeout) + except: + raise ValueError( + f"Invalid timeout (asyncio_timeout) provided. Got {timeout} but expected a float-like." + ) + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_pyfunc_call(pyfuncitem): """ @@ -170,19 +204,23 @@ def pytest_pyfunc_call(pyfuncitem): function call. """ if "asyncio" in pyfuncitem.keywords: + timeout = get_timeout(pyfuncitem) if getattr(pyfuncitem.obj, "is_hypothesis_test", False): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem.obj.hypothesis.inner_test, _loop=pyfuncitem.funcargs["event_loop"], + timeout=timeout, ) else: pyfuncitem.obj = wrap_in_sync( - pyfuncitem.obj, _loop=pyfuncitem.funcargs["event_loop"] + pyfuncitem.obj, + _loop=pyfuncitem.funcargs["event_loop"], + timeout=timeout, ) yield -def wrap_in_sync(func, _loop): +def wrap_in_sync(func, _loop, timeout): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -190,7 +228,10 @@ def wrap_in_sync(func, _loop): def inner(**kwargs): coro = func(**kwargs) if coro is not None: + if timeout: + coro = asyncio.wait_for(coro, timeout=timeout) task = asyncio.ensure_future(coro, loop=_loop) + try: _loop.run_until_complete(task) except BaseException: diff --git a/tests/test_simple.py b/tests/test_simple.py index 854faaf3..ba2c2c41 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -145,3 +145,15 @@ async def test_no_warning_on_skip(): def test_async_close_loop(event_loop): event_loop.close() return "ok" + + +@pytest.mark.asyncio(timeout=0.1) +@pytest.mark.xfail(strict=True) +async def test_timeout(): + await asyncio.sleep(1) + + +@pytest.mark.asyncio(asyncio_timeout=0.1) +@pytest.mark.xfail(strict=True) +async def test_timeout_2(): + await asyncio.sleep(1) From c68680df6bc9d505b4834c37bdc24c4bd9f6f0bc Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 13:27:19 +0200 Subject: [PATCH 02/25] Update test_simple.py --- tests/test_simple.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_simple.py b/tests/test_simple.py index ba2c2c41..4e77c5f2 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -148,12 +148,6 @@ def test_async_close_loop(event_loop): @pytest.mark.asyncio(timeout=0.1) -@pytest.mark.xfail(strict=True) +@pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) async def test_timeout(): await asyncio.sleep(1) - - -@pytest.mark.asyncio(asyncio_timeout=0.1) -@pytest.mark.xfail(strict=True) -async def test_timeout_2(): - await asyncio.sleep(1) From e534a2158a45751a8fbcc431f12bde0247dcaea9 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 13:43:31 +0200 Subject: [PATCH 03/25] Fix tests --- pytest_asyncio/plugin.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index cb03b428..48f08d2a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -41,6 +41,11 @@ class Mode(str, enum.Enum): auto-handling is disabled but pytest_asyncio.fixture usage is not enforced """ +ASYNCIO_TIMEOUT_HELP = """\ +Timeout in seconds after which the test coroutine \ +shall be cancelled and marked as failed, 0 for no-timeout +""" + def pytest_addoption(parser, pluginmanager): group = parser.getgroup("asyncio") @@ -57,6 +62,10 @@ def pytest_addoption(parser, pluginmanager): type="string", default="legacy", ) + group.addoption("--asyncio-timeout", dest="asyncio_timeout", type=float, help=ASYNCIO_TIMEOUT_HELP, default=None) + parser.addini("asyncio_timeout", type="string", help="default value for --asyncio-timeout",default=0) + + def fixture(fixture_function=None, **kwargs): @@ -127,13 +136,6 @@ def _issue_warning_captured(warning, hook, *, stacklevel=1): ) -def pytest_addoption(parser): - group = parser.getgroup("asyncio") - help_ = "Timeout in seconds after which the test coroutine shall be cancelled and marked as failed" - group.addoption("--asyncio-timeout", dest="asyncio_timeout", type=float, help=help_) - parser.addini("asyncio_timeout", help=help_) - - @pytest.mark.tryfirst def pytest_pycollect_makeitem(collector, name, obj): """A pytest hook to collect asyncio coroutines.""" @@ -317,13 +319,16 @@ def get_timeout(obj): if not timeout: timeout = obj._request.config.getini("asyncio_timeout") - if timeout: - try: - return float(timeout) - except: - raise ValueError( - f"Invalid timeout (asyncio_timeout) provided. Got {timeout} but expected a float-like." - ) + if not timeout: + return None + + try: + return float(timeout) + except (TypeError, ValueError): + raise ValueError( + f"Invalid asyncio timeout {timeout!r} provided, " + "a float-like value is expected." + ) from None @pytest.hookimpl(tryfirst=True, hookwrapper=True) From 092396e75249b287fbc072f13ac4b97c06a2d10d Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 14:30:00 +0200 Subject: [PATCH 04/25] Reformat --- pytest_asyncio/plugin.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 48f08d2a..ba2b0b92 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -62,10 +62,19 @@ def pytest_addoption(parser, pluginmanager): type="string", default="legacy", ) - group.addoption("--asyncio-timeout", dest="asyncio_timeout", type=float, help=ASYNCIO_TIMEOUT_HELP, default=None) - parser.addini("asyncio_timeout", type="string", help="default value for --asyncio-timeout",default=0) - - + group.addoption( + "--asyncio-timeout", + dest="asyncio_timeout", + type=float, + help=ASYNCIO_TIMEOUT_HELP, + default=None, + ) + parser.addini( + "asyncio_timeout", + type="string", + help="default value for --asyncio-timeout", + default=0, + ) def fixture(fixture_function=None, **kwargs): From 83d5ad9fcc64986d757556077ef8c6e8aefbe0e2 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 14:30:57 +0200 Subject: [PATCH 05/25] rename --- pytest_asyncio/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index ba2b0b92..4ee6fa1f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -310,7 +310,7 @@ async def setup(): yield -def get_timeout(obj): +def _get_timeout(obj): """ Get the timeout for the provided test function. @@ -348,7 +348,7 @@ def pytest_pyfunc_call(pyfuncitem): Wraps marked tests in a synchronous function where the wrapped test coroutine is executed in an event loop. """ if "asyncio" in pyfuncitem.keywords: - timeout = get_timeout(pyfuncitem) + timeout = _get_timeout(pyfuncitem) if getattr(pyfuncitem.obj, "is_hypothesis_test", False): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem.obj.hypothesis.inner_test, From 5bb0f6ae3f638099513b7620ef95d6251f3da896 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 15:07:59 +0200 Subject: [PATCH 06/25] Add a test --- tests/test_simple.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_simple.py b/tests/test_simple.py index a08be690..dae3392b 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -246,3 +246,9 @@ def test_async_close_loop(event_loop): @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) async def test_timeout(): await asyncio.sleep(1) + + +@pytest.mark.asyncio(timeout="abc") +@pytest.mark.xfail(strict=True, raises=ValueError) +async def test_timeout_not_numeric(): + await asyncio.sleep(1) From 1009cc047e6b58f77cc6156d48356536a48afbb5 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 9 Jan 2022 15:24:53 +0200 Subject: [PATCH 07/25] Add tests --- tests/test_simple.py | 12 -------- tests/test_timeout.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 tests/test_timeout.py diff --git a/tests/test_simple.py b/tests/test_simple.py index dae3392b..31204b6c 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -240,15 +240,3 @@ async def test_no_warning_on_skip(): def test_async_close_loop(event_loop): event_loop.close() return "ok" - - -@pytest.mark.asyncio(timeout=0.1) -@pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) -async def test_timeout(): - await asyncio.sleep(1) - - -@pytest.mark.asyncio(timeout="abc") -@pytest.mark.xfail(strict=True, raises=ValueError) -async def test_timeout_not_numeric(): - await asyncio.sleep(1) diff --git a/tests/test_timeout.py b/tests/test_timeout.py new file mode 100644 index 00000000..b4d285b1 --- /dev/null +++ b/tests/test_timeout.py @@ -0,0 +1,64 @@ +import asyncio +from textwrap import dedent + +import pytest + +pytest_plugins = "pytester" + + +@pytest.mark.asyncio(timeout=0.01) +@pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) +async def test_timeout(): + await asyncio.sleep(1) + + +@pytest.mark.asyncio(timeout=0) +async def test_timeout_disabled(): + await asyncio.sleep(0.01) + + +@pytest.mark.asyncio(timeout="abc") +@pytest.mark.xfail(strict=True, raises=ValueError) +async def test_timeout_not_numeric(): + await asyncio.sleep(1) + + +def test_timeout_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) + async def test_a(): + await asyncio.sleep(1) + """ + ) + ) + result = pytester.runpytest("--asyncio-timeout=0.01", "--asyncio-mode=strict") + result.assert_outcomes(xfailed=1) + + +def test_timeout_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) + async def test_a(): + await asyncio.sleep(1) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_timeout = 0.01\n") + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(xfailed=1) From ba587acd8b6d159d13b040aef08e831c8935033a Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 10 Jan 2022 20:25:03 +0200 Subject: [PATCH 08/25] Work on --- README.rst | 6 ++++ pytest_asyncio/plugin.py | 30 +++++++++++++----- tests/test_timeout.py | 67 +++++++++++++++++++++++++++++++--------- 3 files changed, 82 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index 0b35000b..d0152d93 100644 --- a/README.rst +++ b/README.rst @@ -247,6 +247,12 @@ automatically to *async* test functions. .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules +Timeout protection +------------------ + +Sometime tests can work much slowly than expected or even hang. + + Note about unittest ------------------- diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 24c862b8..9d94ead3 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -207,7 +207,8 @@ def pytest_fixture_setup(fixturedef, request): yield return - config = request.node.config + node = request.node + config = node.config asyncio_mode = _get_asyncio_mode(config) if not _has_explicit_asyncio_mark(func): @@ -253,13 +254,18 @@ def wrapper(*args, **kwargs): gen_obj = generator(*args, **kwargs) async def setup(): - res = await gen_obj.__anext__() - return res + node._asyncio_task = asyncio.get_running_loop() + try: + res = await gen_obj.__anext__() + return res + finally: + node._asyncio_task = None def finalizer(): """Yield again, to finalize.""" async def async_finalizer(): + node._asyncio_task = asyncio.get_running_loop() try: await gen_obj.__anext__() except StopAsyncIteration: @@ -268,6 +274,8 @@ async def async_finalizer(): msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) + finally: + node._asyncio_task = None loop.run_until_complete(async_finalizer()) @@ -287,8 +295,12 @@ def wrapper(*args, **kwargs): ) async def setup(): - res = await coro(*args, **kwargs) - return res + node._asyncio_task = asyncio.get_running_loop() + try: + res = await coro(*args, **kwargs) + return res + finally: + node._asyncio_task = None return loop.run_until_complete(setup()) @@ -338,12 +350,14 @@ def pytest_pyfunc_call(pyfuncitem): timeout = _get_timeout(pyfuncitem) if getattr(pyfuncitem.obj, "is_hypothesis_test", False): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( + pyfuncitem, pyfuncitem.obj.hypothesis.inner_test, _loop=pyfuncitem.funcargs["event_loop"], timeout=timeout, ) else: pyfuncitem.obj = wrap_in_sync( + pyfuncitem, pyfuncitem.obj, _loop=pyfuncitem.funcargs["event_loop"], timeout=timeout, @@ -351,7 +365,7 @@ def pytest_pyfunc_call(pyfuncitem): yield -def wrap_in_sync(func, _loop, timeout): +def wrap_in_sync(node, func, _loop, timeout): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -367,7 +381,7 @@ def inner(**kwargs): if coro is not None: if timeout: coro = asyncio.wait_for(coro, timeout=timeout) - task = asyncio.ensure_future(coro, loop=_loop) + node._asyncio_task = task = asyncio.ensure_future(coro, loop=_loop) try: _loop.run_until_complete(task) @@ -378,6 +392,8 @@ def inner(**kwargs): if task.done() and not task.cancelled(): task.exception() raise + finally: + node._asyncio_task = None inner._raw_test_func = func return inner diff --git a/tests/test_timeout.py b/tests/test_timeout.py index b4d285b1..e40c5946 100644 --- a/tests/test_timeout.py +++ b/tests/test_timeout.py @@ -1,26 +1,65 @@ -import asyncio from textwrap import dedent -import pytest - pytest_plugins = "pytester" -@pytest.mark.asyncio(timeout=0.01) -@pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) -async def test_timeout(): - await asyncio.sleep(1) +def test_timeout_ok(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio(timeout=0.01) + @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) + async def test_a(): + await asyncio.sleep(1) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(xfailed=1) + + +def test_timeout_disabled(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio(timeout=0) + async def test_a(): + await asyncio.sleep(0.01) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.mark.asyncio(timeout=0) -async def test_timeout_disabled(): - await asyncio.sleep(0.01) +def test_timeout_not_numeric(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + pytest_plugins = 'pytest_asyncio' -@pytest.mark.asyncio(timeout="abc") -@pytest.mark.xfail(strict=True, raises=ValueError) -async def test_timeout_not_numeric(): - await asyncio.sleep(1) + @pytest.mark.asyncio(timeout="abc") + @pytest.mark.xfail(strict=True, raises=ValueError) + async def test_a(): + await asyncio.sleep(0.01) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(xfailed=1) def test_timeout_cmdline(pytester): From 229d3ba1226f1002f8ab15bb1f1df6d13b37f0c9 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 21 Jan 2022 13:37:49 +0200 Subject: [PATCH 09/25] Work on --- pytest_asyncio/_runner.py | 7 ++--- pytest_asyncio/plugin.py | 1 + tests/test_timeout.py | 55 ++++++--------------------------------- 3 files changed, 13 insertions(+), 50 deletions(-) diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index c65f7778..114ca8a0 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -29,13 +29,14 @@ def run_test(self, coro: Awaitable[None]) -> None: raise def set_timer(self, timeout: Union[int, float]) -> None: - assert self._timeout_hande is None + if self._timeout_hande is not None: + self._timeout_hande.cancel() self._timeout_reached = False self._timeout_hande = self._loop.call_later(timeout, self._on_timeout) def cancel_timer(self) -> None: - assert self._timeout_hande is not None - self._timeout_hande.cancel() + if self._timeout_hande is not None: + self._timeout_hande.cancel() self._timeout_reached = False self._timeout_hande = None diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c12a4cbc..6639c515 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -294,6 +294,7 @@ def pytest_fixture_setup( if fixturedef.argname == "event_loop": outcome = yield loop = outcome.get_result() + print("\ninstall runner", request.node, id(request.node), id(loop)) _install_runner(request.node, loop) policy = asyncio.get_event_loop_policy() try: diff --git a/tests/test_timeout.py b/tests/test_timeout.py index e40c5946..9754cda7 100644 --- a/tests/test_timeout.py +++ b/tests/test_timeout.py @@ -10,10 +10,11 @@ def test_timeout_ok(pytester): import asyncio import pytest - pytest_plugins = 'pytest_asyncio' + pytest_plugins = ['pytest_asyncio'] - @pytest.mark.asyncio(timeout=0.01) @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) + @pytest.mark.timeout(0.01) + @pytest.mark.asyncio async def test_a(): await asyncio.sleep(1) """ @@ -30,9 +31,10 @@ def test_timeout_disabled(pytester): import asyncio import pytest - pytest_plugins = 'pytest_asyncio' + pytest_plugins = ['pytest_asyncio'] - @pytest.mark.asyncio(timeout=0) + @pytest.mark.timeout(0) + @pytest.mark.asyncio async def test_a(): await asyncio.sleep(0.01) """ @@ -42,26 +44,6 @@ async def test_a(): result.assert_outcomes(passed=1) -def test_timeout_not_numeric(pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = 'pytest_asyncio' - - @pytest.mark.asyncio(timeout="abc") - @pytest.mark.xfail(strict=True, raises=ValueError) - async def test_a(): - await asyncio.sleep(0.01) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(xfailed=1) - - def test_timeout_cmdline(pytester): pytester.makepyfile( dedent( @@ -69,7 +51,7 @@ def test_timeout_cmdline(pytester): import asyncio import pytest - pytest_plugins = 'pytest_asyncio' + pytest_plugins = ['pytest_asyncio'] @pytest.mark.asyncio @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) @@ -78,26 +60,5 @@ async def test_a(): """ ) ) - result = pytester.runpytest("--asyncio-timeout=0.01", "--asyncio-mode=strict") - result.assert_outcomes(xfailed=1) - - -def test_timeout_cfg(pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = 'pytest_asyncio' - - @pytest.mark.asyncio - @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) - async def test_a(): - await asyncio.sleep(1) - """ - ) - ) - pytester.makefile(".ini", pytest="[pytest]\nasyncio_timeout = 0.01\n") - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--timeout=0.01", "--asyncio-mode=strict") result.assert_outcomes(xfailed=1) From 31ab61b39a834d233ad98cb10e249cab312dbd10 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 09:32:20 +0200 Subject: [PATCH 10/25] Work on proxies --- pytest_asyncio/_runner.py | 117 +++++++++++++++++--------------------- pytest_asyncio/plugin.py | 54 +++--------------- tests/test_timeout.py | 64 --------------------- 3 files changed, 59 insertions(+), 176 deletions(-) delete mode 100644 tests/test_timeout.py diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index 114ca8a0..103d44b3 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -1,5 +1,5 @@ import asyncio -from typing import Awaitable, TypeVar, Union +from typing import Awaitable, List, TypeVar import pytest @@ -7,14 +7,47 @@ class Runner: - def __init__(self, loop: asyncio.AbstractEventLoop) -> None: - self._loop = loop - self._task = None - self._timeout_hande = None - self._timeout_reached = False + __slots__ = ("_loop", "_node", "_children") - def run(self, coro: Awaitable[_R]) -> _R: - return self._loop.run_until_complete(self._async_wrapper(coro)) + def __init__(self, node: pytest.Item, loop: asyncio.AbstractEventLoop) -> None: + self._node = node + # children nodes that uses asyncio + # the list can be reset if the current node re-assigns the loop + self._children: List[Runner] = [] + self._set_loop(loop) + + @classmethod + def install( + cls, request: pytest.FixtureRequest, loop: asyncio.AbstractEventLoop + ) -> None: + node = request.node + print("\n+++++++++", id(node)) + if hasattr(request, "param"): + print("@@@@@@@@@", request.param) + runner = getattr(node, "_asyncio_runner", None) + if runner is None: + runner = cls(node, loop) + node._asyncio_runner = runner + else: + # parametrized non-function scope loop was recalculated + # with other params or precessors + runner._set_loop(loop) + request.addfinalizer(runner._uninstall) + + @classmethod + def get(cls, node: pytest.Item) -> "Runner": + print("!!!!!!!!!", id(node), type(node)) + runner = getattr(node, "_asyncio_runner", None) + if runner is not None: + return runner + parent_node = node.parent + if parent_node is None: + # should never happen if pytest_fixture_setup works correctly + raise RuntimeError("Cannot find a node with installed loop") + parent_runner = cls.get(parent_node) + runner = cls(node, parent_runner._loop) + node._asyncio_runner = runner + return runner def run_test(self, coro: Awaitable[None]) -> None: task = asyncio.ensure_future(coro, loop=self._loop) @@ -28,62 +61,16 @@ def run_test(self, coro: Awaitable[None]) -> None: task.exception() raise - def set_timer(self, timeout: Union[int, float]) -> None: - if self._timeout_hande is not None: - self._timeout_hande.cancel() - self._timeout_reached = False - self._timeout_hande = self._loop.call_later(timeout, self._on_timeout) - - def cancel_timer(self) -> None: - if self._timeout_hande is not None: - self._timeout_hande.cancel() - self._timeout_reached = False - self._timeout_hande = None - - async def _async_wrapper(self, coro: Awaitable[_R]) -> _R: - if self._timeout_reached: - # timeout can happen in a gap between tasks execution, - # it should be handled anyway - raise asyncio.TimeoutError() - task = asyncio.current_task() - assert self._task is None - self._task = task - try: - return await coro - except asyncio.CancelledError: - if self._timeout_reached: - raise asyncio.TimeoutError() - finally: - self._task = None - - def _on_timeout(self) -> None: - # the plugin is optional, - # pytest-asyncio should work fine without pytest-timeout - # That's why the lazy import is required here - import pytest_timeout - - if pytest_timeout.is_debugging(): - return - self._timeout_reached = True - if self._task is not None: - self._task.cancel() - - -def _install_runner(item: pytest.Item, loop: asyncio.AbstractEventLoop) -> None: - item._pytest_asyncio_runner = Runner(loop) + def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + # cleanup children runners, recreate them on the next run + for child in self._children: + child._uninstall() + self._children.clear() + def _uninstall(self) -> None: + print("\n---------", id(self._node)) + delattr(self._node, "_asyncio_runner") -def _get_runner(item: pytest.Item) -> Runner: - runner = getattr(item, "_pytest_asyncio_runner", None) - if runner is not None: - return runner - else: - parent = item.parent - if parent is not None: - parent_runner = _get_runner(parent) - runner = item._pytest_asyncio_runner = Runner(parent_runner._loop) - return runner - else: # pragma: no cover - # can happen only if the plugin is broken and no event_loop fixture - # dependency was installed. - raise RuntimeError(f"There is no event_loop associated with {item}") + def run(self, coro: Awaitable[_R]) -> _R: + return self._loop.run_until_complete(coro) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 6639c515..4acd02dd 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -8,7 +8,6 @@ import sys import warnings from typing import ( - TYPE_CHECKING, Any, AsyncIterator, Awaitable, @@ -25,9 +24,8 @@ ) import pytest -from pluggy import PluginValidationError -from ._runner import Runner, _get_runner, _install_runner +from ._runner import Runner if sys.version_info >= (3, 8): from typing import Literal @@ -35,17 +33,6 @@ from typing_extensions import Literal -if TYPE_CHECKING: - from pytest_timeout import Settings - - -try: - pass - - HAS_TIMEOUT_SUPPORT = True -except ImportError: - HAS_TIMEOUT_SUPPORT = False - _R = TypeVar("_R") _ScopeName = Literal["session", "package", "module", "class", "function"] @@ -292,10 +279,11 @@ def pytest_fixture_setup( ) -> Optional[object]: """Adjust the event loop policy when an event loop is produced.""" if fixturedef.argname == "event_loop": + # a marker for future runners lookup + # The lookup doesn't go deeper than a node with this marker set. outcome = yield loop = outcome.get_result() - print("\ninstall runner", request.node, id(request.node), id(loop)) - _install_runner(request.node, loop) + Runner.install(request, loop) policy = asyncio.get_event_loop_policy() try: old_loop = policy.get_event_loop() @@ -349,9 +337,9 @@ def pytest_fixture_setup( def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + runner = Runner.get(request.node) gen_obj = generator(*args, **kwargs) - runner = _get_runner(request.node) async def setup(): res = await gen_obj.__anext__() @@ -385,7 +373,7 @@ async def async_finalizer(): def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) - runner = _get_runner(request.node) + runner = Runner.get(request.node) async def setup(): res = await coro(*args, **kwargs) @@ -406,7 +394,7 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: where the wrapped test coroutine is executed in an event loop. """ if "asyncio" in pyfuncitem.keywords: - runner = _get_runner(pyfuncitem) + runner = Runner.get(pyfuncitem) if _is_hypothesis_test(pyfuncitem.obj): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem.obj.hypothesis.inner_test, @@ -468,34 +456,6 @@ def pytest_runtest_setup(item: pytest.Item) -> None: ) -if HAS_TIMEOUT_SUPPORT: - # Install hooks only if pytest-timeout is installed - try: - - @pytest.mark.tryfirst - def pytest_timeout_set_timer( - item: pytest.Item, settings: "Settings" - ) -> Optional[object]: - if item.get_closest_marker("asyncio") is None: - return None - runner = _get_runner(item) - runner.set_timer(settings.timeout) - return True - - @pytest.mark.tryfirst - def pytest_timeout_cancel_timer(item: pytest.Item) -> Optional[object]: - if item.get_closest_marker("asyncio") is None: - return None - runner = _get_runner(item) - runner.cancel_timer() - return True - - except PluginValidationError: # pragma: no cover - raise RuntimeError( - "pytest-asyncio requires pytest-timeout>=2.1.0, please upgrade" - ) - - @pytest.fixture def event_loop(request: "pytest.FixtureRequest") -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" diff --git a/tests/test_timeout.py b/tests/test_timeout.py deleted file mode 100644 index 9754cda7..00000000 --- a/tests/test_timeout.py +++ /dev/null @@ -1,64 +0,0 @@ -from textwrap import dedent - -pytest_plugins = "pytester" - - -def test_timeout_ok(pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = ['pytest_asyncio'] - - @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) - @pytest.mark.timeout(0.01) - @pytest.mark.asyncio - async def test_a(): - await asyncio.sleep(1) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(xfailed=1) - - -def test_timeout_disabled(pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = ['pytest_asyncio'] - - @pytest.mark.timeout(0) - @pytest.mark.asyncio - async def test_a(): - await asyncio.sleep(0.01) - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) - - -def test_timeout_cmdline(pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytest_plugins = ['pytest_asyncio'] - - @pytest.mark.asyncio - @pytest.mark.xfail(strict=True, raises=asyncio.TimeoutError) - async def test_a(): - await asyncio.sleep(1) - """ - ) - ) - result = pytester.runpytest("--timeout=0.01", "--asyncio-mode=strict") - result.assert_outcomes(xfailed=1) From 8e5ac2ac16d17318486bcb9c8fd4166500b38137 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 10:15:15 +0200 Subject: [PATCH 11/25] Work on --- pytest_asyncio/_runner.py | 13 ++++++++++--- pytest_asyncio/plugin.py | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index 103d44b3..87e6dad1 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -22,8 +22,8 @@ def install( ) -> None: node = request.node print("\n+++++++++", id(node)) - if hasattr(request, "param"): - print("@@@@@@@@@", request.param) + # if hasattr(request, "param"): + # print("@@@@@@@@@", request.param) runner = getattr(node, "_asyncio_runner", None) if runner is None: runner = cls(node, loop) @@ -32,7 +32,14 @@ def install( # parametrized non-function scope loop was recalculated # with other params or precessors runner._set_loop(loop) - request.addfinalizer(runner._uninstall) + + @classmethod + def uninstall(cls, request: pytest.FixtureRequest) -> None: + node = request.node + print("#########", id(node), type(node)) + runner = getattr(node, "_asyncio_runner", None) + assert runner is not None + runner._uninstall @classmethod def get(cls, node: pytest.Item) -> "Runner": diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 4acd02dd..72eb5be7 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -271,6 +271,7 @@ def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) - new_loop = policy.new_event_loop() # Replace existing event loop # Ensure subsequent calls to get_event_loop() succeed policy.set_event_loop(new_loop) + Runner.uninstall(request) @pytest.hookimpl(hookwrapper=True) @@ -278,6 +279,8 @@ def pytest_fixture_setup( fixturedef: FixtureDef, request: SubRequest ) -> Optional[object]: """Adjust the event loop policy when an event loop is produced.""" + if hasattr(request, "param"): + print("@@@@@@@@@", fixturedef.argname, request.param) if fixturedef.argname == "event_loop": # a marker for future runners lookup # The lookup doesn't go deeper than a node with this marker set. From 1b146225bb7951b21140bc9686fcbf0eef018620 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 10:22:31 +0200 Subject: [PATCH 12/25] Make basic tests work --- pytest_asyncio/_runner.py | 11 +++-------- pytest_asyncio/plugin.py | 12 +++++++++--- tests/test_asyncio_fixture.py | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index 87e6dad1..a39a7885 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -21,29 +21,25 @@ def install( cls, request: pytest.FixtureRequest, loop: asyncio.AbstractEventLoop ) -> None: node = request.node - print("\n+++++++++", id(node)) - # if hasattr(request, "param"): - # print("@@@@@@@@@", request.param) runner = getattr(node, "_asyncio_runner", None) if runner is None: runner = cls(node, loop) node._asyncio_runner = runner else: # parametrized non-function scope loop was recalculated - # with other params or precessors + # with other params of precessors runner._set_loop(loop) + request.addfinalizer(runner._uninstall) @classmethod def uninstall(cls, request: pytest.FixtureRequest) -> None: node = request.node - print("#########", id(node), type(node)) runner = getattr(node, "_asyncio_runner", None) assert runner is not None - runner._uninstall + runner._uninstall() @classmethod def get(cls, node: pytest.Item) -> "Runner": - print("!!!!!!!!!", id(node), type(node)) runner = getattr(node, "_asyncio_runner", None) if runner is not None: return runner @@ -76,7 +72,6 @@ def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._children.clear() def _uninstall(self) -> None: - print("\n---------", id(self._node)) delattr(self._node, "_asyncio_runner") def run(self, coro: Awaitable[_R]) -> _R: diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 72eb5be7..610d66cf 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -235,6 +235,7 @@ class FixtureStripper: """Include additional Fixture, and then strip them""" EVENT_LOOP = "event_loop" + REQUEST = "request" def __init__(self, fixturedef: FixtureDef) -> None: self.fixturedef = fixturedef @@ -271,7 +272,6 @@ def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) - new_loop = policy.new_event_loop() # Replace existing event loop # Ensure subsequent calls to get_event_loop() succeed policy.set_event_loop(new_loop) - Runner.uninstall(request) @pytest.hookimpl(hookwrapper=True) @@ -279,8 +279,6 @@ def pytest_fixture_setup( fixturedef: FixtureDef, request: SubRequest ) -> Optional[object]: """Adjust the event loop policy when an event loop is produced.""" - if hasattr(request, "param"): - print("@@@@@@@@@", fixturedef.argname, request.param) if fixturedef.argname == "event_loop": # a marker for future runners lookup # The lookup doesn't go deeper than a node with this marker set. @@ -337,9 +335,13 @@ def pytest_fixture_setup( fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) + fixture_stripper.add(FixtureStripper.REQUEST) def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + request = fixture_stripper.get_and_strip_from( + FixtureStripper.REQUEST, kwargs + ) runner = Runner.get(request.node) gen_obj = generator(*args, **kwargs) @@ -373,9 +375,13 @@ async def async_finalizer(): fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) + fixture_stripper.add(FixtureStripper.REQUEST) def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + request = fixture_stripper.get_and_strip_from( + FixtureStripper.REQUEST, kwargs + ) runner = Runner.get(request.node) async def setup(): diff --git a/tests/test_asyncio_fixture.py b/tests/test_asyncio_fixture.py index cfe10479..2ac71dc2 100644 --- a/tests/test_asyncio_fixture.py +++ b/tests/test_asyncio_fixture.py @@ -36,6 +36,6 @@ async def fixture_with_params(request): @pytest.mark.asyncio -async def test_fixture_with_params(fixture_with_params): +async def test_fixture_with_params(request, fixture_with_params): await asyncio.sleep(0) assert fixture_with_params % 2 == 0 From b13b55749b3e71360f202131177d524c3328e72a Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 10:27:47 +0200 Subject: [PATCH 13/25] Add a comment --- pytest_asyncio/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 610d66cf..c61c0ab8 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -339,6 +339,7 @@ def pytest_fixture_setup( def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + # Late binding is crucial here request = fixture_stripper.get_and_strip_from( FixtureStripper.REQUEST, kwargs ) @@ -379,6 +380,7 @@ async def async_finalizer(): def wrapper(*args, **kwargs): fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + # Late binding is crucial here request = fixture_stripper.get_and_strip_from( FixtureStripper.REQUEST, kwargs ) From 3363b90d77a27e1e53e031e75b41fe2808de6618 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 10:29:18 +0200 Subject: [PATCH 14/25] More comments --- pytest_asyncio/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c61c0ab8..0b35a0c4 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -334,6 +334,7 @@ def pytest_fixture_setup( generator = func fixture_stripper = FixtureStripper(fixturedef) + # loop is required for correct fixture dependencies order fixture_stripper.add(FixtureStripper.EVENT_LOOP) fixture_stripper.add(FixtureStripper.REQUEST) @@ -375,6 +376,7 @@ async def async_finalizer(): coro = func fixture_stripper = FixtureStripper(fixturedef) + # loop is required for correct fixture dependencies order fixture_stripper.add(FixtureStripper.EVENT_LOOP) fixture_stripper.add(FixtureStripper.REQUEST) From a8cc151dd20fb1550a25d23556da2823bd787995 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Mon, 24 Jan 2022 12:22:06 +0200 Subject: [PATCH 15/25] Harmonize finalizers --- pytest_asyncio/_runner.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index a39a7885..6582fe0a 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -50,6 +50,7 @@ def get(cls, node: pytest.Item) -> "Runner": parent_runner = cls.get(parent_node) runner = cls(node, parent_runner._loop) node._asyncio_runner = runner + node.addfinalizer(runner._uninstall) return runner def run_test(self, coro: Awaitable[None]) -> None: @@ -64,6 +65,9 @@ def run_test(self, coro: Awaitable[None]) -> None: task.exception() raise + def run(self, coro: Awaitable[_R]) -> _R: + return self._loop.run_until_complete(coro) + def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: self._loop = loop # cleanup children runners, recreate them on the next run @@ -73,6 +77,3 @@ def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: def _uninstall(self) -> None: delattr(self._node, "_asyncio_runner") - - def run(self, coro: Awaitable[_R]) -> _R: - return self._loop.run_until_complete(coro) From 3678a7ff9300c20259512336d5a8b1b6f761e86b Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 25 Jan 2022 10:38:11 +0200 Subject: [PATCH 16/25] Support parametrized event_loop fixture --- pytest_asyncio/plugin.py | 229 +++++++++--------- .../async_fixtures/test_parametrized_loop.py | 30 +++ 2 files changed, 144 insertions(+), 115 deletions(-) create mode 100644 tests/async_fixtures/test_parametrized_loop.py diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 8d9aa980..1cceb949 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -165,7 +165,7 @@ def _set_explicit_asyncio_mark(obj: Any) -> None: def _is_coroutine(obj: Any) -> bool: """Check to see if an object is really an asyncio coroutine.""" - return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj) + return asyncio.iscoroutinefunction(obj) def _is_coroutine_or_asyncgen(obj: Any) -> bool: @@ -198,6 +198,118 @@ def pytest_report_header(config: Config) -> List[str]: return [f"asyncio: mode={mode}"] +def _add_fixture_argnames(config: Config, holder: Set[FixtureDef]) -> None: + asyncio_mode = _get_asyncio_mode(config) + fixturemanager = config.pluginmanager.get_plugin("funcmanage") + for fixtures in fixturemanager._arg2fixturedefs.values(): + for fixturedef in fixtures: + if fixturedef is holder: + continue + func = fixturedef.func + if not _is_coroutine_or_asyncgen(func): + # Nothing to do with a regular fixture function + continue + if not _has_explicit_asyncio_mark(func): + if asyncio_mode == Mode.AUTO: + # Enforce asyncio mode if 'auto' + _set_explicit_asyncio_mark(func) + elif asyncio_mode == Mode.LEGACY: + _set_explicit_asyncio_mark(func) + try: + code = func.__code__ + except AttributeError: + code = func.__func__.__code__ + name = ( + f"" + ) + warnings.warn( + LEGACY_ASYNCIO_FIXTURE.format(name=name), + DeprecationWarning, + ) + + to_add = [] + for name in ("request", "event_loop"): + if name not in fixturedef.argnames: + to_add.append(name) + + if to_add: + fixturedef.argnames += tuple(to_add) + + if inspect.isasyncgenfunction(func): + fixturedef.func = _wrap_asyncgen(func) + elif inspect.iscoroutinefunction(func): + fixturedef.func = _wrap_async(func) + + assert _has_explicit_asyncio_mark(fixturedef.func) + holder.add(fixturedef) + + +def _add_kwargs( + func: Callable[..., Any], + kwargs: Dict[str, Any], + event_loop: asyncio.AbstractEventLoop, + request: SubRequest, +) -> Dict[str, Any]: + sig = inspect.signature(func) + ret = kwargs.copy() + if "request" in sig.parameters: + ret["request"] = request + if "event_loop" in sig.parameters: + ret["event_loop"] = event_loop + return ret + + +def _wrap_asyncgen(func: Callable[..., AsyncIterator[_R]]) -> Callable[..., _R]: + @functools.wraps(func) + def _asyncgen_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ) -> _R: + gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + + async def setup() -> _R: + res = await gen_obj.__anext__() + return res + + def finalizer() -> None: + """Yield again, to finalize.""" + + async def async_finalizer() -> None: + try: + await gen_obj.__anext__() + except StopAsyncIteration: + pass + else: + msg = "Async generator fixture didn't stop." + msg += "Yield only once." + raise ValueError(msg) + + event_loop.run_until_complete(async_finalizer()) + + result = event_loop.run_until_complete(setup()) + request.addfinalizer(finalizer) + return result + + return _asyncgen_fixture_wrapper + + +def _wrap_async(func: Callable[..., Awaitable[_R]]) -> Callable[..., _R]: + @functools.wraps(func) + def _async_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ) -> _R: + async def setup() -> _R: + res = await func(**_add_kwargs(func, kwargs, event_loop, request)) + return res + + return event_loop.run_until_complete(setup()) + + return _async_fixture_wrapper + + +_HOLDER: Set[FixtureDef] = set() + + @pytest.mark.tryfirst def pytest_pycollect_makeitem( collector: Union[pytest.Module, pytest.Class], name: str, obj: object @@ -212,6 +324,7 @@ def pytest_pycollect_makeitem( or _is_hypothesis_test(obj) and _hypothesis_test_wraps_coroutine(obj) ): + _add_fixture_argnames(collector.config, _HOLDER) item = pytest.Function.from_parent(collector, name=name) marker = item.get_closest_marker("asyncio") if marker is not None: @@ -230,31 +343,6 @@ def _hypothesis_test_wraps_coroutine(function: Any) -> bool: return _is_coroutine(function.hypothesis.inner_test) -class FixtureStripper: - """Include additional Fixture, and then strip them""" - - EVENT_LOOP = "event_loop" - - def __init__(self, fixturedef: FixtureDef) -> None: - self.fixturedef = fixturedef - self.to_strip: Set[str] = set() - - def add(self, name: str) -> None: - """Add fixture name to fixturedef - and record in to_strip list (If not previously included)""" - if name in self.fixturedef.argnames: - return - self.fixturedef.argnames += (name,) - self.to_strip.add(name) - - def get_and_strip_from(self, name: str, data_dict: Dict[str, _T]) -> _T: - """Strip name from data, and return value""" - result = data_dict[name] - if name in self.to_strip: - del data_dict[name] - return result - - @pytest.hookimpl(trylast=True) def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) -> None: """Called after fixture teardown""" @@ -291,95 +379,6 @@ def pytest_fixture_setup( policy.set_event_loop(loop) return - func = fixturedef.func - if not _is_coroutine_or_asyncgen(func): - # Nothing to do with a regular fixture function - yield - return - - config = request.node.config - asyncio_mode = _get_asyncio_mode(config) - - if not _has_explicit_asyncio_mark(func): - if asyncio_mode == Mode.AUTO: - # Enforce asyncio mode if 'auto' - _set_explicit_asyncio_mark(func) - elif asyncio_mode == Mode.LEGACY: - _set_explicit_asyncio_mark(func) - try: - code = func.__code__ - except AttributeError: - code = func.__func__.__code__ - name = ( - f"" - ) - warnings.warn( - LEGACY_ASYNCIO_FIXTURE.format(name=name), - DeprecationWarning, - ) - else: - # asyncio_mode is STRICT, - # don't handle fixtures that are not explicitly marked - yield - return - - if inspect.isasyncgenfunction(func): - # This is an async generator function. Wrap it accordingly. - generator = func - - fixture_stripper = FixtureStripper(fixturedef) - fixture_stripper.add(FixtureStripper.EVENT_LOOP) - - def wrapper(*args, **kwargs): - loop = fixture_stripper.get_and_strip_from( - FixtureStripper.EVENT_LOOP, kwargs - ) - - gen_obj = generator(*args, **kwargs) - - async def setup(): - res = await gen_obj.__anext__() - return res - - def finalizer(): - """Yield again, to finalize.""" - - async def async_finalizer(): - try: - await gen_obj.__anext__() - except StopAsyncIteration: - pass - else: - msg = "Async generator fixture didn't stop." - msg += "Yield only once." - raise ValueError(msg) - - loop.run_until_complete(async_finalizer()) - - result = loop.run_until_complete(setup()) - request.addfinalizer(finalizer) - return result - - fixturedef.func = wrapper - elif inspect.iscoroutinefunction(func): - coro = func - - fixture_stripper = FixtureStripper(fixturedef) - fixture_stripper.add(FixtureStripper.EVENT_LOOP) - - def wrapper(*args, **kwargs): - loop = fixture_stripper.get_and_strip_from( - FixtureStripper.EVENT_LOOP, kwargs - ) - - async def setup(): - res = await coro(*args, **kwargs) - return res - - return loop.run_until_complete(setup()) - - fixturedef.func = wrapper yield diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py new file mode 100644 index 00000000..1ffa183f --- /dev/null +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -0,0 +1,30 @@ +import asyncio + +import pytest + +TESTS_COUNT = 0 + + +def teardown_module(): + assert TESTS_COUNT == 4 + + +@pytest.fixture(scope="module", params=[1, 2]) +def event_loop(request): + request.param + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(params=["a", "b"]) +async def fix(request): + await asyncio.sleep(0) + return request.param + + +@pytest.mark.asyncio +async def test_parametrized_loop(fix): + await asyncio.sleep(0) + global TESTS_COUNT + TESTS_COUNT += 1 From 2bd78d87b8d42e6e8a77d6daae2979688c6dde18 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 25 Jan 2022 10:50:17 +0200 Subject: [PATCH 17/25] Add changenote --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 2d00f5b9..7c788475 100644 --- a/README.rst +++ b/README.rst @@ -261,6 +261,7 @@ Changelog ~~~~~~~~~~~~~~~~~~~ - Raise a warning if @pytest.mark.asyncio is applied to non-async function. `#275 `_ +- Support parametrized ``event_loop`` fixture. `#278 `_ 0.17.2 (22-01-17) ~~~~~~~~~~~~~~~~~~~ From 7de97480103c1f64698cfed548756e6b42dc967c Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 25 Jan 2022 10:51:31 +0200 Subject: [PATCH 18/25] Add a comment --- tests/async_fixtures/test_parametrized_loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py index 1ffa183f..2fb8befa 100644 --- a/tests/async_fixtures/test_parametrized_loop.py +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -6,6 +6,7 @@ def teardown_module(): + # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' assert TESTS_COUNT == 4 From b2c83f9812540b715cd37ed32aa16baab27db043 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Tue, 25 Jan 2022 11:28:46 +0200 Subject: [PATCH 19/25] rename --- pytest_asyncio/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 1cceb949..f0374893 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -198,7 +198,7 @@ def pytest_report_header(config: Config) -> List[str]: return [f"asyncio: mode={mode}"] -def _add_fixture_argnames(config: Config, holder: Set[FixtureDef]) -> None: +def _preprocess_async_fixtures(config: Config, holder: Set[FixtureDef]) -> None: asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") for fixtures in fixturemanager._arg2fixturedefs.values(): @@ -324,7 +324,7 @@ def pytest_pycollect_makeitem( or _is_hypothesis_test(obj) and _hypothesis_test_wraps_coroutine(obj) ): - _add_fixture_argnames(collector.config, _HOLDER) + _preprocess_async_fixtures(collector.config, _HOLDER) item = pytest.Function.from_parent(collector, name=name) marker = item.get_closest_marker("asyncio") if marker is not None: From b8735941454cca377f263bb17b2b9879c77b91c3 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 26 Jan 2022 00:29:49 +0200 Subject: [PATCH 20/25] Fix --- pytest_asyncio/_runner.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py index 6582fe0a..c4a4a748 100644 --- a/pytest_asyncio/_runner.py +++ b/pytest_asyncio/_runner.py @@ -1,19 +1,25 @@ import asyncio -from typing import Awaitable, List, TypeVar +import contextlib +from typing import Awaitable, List, Optional, TypeVar +import aioloop_proxy import pytest _R = TypeVar("_R") class Runner: - __slots__ = ("_loop", "_node", "_children") + __slots__ = ("_loop", "_node", "_children", "_loop_proxy_cm") def __init__(self, node: pytest.Item, loop: asyncio.AbstractEventLoop) -> None: self._node = node # children nodes that uses asyncio # the list can be reset if the current node re-assigns the loop self._children: List[Runner] = [] + self._loop_proxy_cm: Optional[ + "contextlib.AbstractContextManager[asyncio.AbstractEventLoop]" + ] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None self._set_loop(loop) @classmethod @@ -69,11 +75,19 @@ def run(self, coro: Awaitable[_R]) -> _R: return self._loop.run_until_complete(coro) def _set_loop(self, loop: asyncio.AbstractEventLoop) -> None: - self._loop = loop + assert loop is not None + if self._loop_proxy_cm is not None: + self._loop_proxy_cm.__exit__(None, None, None) + self._loop_proxy_cm = aioloop_proxy.proxy(loop) + self._loop = self._loop_proxy_cm.__enter__() # cleanup children runners, recreate them on the next run for child in self._children: child._uninstall() self._children.clear() def _uninstall(self) -> None: + if self._loop_proxy_cm is not None: + self._loop_proxy_cm.__exit__(None, None, None) + self._loop_proxy_cm = None + self._loop = None delattr(self._node, "_asyncio_runner") From 9d813d2db2c268b21ac748ef958ed6dff56ce016 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 26 Jan 2022 01:32:20 +0200 Subject: [PATCH 21/25] Bump deps --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index c22b6443..0b08f90d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ setup_requires = setuptools_scm >= 6.2 install_requires = + aioloop-proxy >- 0.0.4 pytest >= 6.1.0 typing-extensions >= 4.0; python_version < "3.8" From 753393d90ecdf9e7faba11f9f3554973d1201fec Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 26 Jan 2022 10:35:52 +0200 Subject: [PATCH 22/25] Fix tests --- tests/conftest.py | 11 +++++++++++ tests/multiloop/test_alternative_loops.py | 6 ++++-- .../test_respects_event_loop_policy.py | 8 ++++---- tests/sessionloop/test_session_loops.py | 7 +++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4aa8c89a..2b21ffbd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import asyncio +import aioloop_proxy import pytest pytest_plugins = "pytester" @@ -30,3 +31,13 @@ def factory(): return unused_tcp_port_factory() return factory + + +@pytest.fixture(scope="session") +def original_loop(): + def func(loop_proxy: aioloop_proxy.LoopProxy) -> asyncio.AbstractEventLoop: + while isinstance(loop_proxy, aioloop_proxy.LoopProxy): + loop_proxy = loop_proxy.get_parent_loop() + return loop_proxy + + return func diff --git a/tests/multiloop/test_alternative_loops.py b/tests/multiloop/test_alternative_loops.py index 5f66c967..77271c33 100644 --- a/tests/multiloop/test_alternative_loops.py +++ b/tests/multiloop/test_alternative_loops.py @@ -5,10 +5,12 @@ @pytest.mark.asyncio -async def test_for_custom_loop(): +async def test_for_custom_loop(original_loop): """This test should be executed using the custom loop.""" await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" + assert ( + type(original_loop(asyncio.get_event_loop())).__name__ == "CustomSelectorLoop" + ) @pytest.mark.asyncio diff --git a/tests/respect_event_loop_policy/test_respects_event_loop_policy.py b/tests/respect_event_loop_policy/test_respects_event_loop_policy.py index 610b3388..377ffadc 100644 --- a/tests/respect_event_loop_policy/test_respects_event_loop_policy.py +++ b/tests/respect_event_loop_policy/test_respects_event_loop_policy.py @@ -5,13 +5,13 @@ @pytest.mark.asyncio -async def test_uses_loop_provided_by_custom_policy(): +async def test_uses_loop_provided_by_custom_policy(original_loop): """Asserts that test cases use the event loop provided by the custom event loop policy""" - assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" + assert type(original_loop(asyncio.get_event_loop())).__name__ == "TestEventLoop" @pytest.mark.asyncio -async def test_custom_policy_is_not_overwritten(): +async def test_custom_policy_is_not_overwritten(original_loop): """Asserts that any custom event loop policy stays the same across test cases""" - assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop" + assert type(original_loop(asyncio.get_event_loop())).__name__ == "TestEventLoop" diff --git a/tests/sessionloop/test_session_loops.py b/tests/sessionloop/test_session_loops.py index acb67165..4b4a92f7 100644 --- a/tests/sessionloop/test_session_loops.py +++ b/tests/sessionloop/test_session_loops.py @@ -5,10 +5,13 @@ @pytest.mark.asyncio -async def test_for_custom_loop(): +async def test_for_custom_loop(original_loop): """This test should be executed using the custom loop.""" await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoopSession" + assert ( + type(original_loop(asyncio.get_event_loop())).__name__ + == "CustomSelectorLoopSession" + ) @pytest.mark.asyncio From ea95a6bf24b7eb56a19b71477cd4c8a84dbba2b6 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 26 Jan 2022 10:45:10 +0200 Subject: [PATCH 23/25] Fix test --- tests/async_fixtures/test_async_fixtures_with_finalizer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index 2e72d5de..4ba2404c 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -54,6 +54,11 @@ async def port_afinalizer(): current_loop = asyncio.get_event_loop_policy().get_event_loop() current_loop.run_until_complete(port_afinalizer()) - worker = asyncio.ensure_future(asyncio.sleep(0.2)) + # nested loop proxy is different than explicitly set by + # loop_policy.set_event_loop(). it is really not a big prblem because async + # functions always have the correct 'get_running_loop()' already. + worker = asyncio.ensure_future( + asyncio.sleep(0.2), loop=asyncio.get_event_loop_policy().get_event_loop() + ) request.addfinalizer(functools.partial(port_finalizer, worker)) return True From 6be59805636f965430b630684e66f283f41d5161 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 26 Jan 2022 11:06:50 +0200 Subject: [PATCH 24/25] Fix typo --- .../async_fixtures/test_async_fixtures_with_finalizer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index 4ba2404c..a1c4ae43 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -55,10 +55,12 @@ async def port_afinalizer(): current_loop.run_until_complete(port_afinalizer()) # nested loop proxy is different than explicitly set by - # loop_policy.set_event_loop(). it is really not a big prblem because async - # functions always have the correct 'get_running_loop()' already. + # loop_policy.set_event_loop(). + # It is really not a big problem because async functions + # always have the correct 'get_running_loop()' already. worker = asyncio.ensure_future( - asyncio.sleep(0.2), loop=asyncio.get_event_loop_policy().get_event_loop() + asyncio.sleep(0.2), + loop=asyncio.get_event_loop_policy().get_event_loop(), ) request.addfinalizer(functools.partial(port_finalizer, worker)) return True From ce54bc5d7aabd3d186baf06041548cd15fc7db23 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 28 Jan 2022 16:31:29 +0200 Subject: [PATCH 25/25] Bump aioloop-proxy --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0b08f90d..33430a47 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,7 @@ setup_requires = setuptools_scm >= 6.2 install_requires = - aioloop-proxy >- 0.0.4 + aioloop-proxy >= 0.0.13 pytest >= 6.1.0 typing-extensions >= 4.0; python_version < "3.8"