From c691e0c0851c13ad6d88eedffe7c443b1083d22f Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 5 Aug 2024 15:07:38 +0200 Subject: [PATCH 1/7] fix: Fixes a bug that caused module-scoped async fixtures to fail when reused in other modules. Async fixture synchronizers now choose the event loop for the async fixutre at runtime rather than relying on collection-time information. This fixes #862. --- pytest_asyncio/plugin.py | 92 +++++++++---------- .../test_shared_module_fixture.py | 35 +++++++ 2 files changed, 78 insertions(+), 49 deletions(-) create mode 100644 tests/async_fixtures/test_shared_module_fixture.py diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a90e125e..39d1a08d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -251,15 +251,8 @@ def _preprocess_async_fixtures( or default_loop_scope or fixturedef.scope ) - if scope == "function": - event_loop_fixture_id: Optional[str] = "event_loop" - else: - event_loop_node = _retrieve_scope_root(collector, scope) - event_loop_fixture_id = event_loop_node.stash.get( - # Type ignored because of non-optimal mypy inference. - _event_loop_fixture_id, # type: ignore[arg-type] - None, - ) + if scope == "function" and "event_loop" not in fixturedef.argnames: + fixturedef.argnames += ("event_loop",) _make_asyncio_fixture_function(func, scope) function_signature = inspect.signature(func) if "event_loop" in function_signature.parameters: @@ -271,49 +264,26 @@ def _preprocess_async_fixtures( f"instead." ) ) - assert event_loop_fixture_id - _inject_fixture_argnames( - fixturedef, - event_loop_fixture_id, - ) - _synchronize_async_fixture( - fixturedef, - event_loop_fixture_id, - ) + if "request" not in fixturedef.argnames: + fixturedef.argnames += ("request",) + _synchronize_async_fixture(fixturedef) assert _is_asyncio_fixture_function(fixturedef.func) processed_fixturedefs.add(fixturedef) -def _inject_fixture_argnames( - fixturedef: FixtureDef, event_loop_fixture_id: str -) -> None: - """ - Ensures that `request` and `event_loop` are arguments of the specified fixture. - """ - to_add = [] - for name in ("request", event_loop_fixture_id): - if name not in fixturedef.argnames: - to_add.append(name) - if to_add: - fixturedef.argnames += tuple(to_add) - - -def _synchronize_async_fixture( - fixturedef: FixtureDef, event_loop_fixture_id: str -) -> None: +def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: """ Wraps the fixture function of an async fixture in a synchronous function. """ if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) + _wrap_asyncgen_fixture(fixturedef) elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef, event_loop_fixture_id) + _wrap_async_fixture(fixturedef) def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], - event_loop_fixture_id: str, event_loop: asyncio.AbstractEventLoop, request: FixtureRequest, ) -> Dict[str, Any]: @@ -321,8 +291,8 @@ def _add_kwargs( ret = kwargs.copy() if "request" in sig.parameters: ret["request"] = request - if event_loop_fixture_id in sig.parameters: - ret[event_loop_fixture_id] = event_loop + if "event_loop" in sig.parameters: + ret["event_loop"] = event_loop return ret @@ -345,17 +315,19 @@ def _perhaps_rebind_fixture_func( return func -def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: +def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @functools.wraps(fixture) def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): unittest = fixturedef.unittest if hasattr(fixturedef, "unittest") else False func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) - event_loop = kwargs.pop(event_loop_fixture_id) - gen_obj = func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( + request, func ) + event_loop = request.getfixturevalue(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id, None) + gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) async def setup(): res = await gen_obj.__anext__() @@ -383,19 +355,21 @@ async def async_finalizer() -> None: fixturedef.func = _asyncgen_fixture_wrapper -def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: +def _wrap_async_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @functools.wraps(fixture) def _async_fixture_wrapper(request: FixtureRequest, **kwargs: Any): unittest = False if pytest.version_tuple >= (8, 2) else fixturedef.unittest func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) - event_loop = kwargs.pop(event_loop_fixture_id) + event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( + request, func + ) + event_loop = request.getfixturevalue(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id, None) async def setup(): - res = await func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + res = await func(**_add_kwargs(func, kwargs, event_loop, request)) return res return event_loop.run_until_complete(setup()) @@ -403,6 +377,26 @@ async def setup(): fixturedef.func = _async_fixture_wrapper +def _get_event_loop_fixture_id_for_async_fixture( + request: FixtureRequest, func: Any +) -> str: + default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope") + loop_scope = ( + getattr(func, "_loop_scope", None) or default_loop_scope or request.scope + ) + if loop_scope == "function": + event_loop_fixture_id = "event_loop" + else: + event_loop_node = _retrieve_scope_root(request._pyfuncitem, loop_scope) + event_loop_fixture_id = event_loop_node.stash.get( + # Type ignored because of non-optimal mypy inference. + _event_loop_fixture_id, # type: ignore[arg-type] + "", + ) + assert event_loop_fixture_id + return event_loop_fixture_id + + class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" diff --git a/tests/async_fixtures/test_shared_module_fixture.py b/tests/async_fixtures/test_shared_module_fixture.py new file mode 100644 index 00000000..ff1cb62b --- /dev/null +++ b/tests/async_fixtures/test_shared_module_fixture.py @@ -0,0 +1,35 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester): + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest_asyncio + @pytest_asyncio.fixture(scope="module") + async def async_shared_module_fixture(): + return True + """ + ), + test_module_one=dedent( + """\ + import pytest + @pytest.mark.asyncio + async def test_shared_module_fixture_use_a(async_shared_module_fixture): + assert async_shared_module_fixture is True + """ + ), + test_module_two=dedent( + """\ + import pytest + @pytest.mark.asyncio + async def test_shared_module_fixture_use_b(async_shared_module_fixture): + assert async_shared_module_fixture is True + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) From cfbded063fd3541a693e95897c6289a9dcf05636 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 12:13:19 +0200 Subject: [PATCH 2/7] docs: Added changelog entry. --- docs/source/reference/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index c976b8da..f4067f20 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -7,6 +7,7 @@ Changelog - Adds an optional `loop_scope` keyword argument to `pytest.mark.asyncio`. This argument controls which event loop is used to run the marked async test. `#706`_, `#871 `_ - Deprecates the optional `scope` keyword argument to `pytest.mark.asyncio` for API consistency with ``pytest_asyncio.fixture``. Users are encouraged to use the `loop_scope` keyword argument, which does exactly the same. - Raises an error when passing `scope` or `loop_scope` as a positional argument to ``@pytest.mark.asyncio``. `#812 `_ +- Fixes a bug that caused module-scoped async fixtures to fail when reused in other modules `#862 `_ `#668 `_ 0.23.8 (2024-07-17) From 4698e3e5e3a279e13d687c9431f8c2ef689243a1 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 13:00:01 +0200 Subject: [PATCH 3/7] build: Bump minimum supported pytest version to v8.2.0. --- dependencies/default/requirements.txt | 2 +- dependencies/pytest-min/constraints.txt | 4 ++-- dependencies/pytest-min/requirements.txt | 2 +- docs/source/reference/changelog.rst | 1 + setup.cfg | 2 +- tests/test_is_async_test.py | 5 +---- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt index 3ac25aba..42cfc8d3 100644 --- a/dependencies/default/requirements.txt +++ b/dependencies/default/requirements.txt @@ -1,3 +1,3 @@ # Always adjust install_requires in setup.cfg and pytest-min-requirements.txt # when changing runtime dependencies -pytest >= 7.0.0,<9 +pytest >= 8.2,<9 diff --git a/dependencies/pytest-min/constraints.txt b/dependencies/pytest-min/constraints.txt index 65e3addb..f01a0eb7 100644 --- a/dependencies/pytest-min/constraints.txt +++ b/dependencies/pytest-min/constraints.txt @@ -11,10 +11,10 @@ iniconfig==2.0.0 mock==5.1.0 nose==1.3.7 packaging==23.2 -pluggy==1.3.0 +pluggy==1.5.0 py==1.11.0 Pygments==2.16.1 -pytest==7.0.0 +pytest==8.2.0 requests==2.31.0 sortedcontainers==2.4.0 tomli==2.0.1 diff --git a/dependencies/pytest-min/requirements.txt b/dependencies/pytest-min/requirements.txt index 9fb33e96..918abfd5 100644 --- a/dependencies/pytest-min/requirements.txt +++ b/dependencies/pytest-min/requirements.txt @@ -1,3 +1,3 @@ # Always adjust install_requires in setup.cfg and requirements.txt # when changing minimum version dependencies -pytest[testing] == 7.0.0 +pytest[testing] == 8.2.0 diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index f4067f20..abf27a0a 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -4,6 +4,7 @@ Changelog 0.24.0 (UNRELEASED) =================== +- BREAKING: Updated minimum supported pytest version to v8.2.0 - Adds an optional `loop_scope` keyword argument to `pytest.mark.asyncio`. This argument controls which event loop is used to run the marked async test. `#706`_, `#871 `_ - Deprecates the optional `scope` keyword argument to `pytest.mark.asyncio` for API consistency with ``pytest_asyncio.fixture``. Users are encouraged to use the `loop_scope` keyword argument, which does exactly the same. - Raises an error when passing `scope` or `loop_scope` as a positional argument to ``@pytest.mark.asyncio``. `#812 `_ diff --git a/setup.cfg b/setup.cfg index 9947cbe3..c04d3884 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ include_package_data = True # Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies install_requires = - pytest >= 7.0.0,<9 + pytest >= 8.2,<9 [options.extras_require] testing = diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py index 12e791c1..cf98cc94 100644 --- a/tests/test_is_async_test.py +++ b/tests/test_is_async_test.py @@ -74,10 +74,7 @@ def pytest_collection_modifyitems(items): ) ) result = pytester.runpytest("--asyncio-mode=strict") - if pytest.version_tuple < (7, 2): - # Probably related to https://github.com/pytest-dev/pytest/pull/10012 - result.assert_outcomes(failed=1) - elif pytest.version_tuple < (8,): + if pytest.version_tuple < (8,): result.assert_outcomes(skipped=1) else: result.assert_outcomes(failed=1) From 80748a5dfe80a93f720c9968168bc4a1df65b61f Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 14:11:41 +0200 Subject: [PATCH 4/7] refactor: Removed version-specific code paths for pytest versions lower than 8.2 --- pytest_asyncio/plugin.py | 21 +++++++-------------- tests/test_is_async_test.py | 6 +----- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 39d1a08d..85235de4 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -35,6 +35,7 @@ Class, Collector, Config, + FixtureDef, FixtureRequest, Function, Item, @@ -62,12 +63,6 @@ FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] -# https://github.com/pytest-dev/pytest/commit/fb55615d5e999dd44306596f340036c195428ef1 -if pytest.version_tuple < (8, 0): - FixtureDef = Any -else: - from pytest import FixtureDef - class PytestAsyncioError(Exception): """Base class for exceptions raised by pytest-asyncio""" @@ -320,8 +315,7 @@ def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: @functools.wraps(fixture) def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - unittest = fixturedef.unittest if hasattr(fixturedef, "unittest") else False - func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) + func = _perhaps_rebind_fixture_func(fixture, request.instance, False) event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( request, func ) @@ -330,7 +324,7 @@ def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) async def setup(): - res = await gen_obj.__anext__() + res = await gen_obj.__anext__() # type: ignore[union-attr] return res def finalizer() -> None: @@ -338,7 +332,7 @@ def finalizer() -> None: async def async_finalizer() -> None: try: - await gen_obj.__anext__() + await gen_obj.__anext__() # type: ignore[union-attr] except StopAsyncIteration: pass else: @@ -352,7 +346,7 @@ async def async_finalizer() -> None: request.addfinalizer(finalizer) return result - fixturedef.func = _asyncgen_fixture_wrapper + fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc] def _wrap_async_fixture(fixturedef: FixtureDef) -> None: @@ -360,8 +354,7 @@ def _wrap_async_fixture(fixturedef: FixtureDef) -> None: @functools.wraps(fixture) def _async_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - unittest = False if pytest.version_tuple >= (8, 2) else fixturedef.unittest - func = _perhaps_rebind_fixture_func(fixture, request.instance, unittest) + func = _perhaps_rebind_fixture_func(fixture, request.instance, False) event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( request, func ) @@ -374,7 +367,7 @@ async def setup(): return event_loop.run_until_complete(setup()) - fixturedef.func = _async_fixture_wrapper + fixturedef.func = _async_fixture_wrapper # type: ignore[misc] def _get_event_loop_fixture_id_for_async_fixture( diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py index cf98cc94..e0df54de 100644 --- a/tests/test_is_async_test.py +++ b/tests/test_is_async_test.py @@ -1,6 +1,5 @@ from textwrap import dedent -import pytest from pytest import Pytester @@ -74,10 +73,7 @@ def pytest_collection_modifyitems(items): ) ) result = pytester.runpytest("--asyncio-mode=strict") - if pytest.version_tuple < (8,): - result.assert_outcomes(skipped=1) - else: - result.assert_outcomes(failed=1) + result.assert_outcomes(failed=1) def test_returns_true_for_unmarked_coroutine_item_in_auto_mode(pytester: Pytester): From 34d4aae84cc89cfec0439a92fac50b2058dd7684 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 14:20:58 +0200 Subject: [PATCH 5/7] refactor: Removed obsolete "unittest" argument from _perhabs_rebind_fixture_func --- pytest_asyncio/plugin.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 85235de4..af403053 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -291,9 +291,7 @@ def _add_kwargs( return ret -def _perhaps_rebind_fixture_func( - func: _T, instance: Optional[Any], unittest: bool -) -> _T: +def _perhaps_rebind_fixture_func(func: _T, instance: Optional[Any]) -> _T: if instance is not None: # The fixture needs to be bound to the actual request.instance # so it is bound to the same object as the test method. @@ -302,10 +300,9 @@ def _perhaps_rebind_fixture_func( unbound, cls = func.__func__, type(func.__self__) # type: ignore except AttributeError: pass - # If unittest is true, the fixture is bound unconditionally. - # otherwise, only if the fixture was bound before to an instance of + # Only if the fixture was bound before to an instance of # the same type. - if unittest or (cls is not None and isinstance(instance, cls)): + if cls is not None and isinstance(instance, cls): func = unbound.__get__(instance) # type: ignore return func @@ -315,7 +312,7 @@ def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: @functools.wraps(fixture) def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - func = _perhaps_rebind_fixture_func(fixture, request.instance, False) + func = _perhaps_rebind_fixture_func(fixture, request.instance) event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( request, func ) @@ -354,7 +351,7 @@ def _wrap_async_fixture(fixturedef: FixtureDef) -> None: @functools.wraps(fixture) def _async_fixture_wrapper(request: FixtureRequest, **kwargs: Any): - func = _perhaps_rebind_fixture_func(fixture, request.instance, False) + func = _perhaps_rebind_fixture_func(fixture, request.instance) event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture( request, func ) From 4c7cfb4a9b81bf4c0fc71eeab2b88626b608f08e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 14:38:31 +0200 Subject: [PATCH 6/7] refactor: Narrowed down return type of pytest_pycollect_makeitem_convert_async_functions_to_subclass. --- 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 af403053..a653ace5 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -30,6 +30,7 @@ overload, ) +import pluggy import pytest from pytest import ( Class, @@ -539,13 +540,12 @@ def pytest_pycollect_makeitem_preprocess_async_fixtures( return None -# TODO: #778 Narrow down return type of function when dropping support for pytest 7 # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", hookwrapper=True) def pytest_pycollect_makeitem_convert_async_functions_to_subclass( collector: Union[pytest.Module, pytest.Class], name: str, obj: object -) -> Generator[None, Any, None]: +) -> Generator[None, pluggy.Result, None]: """ Converts coroutines and async generators collected as pytest.Functions to AsyncFunction items. From 171da4f9a9b902bde805556edf858c03a377fe0d Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 7 Aug 2024 14:40:35 +0200 Subject: [PATCH 7/7] refactor: Narrowed down return type of pytest_fixture_setup. --- pytest_asyncio/plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a653ace5..178fcaa6 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -756,11 +756,10 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: ) -# TODO: #778 Narrow down return type of function when dropping support for pytest 7 @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef, -) -> Generator[None, Any, None]: +) -> Generator[None, pluggy.Result, None]: """Adjust the event loop policy when an event loop is produced.""" if fixturedef.argname == "event_loop": # The use of a fixture finalizer is preferred over the