From 314020c7dcc9847ee4d064827da20b54d984b59e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 5 Nov 2023 14:55:03 +0100 Subject: [PATCH 01/11] [feat!] Replaced the asyncio_event_loop marker with an optional "scope" kwarg to the asyncio mark. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 9 +- .../class_scoped_loop_auto_mode_example.py | 14 -- ..._loop_custom_policy_strict_mode_example.py | 19 -- .../class_scoped_loop_strict_mode_example.py | 4 +- ...d_loop_with_fixture_strict_mode_example.py | 3 +- ...ped_loop_pytestmark_strict_mode_example.py | 10 + ...unction_scoped_loop_strict_mode_example.py | 8 + docs/source/reference/markers/index.rst | 58 ++---- ...module_scoped_loop_strict_mode_example.py} | 2 +- .../pytestmark_asyncio_strict_mode_example.py | 11 -- pytest_asyncio/plugin.py | 174 ++++++++++-------- ...st_class_marker.py => test_class_scope.py} | 72 ++++---- ..._module_marker.py => test_module_scope.py} | 58 ++---- tox.ini | 12 +- 14 files changed, 196 insertions(+), 258 deletions(-) delete mode 100644 docs/source/reference/markers/class_scoped_loop_auto_mode_example.py delete mode 100644 docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py create mode 100644 docs/source/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py create mode 100644 docs/source/reference/markers/function_scoped_loop_strict_mode_example.py rename docs/source/reference/markers/{module_scoped_loop_auto_mode_example.py => module_scoped_loop_strict_mode_example.py} (88%) delete mode 100644 docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py rename tests/markers/{test_class_marker.py => test_class_scope.py} (81%) rename tests/markers/{test_module_marker.py => test_module_scope.py} (75%) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index a3fab017..e8ec016c 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -4,11 +4,18 @@ Changelog 0.23.0 (UNRELEASED) =================== -- Removes pytest-trio from the test dependencies `#620 `_ +This release is backwards-compatible with v0.21. +Changes are non-breaking, unless you upgrade from v0.22. + +- BREAKING: The *asyncio_event_loop* mark has been removed. Class-scoped and module-scoped event loops can be requested + via the *scope* keyword argument to the _asyncio_ mark. - Introduces the *event_loop_policy* fixture which allows testing with non-default or multiple event loops `#662 `_ +- Removes pytest-trio from the test dependencies `#620 `_ 0.22.0 (2023-10-31) =================== +This release has been yanked from PyPI due to fundamental issues with the _asyncio_event_loop_ mark. + - Class-scoped and module-scoped event loops can be requested via the _asyncio_event_loop_ mark. `#620 `_ - Deprecate redefinition of the `event_loop` fixture. `#587 `_ diff --git a/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py b/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py deleted file mode 100644 index a839e571..00000000 --- a/docs/source/reference/markers/class_scoped_loop_auto_mode_example.py +++ /dev/null @@ -1,14 +0,0 @@ -import asyncio - -import pytest - - -@pytest.mark.asyncio_event_loop -class TestClassScopedLoop: - loop: asyncio.AbstractEventLoop - - async def test_remember_loop(self): - TestClassScopedLoop.loop = asyncio.get_running_loop() - - async def test_this_runs_in_same_loop(self): - assert asyncio.get_running_loop() is TestClassScopedLoop.loop diff --git a/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py deleted file mode 100644 index e5cc6238..00000000 --- a/docs/source/reference/markers/class_scoped_loop_custom_policy_strict_mode_example.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio - -import pytest - - -class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - pass - - -@pytest.fixture(scope="class") -def event_loop_policy(request): - return CustomEventLoopPolicy() - - -@pytest.mark.asyncio_event_loop -class TestUsesCustomEventLoopPolicy: - @pytest.mark.asyncio - async def test_uses_custom_event_loop_policy(self): - assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) diff --git a/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py index c33b34b8..38b5689c 100644 --- a/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py +++ b/docs/source/reference/markers/class_scoped_loop_strict_mode_example.py @@ -3,14 +3,12 @@ import pytest -@pytest.mark.asyncio_event_loop +@pytest.mark.asyncio(scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio async def test_remember_loop(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop diff --git a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py index c70a4bc6..f912dec9 100644 --- a/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py +++ b/docs/source/reference/markers/class_scoped_loop_with_fixture_strict_mode_example.py @@ -5,7 +5,7 @@ import pytest_asyncio -@pytest.mark.asyncio_event_loop +@pytest.mark.asyncio(scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -13,6 +13,5 @@ class TestClassScopedLoop: async def my_fixture(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_runs_is_same_loop_as_fixture(self, my_fixture): assert asyncio.get_running_loop() is TestClassScopedLoop.loop diff --git a/docs/source/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py b/docs/source/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py new file mode 100644 index 00000000..f8e7e717 --- /dev/null +++ b/docs/source/reference/markers/function_scoped_loop_pytestmark_strict_mode_example.py @@ -0,0 +1,10 @@ +import asyncio + +import pytest + +# Marks all test coroutines in this module +pytestmark = pytest.mark.asyncio + + +async def test_runs_in_asyncio_event_loop(): + assert asyncio.get_running_loop() diff --git a/docs/source/reference/markers/function_scoped_loop_strict_mode_example.py b/docs/source/reference/markers/function_scoped_loop_strict_mode_example.py new file mode 100644 index 00000000..e30f73c5 --- /dev/null +++ b/docs/source/reference/markers/function_scoped_loop_strict_mode_example.py @@ -0,0 +1,8 @@ +import asyncio + +import pytest + + +@pytest.mark.asyncio +async def test_runs_in_asyncio_event_loop(): + assert asyncio.get_running_loop() diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst index 6c3e5253..b625bbd7 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -4,62 +4,34 @@ Markers ``pytest.mark.asyncio`` ======================= -A coroutine or async generator with this marker will be treated as a test function by pytest. The marked function will be executed as an -asyncio task in the event loop provided by the ``event_loop`` fixture. +A coroutine or async generator with this marker is treated as a test function by pytest. +The marked function is executed as an asyncio task in the event loop provided by pytest-asyncio. -In order to make your test code a little more concise, the pytest |pytestmark|_ -feature can be used to mark entire modules or classes with this marker. -Only test coroutines will be affected (by default, coroutines prefixed by -``test_``), so, for example, fixtures are safe to define. - -.. include:: pytestmark_asyncio_strict_mode_example.py - :code: python - -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added -automatically to *async* test functions. - - -``pytest.mark.asyncio_event_loop`` -================================== -Test classes or modules with this mark provide a class-scoped or module-scoped asyncio event loop. - -This functionality is orthogonal to the `asyncio` mark. -That means the presence of this mark does not imply that async test functions inside the class or module are collected by pytest-asyncio. -The collection happens automatically in `auto` mode. -However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions. - -The following code example uses the `asyncio_event_loop` mark to provide a shared event loop for all tests in `TestClassScopedLoop`: - -.. include:: class_scoped_loop_strict_mode_example.py +.. include:: function_scoped_loop_strict_mode_example.py :code: python -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: +Multiple async tests in a single class or module can be marked using |pytestmark|_. -.. include:: class_scoped_loop_auto_mode_example.py +.. include:: function_scoped_loop_pytestmark_strict_mode_example.py :code: python -Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` mark to the module: - -.. include:: module_scoped_loop_auto_mode_example.py - :code: python +The ``pytest.mark.asyncio`` marker can be omitted entirely in *auto* mode, where the *asyncio* marker is added automatically to *async* test functions. -The `asyncio_event_loop` mark supports an optional `policy` keyword argument to set the asyncio event loop policy. +By default, each test runs in it's own asyncio event loop. +Multiple tests can share the same event loop by providing a *scope* keyword argument to the *asyncio* mark. +The following code example provides a shared event loop for all tests in `TestClassScopedLoop`: -.. include:: class_scoped_loop_custom_policy_strict_mode_example.py +.. include:: class_scoped_loop_strict_mode_example.py :code: python +Requesting class scope for tests that are not part of a class will give a *UsageError.* +Similar to class-scoped event loops, a module-scoped loop is provided when setting the asyncio mark's scope to *module:* -The ``policy`` keyword argument may also take an iterable of event loop policies. This causes tests under by the `asyncio_event_loop` mark to be parametrized with different policies: - -.. include:: class_scoped_loop_custom_policies_strict_mode_example.py +.. include:: module_scoped_loop_strict_mode_example.py :code: python -If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``. - -Fixtures and tests sharing the same `asyncio_event_loop` mark are executed in the same event loop: - -.. include:: class_scoped_loop_with_fixture_strict_mode_example.py - :code: python +Requesting class scope with the test being part of a class will give a *UsageError*. +The supported scopes are *class*, and *module.* .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/docs/source/reference/markers/module_scoped_loop_auto_mode_example.py b/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py similarity index 88% rename from docs/source/reference/markers/module_scoped_loop_auto_mode_example.py rename to docs/source/reference/markers/module_scoped_loop_strict_mode_example.py index e38bdeff..221d554e 100644 --- a/docs/source/reference/markers/module_scoped_loop_auto_mode_example.py +++ b/docs/source/reference/markers/module_scoped_loop_strict_mode_example.py @@ -2,7 +2,7 @@ import pytest -pytestmark = pytest.mark.asyncio_event_loop +pytestmark = pytest.mark.asyncio(scope="module") loop: asyncio.AbstractEventLoop diff --git a/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py b/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py deleted file mode 100644 index f1465728..00000000 --- a/docs/source/reference/markers/pytestmark_asyncio_strict_mode_example.py +++ /dev/null @@ -1,11 +0,0 @@ -import asyncio - -import pytest - -# All test coroutines will be treated as marked. -pytestmark = pytest.mark.asyncio - - -async def test_example(): - """No marker!""" - await asyncio.sleep(0) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a6554e22..1fa1d327 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -26,14 +26,15 @@ ) import pytest -from _pytest.mark.structures import get_unpacked_marks from pytest import ( + Class, Collector, Config, FixtureRequest, Function, Item, Metafunc, + Module, Parser, PytestCollectionWarning, PytestDeprecationWarning, @@ -207,11 +208,13 @@ def _preprocess_async_fixtures( config = collector.config asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") - event_loop_fixture_id = "event_loop" - for node, mark in collector.iter_markers_with_node("asyncio_event_loop"): - event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) - if event_loop_fixture_id: - break + marker = collector.get_closest_marker("asyncio") + scope = marker.kwargs.get("scope", "function") if marker else "function" + if scope == "function": + event_loop_fixture_id = "event_loop" + else: + event_loop_node = _retrieve_scope_root(collector, scope) + event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None) for fixtures in fixturemanager._arg2fixturedefs.values(): for fixturedef in fixtures: func = fixturedef.func @@ -548,48 +551,40 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( def pytest_collectstart(collector: pytest.Collector): if not isinstance(collector, (pytest.Class, pytest.Module)): return - # pytest.Collector.own_markers is empty at this point, - # so we rely on _pytest.mark.structures.get_unpacked_marks - marks = get_unpacked_marks(collector.obj) - for mark in marks: - if not mark.name == "asyncio_event_loop": - continue - - # There seem to be issues when a fixture is shadowed by another fixture - # and both differ in their params. - # https://github.com/pytest-dev/pytest/issues/2043 - # https://github.com/pytest-dev/pytest/issues/11350 - # As such, we assign a unique name for each event_loop fixture. - # The fixture name is stored in the collector's Stash, so it can - # be injected when setting up the test - event_loop_fixture_id = f"{collector.nodeid}::" - collector.stash[_event_loop_fixture_id] = event_loop_fixture_id - - @pytest.fixture( - scope="class" if isinstance(collector, pytest.Class) else "module", - name=event_loop_fixture_id, - ) - def scoped_event_loop( - *args, # Function needs to accept "cls" when collected by pytest.Class - event_loop_policy, - ) -> Iterator[asyncio.AbstractEventLoop]: - new_loop_policy = event_loop_policy - old_loop_policy = asyncio.get_event_loop_policy() - old_loop = asyncio.get_event_loop() - asyncio.set_event_loop_policy(new_loop_policy) - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - yield loop - loop.close() - asyncio.set_event_loop_policy(old_loop_policy) - asyncio.set_event_loop(old_loop) + # There seem to be issues when a fixture is shadowed by another fixture + # and both differ in their params. + # https://github.com/pytest-dev/pytest/issues/2043 + # https://github.com/pytest-dev/pytest/issues/11350 + # As such, we assign a unique name for each event_loop fixture. + # The fixture name is stored in the collector's Stash, so it can + # be injected when setting up the test + event_loop_fixture_id = f"{collector.nodeid}::" + collector.stash[_event_loop_fixture_id] = event_loop_fixture_id + + @pytest.fixture( + scope="class" if isinstance(collector, pytest.Class) else "module", + name=event_loop_fixture_id, + ) + def scoped_event_loop( + *args, # Function needs to accept "cls" when collected by pytest.Class + event_loop_policy, + ) -> Iterator[asyncio.AbstractEventLoop]: + new_loop_policy = event_loop_policy + old_loop_policy = asyncio.get_event_loop_policy() + old_loop = asyncio.get_event_loop() + asyncio.set_event_loop_policy(new_loop_policy) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + asyncio.set_event_loop_policy(old_loop_policy) + asyncio.set_event_loop(old_loop) - # @pytest.fixture does not register the fixture anywhere, so pytest doesn't - # know it exists. We work around this by attaching the fixture function to the - # collected Python class, where it will be picked up by pytest.Class.collect() - # or pytest.Module.collect(), respectively - collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop - break + # @pytest.fixture does not register the fixture anywhere, so pytest doesn't + # know it exists. We work around this by attaching the fixture function to the + # collected Python class, where it will be picked up by pytest.Class.collect() + # or pytest.Module.collect(), respectively + collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop def pytest_collection_modifyitems( @@ -608,7 +603,9 @@ def pytest_collection_modifyitems( if _get_asyncio_mode(config) != Mode.AUTO: return for item in items: - if isinstance(item, PytestAsyncioFunction): + if isinstance(item, PytestAsyncioFunction) and not item.get_closest_marker( + "asyncio" + ): item.add_marker("asyncio") @@ -626,32 +623,34 @@ def pytest_collection_modifyitems( @pytest.hookimpl(tryfirst=True) def pytest_generate_tests(metafunc: Metafunc) -> None: - for event_loop_provider_node, _ in metafunc.definition.iter_markers_with_node( - "asyncio_event_loop" - ): - event_loop_fixture_id = event_loop_provider_node.stash.get( - _event_loop_fixture_id, None - ) - if event_loop_fixture_id: - # This specific fixture name may already be in metafunc.argnames, if this - # test indirectly depends on the fixture. For example, this is the case - # when the test depends on an async fixture, both of which share the same - # asyncio_event_loop mark. - if event_loop_fixture_id in metafunc.fixturenames: - continue - fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") - if "event_loop" in metafunc.fixturenames: - raise MultipleEventLoopsRequestedError( - _MULTIPLE_LOOPS_REQUESTED_ERROR - % (metafunc.definition.nodeid, event_loop_provider_node.nodeid), - ) - # Add the scoped event loop fixture to Metafunc's list of fixture names and - # fixturedefs and leave the actual parametrization to pytest - metafunc.fixturenames.insert(0, event_loop_fixture_id) - metafunc._arg2fixturedefs[ - event_loop_fixture_id - ] = fixturemanager._arg2fixturedefs[event_loop_fixture_id] - break + marker = metafunc.definition.get_closest_marker("asyncio") + if not marker: + return + scope = marker.kwargs.get("scope", "function") + if scope == "function": + return + event_loop_node = _retrieve_scope_root(metafunc.definition, scope) + event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None) + + if event_loop_fixture_id: + # This specific fixture name may already be in metafunc.argnames, if this + # test indirectly depends on the fixture. For example, this is the case + # when the test depends on an async fixture, both of which share the same + # asyncio_event_loop mark. + if event_loop_fixture_id in metafunc.fixturenames: + return + fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") + if "event_loop" in metafunc.fixturenames: + raise MultipleEventLoopsRequestedError( + _MULTIPLE_LOOPS_REQUESTED_ERROR + % (metafunc.definition.nodeid, event_loop_node.nodeid), + ) + # Add the scoped event loop fixture to Metafunc's list of fixture names and + # fixturedefs and leave the actual parametrization to pytest + metafunc.fixturenames.insert(0, event_loop_fixture_id) + metafunc._arg2fixturedefs[ + event_loop_fixture_id + ] = fixturemanager._arg2fixturedefs[event_loop_fixture_id] @pytest.hookimpl(hookwrapper=True) @@ -844,11 +843,12 @@ def pytest_runtest_setup(item: pytest.Item) -> None: marker = item.get_closest_marker("asyncio") if marker is None: return - event_loop_fixture_id = "event_loop" - for node, mark in item.iter_markers_with_node("asyncio_event_loop"): - event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None) - if event_loop_fixture_id: - break + scope = marker.kwargs.get("scope", "function") + if scope != "function": + parent_node = _retrieve_scope_root(item, scope) + event_loop_fixture_id = parent_node.stash[_event_loop_fixture_id] + else: + event_loop_fixture_id = "event_loop" fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests if "event_loop" in fixturenames: @@ -864,6 +864,22 @@ def pytest_runtest_setup(item: pytest.Item) -> None: ) +def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: + node_type_by_scope = { + "class": Class, + "module": Module, + } + scope_root_type = node_type_by_scope[scope] + for node in reversed(item.listchain()): + if isinstance(node, scope_root_type): + return node + error_message = ( + f"{item.name} is marked to be run in an event loop with scope {scope}, " + f"but is not part of any {scope}." + ) + raise pytest.UsageError(error_message) + + @pytest.fixture def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_scope.py similarity index 81% rename from tests/markers/test_class_marker.py rename to tests/markers/test_class_scope.py index e06a34d8..9d5cd374 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_scope.py @@ -26,7 +26,7 @@ def sample_fixture(): return None -def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode( +def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_functions( pytester: pytest.Pytester, ): pytester.makepyfile( @@ -35,15 +35,14 @@ def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode( import asyncio import pytest - @pytest.mark.asyncio_event_loop class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio + @pytest.mark.asyncio(scope="class") async def test_remember_loop(self): TestClassScopedLoop.loop = asyncio.get_running_loop() - @pytest.mark.asyncio + @pytest.mark.asyncio(scope="class") async def test_this_runs_in_same_loop(self): assert asyncio.get_running_loop() is TestClassScopedLoop.loop """ @@ -53,7 +52,7 @@ async def test_this_runs_in_same_loop(self): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode( +def test_asyncio_mark_provides_class_scoped_loop_when_applied_to_class( pytester: pytest.Pytester, ): pytester.makepyfile( @@ -62,7 +61,7 @@ def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode( import asyncio import pytest - @pytest.mark.asyncio_event_loop + @pytest.mark.asyncio(scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop @@ -74,61 +73,59 @@ async def test_this_runs_in_same_loop(self): """ ) ) - result = pytester.runpytest("--asyncio-mode=auto") + result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): +def test_asyncio_mark_raises_when_class_scoped_is_request_without_class( + pytester: pytest.Pytester, +): pytester.makepyfile( dedent( """\ import asyncio import pytest - @pytest.mark.asyncio_event_loop - class TestSuperClassWithMark: + @pytest.mark.asyncio(scope="class") + async def test_has_no_surrounding_class(): pass - - class TestWithoutMark(TestSuperClassWithMark): - loop: asyncio.AbstractEventLoop - - @pytest.mark.asyncio - async def test_remember_loop(self): - TestWithoutMark.loop = asyncio.get_running_loop() - - @pytest.mark.asyncio - async def test_this_runs_in_same_loop(self): - assert asyncio.get_running_loop() is TestWithoutMark.loop """ ) ) result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=2) + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + "*is marked to be run in an event loop with scope*", + ) -def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( - pytester: pytest.Pytester, -): +def test_asyncio_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): pytester.makepyfile( dedent( """\ import asyncio import pytest - @pytest.mark.asyncio_event_loop - class TestClassScopedLoop: - @pytest.mark.asyncio - async def test_remember_loop(self, event_loop): - pass + @pytest.mark.asyncio(scope="class") + class TestSuperClassWithMark: + pass + + class TestWithoutMark(TestSuperClassWithMark): + loop: asyncio.AbstractEventLoop + + async def test_remember_loop(self): + TestWithoutMark.loop = asyncio.get_running_loop() + + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is TestWithoutMark.loop """ ) ) result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( +def test_asyncio_mark_respects_the_loop_policy( pytester: pytest.Pytester, ): pytester.makepyfile( @@ -167,7 +164,7 @@ async def test_does_not_use_custom_event_loop_policy(): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( +def test_asyncio_mark_respects_parametrized_loop_policies( pytester: pytest.Pytester, ): pytester.makepyfile( @@ -178,6 +175,7 @@ def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( import pytest @pytest.fixture( + scope="class", params=[ asyncio.DefaultEventLoopPolicy(), asyncio.DefaultEventLoopPolicy(), @@ -186,8 +184,8 @@ def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( def event_loop_policy(request): return request.param + @pytest.mark.asyncio(scope="class") class TestWithDifferentLoopPolicies: - @pytest.mark.asyncio async def test_parametrized_loop(self, request): pass """ @@ -197,7 +195,7 @@ async def test_parametrized_loop(self, request): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( +def test_asyncio_mark_provides_class_scoped_loop_to_fixtures( pytester: pytest.Pytester, ): pytester.makepyfile( @@ -208,7 +206,7 @@ def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( import pytest import pytest_asyncio - @pytest.mark.asyncio_event_loop + @pytest.mark.asyncio(scope="class") class TestClassScopedLoop: loop: asyncio.AbstractEventLoop diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_scope.py similarity index 75% rename from tests/markers/test_module_marker.py rename to tests/markers/test_module_scope.py index 882f51af..1cd8ac65 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_scope.py @@ -59,22 +59,19 @@ def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester import asyncio import pytest - pytestmark = pytest.mark.asyncio_event_loop + pytestmark = pytest.mark.asyncio(scope="module") loop: asyncio.AbstractEventLoop - @pytest.mark.asyncio async def test_remember_loop(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_this_runs_in_same_loop(): global loop assert asyncio.get_running_loop() is loop class TestClassA: - @pytest.mark.asyncio async def test_this_runs_in_same_loop(self): global loop assert asyncio.get_running_loop() is loop @@ -85,36 +82,6 @@ async def test_this_runs_in_same_loop(self): result.assert_outcomes(passed=3) -def test_asyncio_mark_provides_class_scoped_loop_auto_mode(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - - pytestmark = pytest.mark.asyncio_event_loop - - loop: asyncio.AbstractEventLoop - - async def test_remember_loop(): - global loop - loop = asyncio.get_running_loop() - - async def test_this_runs_in_same_loop(): - global loop - assert asyncio.get_running_loop() is loop - - class TestClassA: - async def test_this_runs_in_same_loop(self): - global loop - assert asyncio.get_running_loop() is loop - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=auto") - result.assert_outcomes(passed=3) - - def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( pytester: Pytester, ): @@ -124,9 +91,8 @@ def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( import asyncio import pytest - pytestmark = pytest.mark.asyncio_event_loop + pytestmark = pytest.mark.asyncio(scope="module") - @pytest.mark.asyncio async def test_remember_loop(event_loop): pass """ @@ -137,7 +103,7 @@ async def test_remember_loop(event_loop): result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") -def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( +def test_asyncio_mark_respects_the_loop_policy( pytester: Pytester, ): pytester.makepyfile( @@ -157,13 +123,12 @@ class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): from .custom_policy import CustomEventLoopPolicy - pytestmark = pytest.mark.asyncio_event_loop + pytestmark = pytest.mark.asyncio(scope="module") @pytest.fixture(scope="module") def event_loop_policy(): return CustomEventLoopPolicy() - @pytest.mark.asyncio async def test_uses_custom_event_loop_policy(): assert isinstance( asyncio.get_event_loop_policy(), @@ -178,7 +143,8 @@ async def test_uses_custom_event_loop_policy(): from .custom_policy import CustomEventLoopPolicy - @pytest.mark.asyncio + pytestmark = pytest.mark.asyncio(scope="module") + async def test_does_not_use_custom_event_loop_policy(): assert not isinstance( asyncio.get_event_loop_policy(), @@ -191,7 +157,7 @@ async def test_does_not_use_custom_event_loop_policy(): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( +def test_asyncio_mark_respects_parametrized_loop_policies( pytester: Pytester, ): pytester.makepyfile( @@ -201,7 +167,7 @@ def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( import pytest - pytestmark = pytest.mark.asyncio_event_loop + pytestmark = pytest.mark.asyncio(scope="module") @pytest.fixture( scope="module", @@ -213,7 +179,6 @@ def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( def event_loop_policy(request): return request.param - @pytest.mark.asyncio async def test_parametrized_loop(): pass """ @@ -223,7 +188,7 @@ async def test_parametrized_loop(): result.assert_outcomes(passed=2) -def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures( +def test_asyncio_mark_provides_module_scoped_loop_to_fixtures( pytester: Pytester, ): pytester.makepyfile( @@ -234,16 +199,15 @@ def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures( import pytest import pytest_asyncio - pytestmark = pytest.mark.asyncio_event_loop + pytestmark = pytest.mark.asyncio(scope="module") loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture + @pytest_asyncio.fixture(scope="module") async def my_fixture(): global loop loop = asyncio.get_running_loop() - @pytest.mark.asyncio async def test_runs_is_same_loop_as_fixture(my_fixture): global loop assert asyncio.get_running_loop() is loop diff --git a/tox.ini b/tox.ini index 5acc30e2..7bab7350 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.14.0 -envlist = py38, py39, py310, py311, py312, pytest-min +envlist = py38, py39, py310, py311, py312, pytest-min, docs isolated_build = true passenv = CI @@ -25,6 +25,16 @@ commands = make test allowlist_externals = make +[testenv:docs] +extras = docs +deps = + --requirement dependencies/docs/requirements.txt + --constraint dependencies/docs/constraints.txt +change_dir = docs +commands = make html +allowlist_externals = + make + [gh-actions] python = 3.8: py38, pytest-min From d35c24da26ab965838823eccf84037ad51ab4efa Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 5 Nov 2023 16:37:39 +0100 Subject: [PATCH 02/11] [feat] Add support for package-scoped loops. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 3 +- docs/source/reference/markers/index.rst | 12 +- pytest_asyncio/plugin.py | 29 ++- tests/markers/test_package_scope.py | 225 ++++++++++++++++++++++++ 4 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 tests/markers/test_package_scope.py diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index e8ec016c..10c274b2 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -7,8 +7,7 @@ Changelog This release is backwards-compatible with v0.21. Changes are non-breaking, unless you upgrade from v0.22. -- BREAKING: The *asyncio_event_loop* mark has been removed. Class-scoped and module-scoped event loops can be requested - via the *scope* keyword argument to the _asyncio_ mark. +- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module and package scope can be requested via the *scope* keyword argument to the _asyncio_ mark. - Introduces the *event_loop_policy* fixture which allows testing with non-default or multiple event loops `#662 `_ - Removes pytest-trio from the test dependencies `#620 `_ diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst index b625bbd7..83bcbbc0 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -19,19 +19,23 @@ The ``pytest.mark.asyncio`` marker can be omitted entirely in *auto* mode, where By default, each test runs in it's own asyncio event loop. Multiple tests can share the same event loop by providing a *scope* keyword argument to the *asyncio* mark. +The supported scopes are *class,* and *module,* and *package*. The following code example provides a shared event loop for all tests in `TestClassScopedLoop`: .. include:: class_scoped_loop_strict_mode_example.py :code: python -Requesting class scope for tests that are not part of a class will give a *UsageError.* -Similar to class-scoped event loops, a module-scoped loop is provided when setting the asyncio mark's scope to *module:* +Requesting class scope with the test being part of a class will give a *UsageError*. +Similar to class-scoped event loops, a module-scoped loop is provided when setting mark's scope to *module:* .. include:: module_scoped_loop_strict_mode_example.py :code: python -Requesting class scope with the test being part of a class will give a *UsageError*. -The supported scopes are *class*, and *module.* +Package-scoped loops only work with `regular Python packages. `__ +That means they require an *__init__.py* to be present. +Package-scoped loops do not work in `namespace packages. `__ +Subpackages do not share the loop with their parent package. + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 1fa1d327..5ca9b01e 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -5,6 +5,7 @@ import functools import inspect import socket +import sys import warnings from asyncio import AbstractEventLoopPolicy from textwrap import dedent @@ -35,6 +36,7 @@ Item, Metafunc, Module, + Package, Parser, PytestCollectionWarning, PytestDeprecationWarning, @@ -545,11 +547,16 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( _event_loop_fixture_id = StashKey[str] +_fixture_scope_by_collector_type = { + Class: "class", + Module: "module", + Package: "package", +} @pytest.hookimpl def pytest_collectstart(collector: pytest.Collector): - if not isinstance(collector, (pytest.Class, pytest.Module)): + if not isinstance(collector, (Class, Module, Package)): return # There seem to be issues when a fixture is shadowed by another fixture # and both differ in their params. @@ -562,7 +569,7 @@ def pytest_collectstart(collector: pytest.Collector): collector.stash[_event_loop_fixture_id] = event_loop_fixture_id @pytest.fixture( - scope="class" if isinstance(collector, pytest.Class) else "module", + scope=_fixture_scope_by_collector_type[type(collector)], name=event_loop_fixture_id, ) def scoped_event_loop( @@ -585,6 +592,23 @@ def scoped_event_loop( # collected Python class, where it will be picked up by pytest.Class.collect() # or pytest.Module.collect(), respectively collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop + # When collector is a package, collector.obj is the package's __init__.py. + # pytest doesn't seem to collect fixtures in __init__.py. + # Using parsefactories to collect fixtures in __init__.py their baseid will end + # with "__init__.py", thus limiting the scope of the fixture to the init module. + # Therefore, we tell the pluginmanager explicitly to collect the fixtures + # in the init module, but strip "__init__.py" from the baseid + # Possibly related to https://github.com/pytest-dev/pytest/issues/4085 + if isinstance(collector, Package): + fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage") + package_node_id = _removesuffix(collector.nodeid, "__init__.py") + fixturemanager.parsefactories(collector.obj, nodeid=package_node_id) + + +def _removesuffix(s: str, suffix: str) -> str: + if sys.version_info < (3, 9): + return s[: -len(suffix)] + return s.removesuffix(suffix) def pytest_collection_modifyitems( @@ -868,6 +892,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: node_type_by_scope = { "class": Class, "module": Module, + "package": Package, } scope_root_type = node_type_by_scope[scope] for node in reversed(item.listchain()): diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py new file mode 100644 index 00000000..fde2e836 --- /dev/null +++ b/tests/markers/test_package_scope.py @@ -0,0 +1,225 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_mark_provides_package_scoped_loop_strict_mode(pytester: Pytester): + package_name = pytester.path.name + subpackage_name = "subpkg" + pytester.makepyfile( + __init__="", + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_module_one=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + @pytest.mark.asyncio(scope="package") + async def test_remember_loop(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + test_module_two=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="package") + + async def test_this_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is shared_module.loop + """ + ), + ) + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="package") + + async def test_subpackage_runs_in_different_loop(): + assert asyncio.get_running_loop() is not shared_module.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=4) + + +def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_raises=dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio(scope="package") + async def test_remember_loop(event_loop): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + + +def test_asyncio_mark_respects_the_loop_policy( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.fixture(scope="package") + def event_loop_policy(): + return CustomEventLoopPolicy() + """ + ), + custom_policy=dedent( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(scope="package") + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_also_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(scope="package") + + async def test_also_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_respects_parametrized_loop_policies( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_parametrization=dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio(scope="package") + + @pytest.fixture( + scope="package", + params=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + pass + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_package_scoped_loop_to_fixtures( + pytester: Pytester, +): + package_name = pytester.path.name + pytester.makepyfile( + __init__="", + conftest=dedent( + f"""\ + import asyncio + + import pytest_asyncio + + from {package_name} import shared_module + + @pytest_asyncio.fixture(scope="package") + async def my_fixture(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_fixture_runs_in_scoped_loop=dedent( + f"""\ + import asyncio + + import pytest + import pytest_asyncio + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="package") + + async def test_runs_in_same_loop_as_fixture(my_fixture): + assert asyncio.get_running_loop() is shared_module.loop + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) From b851d058b0aaa536af16079834a03b7f6c6f8f6a Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 8 Nov 2023 10:39:28 +0100 Subject: [PATCH 03/11] [docs] Link to auto mode concept from marker reference. Signed-off-by: Michael Seifert --- docs/source/reference/markers/index.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst index 83bcbbc0..8cf2b279 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -15,7 +15,7 @@ Multiple async tests in a single class or module can be marked using |pytestmark .. include:: function_scoped_loop_pytestmark_strict_mode_example.py :code: python -The ``pytest.mark.asyncio`` marker can be omitted entirely in *auto* mode, where the *asyncio* marker is added automatically to *async* test functions. +The ``pytest.mark.asyncio`` marker can be omitted entirely in |auto mode|_ where the *asyncio* marker is added automatically to *async* test functions. By default, each test runs in it's own asyncio event loop. Multiple tests can share the same event loop by providing a *scope* keyword argument to the *asyncio* mark. @@ -37,5 +37,7 @@ Package-scoped loops do not work in `namespace packages. Date: Wed, 8 Nov 2023 11:35:57 +0100 Subject: [PATCH 04/11] [feat] Add support for session-scoped event loops. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 2 +- docs/source/reference/markers/index.rst | 1 + pytest_asyncio/plugin.py | 27 +++ tests/markers/test_session_scope.py | 229 ++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 tests/markers/test_session_scope.py diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index 10c274b2..d902ff06 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -7,7 +7,7 @@ Changelog This release is backwards-compatible with v0.21. Changes are non-breaking, unless you upgrade from v0.22. -- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module and package scope can be requested via the *scope* keyword argument to the _asyncio_ mark. +- BREAKING: The *asyncio_event_loop* mark has been removed. Event loops with class, module, package, and session scopes can be requested via the *scope* keyword argument to the _asyncio_ mark. - Introduces the *event_loop_policy* fixture which allows testing with non-default or multiple event loops `#662 `_ - Removes pytest-trio from the test dependencies `#620 `_ diff --git a/docs/source/reference/markers/index.rst b/docs/source/reference/markers/index.rst index 8cf2b279..a875b90d 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -36,6 +36,7 @@ That means they require an *__init__.py* to be present. Package-scoped loops do not work in `namespace packages. `__ Subpackages do not share the loop with their parent package. +Tests marked with *session* scope share the same event loop, even if the tests exist in different packages. .. |auto mode| replace:: *auto mode* .. _auto mode: ../../concepts.html#auto-mode diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 5ca9b01e..694c8f5a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -551,11 +551,21 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( Class: "class", Module: "module", Package: "package", + Session: "session", } @pytest.hookimpl def pytest_collectstart(collector: pytest.Collector): + # Session is not a PyCollector type, so it doesn't have a corresponding + # "obj" attribute to attach a dynamic fixture function to. + # However, there's only one session per pytest run, so there's no need to + # create the fixture dynamically. We can simply define a session-scoped + # event loop fixture once in the plugin code. + if isinstance(collector, Session): + event_loop_fixture_id = _session_event_loop.__name__ + collector.stash[_event_loop_fixture_id] = event_loop_fixture_id + return if not isinstance(collector, (Class, Module, Package)): return # There seem to be issues when a fixture is shadowed by another fixture @@ -893,6 +903,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: "class": Class, "module": Module, "package": Package, + "session": Session, } scope_root_type = node_type_by_scope[scope] for node in reversed(item.listchain()): @@ -921,6 +932,22 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: loop.close() +@pytest.fixture(scope="session") +def _session_event_loop( + request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy +) -> Iterator[asyncio.AbstractEventLoop]: + new_loop_policy = event_loop_policy + old_loop_policy = asyncio.get_event_loop_policy() + old_loop = asyncio.get_event_loop() + asyncio.set_event_loop_policy(new_loop_policy) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() + asyncio.set_event_loop_policy(old_loop_policy) + asyncio.set_event_loop(old_loop) + + @pytest.fixture(scope="session", autouse=True) def event_loop_policy() -> AbstractEventLoopPolicy: """Return an instance of the policy used to create asyncio event loops.""" diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py new file mode 100644 index 00000000..1242cfee --- /dev/null +++ b/tests/markers/test_session_scope.py @@ -0,0 +1,229 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_mark_provides_session_scoped_loop_strict_mode(pytester: Pytester): + package_name = pytester.path.name + pytester.makepyfile( + __init__="", + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + test_module_one=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + @pytest.mark.asyncio(scope="session") + async def test_remember_loop(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + test_module_two=dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="session") + + async def test_this_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + + class TestClassA: + async def test_this_runs_in_same_loop(self): + assert asyncio.get_running_loop() is shared_module.loop + """ + ), + ) + subpackage_name = "subpkg" + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + import pytest + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="session") + + async def test_subpackage_runs_in_same_loop(): + assert asyncio.get_running_loop() is shared_module.loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=4) + + +def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_raises=dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio(scope="session") + async def test_remember_loop(event_loop): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + + +def test_asyncio_mark_respects_the_loop_policy( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + conftest=dedent( + """\ + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.fixture(scope="session") + def event_loop_policy(): + return CustomEventLoopPolicy() + """ + ), + custom_policy=dedent( + """\ + import asyncio + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + """ + ), + test_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(scope="session") + + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_also_uses_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + pytestmark = pytest.mark.asyncio(scope="session") + + async def test_also_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_respects_parametrized_loop_policies( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_parametrization=dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio(scope="session") + + @pytest.fixture( + scope="session", + params=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ], + ) + def event_loop_policy(request): + return request.param + + async def test_parametrized_loop(): + pass + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_session_scoped_loop_to_fixtures( + pytester: Pytester, +): + package_name = pytester.path.name + pytester.makepyfile( + __init__="", + conftest=dedent( + f"""\ + import asyncio + + import pytest_asyncio + + from {package_name} import shared_module + + @pytest_asyncio.fixture(scope="session") + async def my_fixture(): + shared_module.loop = asyncio.get_running_loop() + """ + ), + shared_module=dedent( + """\ + import asyncio + + loop: asyncio.AbstractEventLoop = None + """ + ), + ) + subpackage_name = "subpkg" + subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("test_subpkg.py").write_text( + dedent( + f"""\ + import asyncio + + import pytest + import pytest_asyncio + + from {package_name} import shared_module + + pytestmark = pytest.mark.asyncio(scope="session") + + async def test_runs_in_same_loop_as_fixture(my_fixture): + assert asyncio.get_running_loop() is shared_module.loop + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) From f028ac156b484b076f3b16125928145e6fbe2555 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:17:04 +0100 Subject: [PATCH 05/11] [feat!] Requesting the "event_loop" fixture in an async fixture will raise a pytest.UsageError. This is a follow-up to the previous deprecation of event_loop fixture requests in async fixtures and tests. The change allows cleaning up the fixture synchronization code, so that the synchronization will eventually become independent of the fixture providing the asyncio event loop. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 4 ++++ pytest_asyncio/plugin.py | 24 ++++++------------- ...est_explicit_event_loop_fixture_request.py | 17 ++++++------- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index d902ff06..9c1176ff 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,10 @@ Changelog ========= +1.0.0 (UNRELEASED) +================== +- BREAKING: Asynchronous fixtures can no longer request the *event_loop* fixture + 0.23.0 (UNRELEASED) =================== This release is backwards-compatible with v0.21. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 694c8f5a..3e4213ef 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -231,13 +231,11 @@ def _preprocess_async_fixtures( _make_asyncio_fixture_function(func) function_signature = inspect.signature(func) if "event_loop" in function_signature.parameters: - warnings.warn( - PytestDeprecationWarning( - f"{func.__name__} is asynchronous and explicitly " - f'requests the "event_loop" fixture. Asynchronous fixtures and ' - f'test functions should use "asyncio.get_running_loop()" ' - f"instead." - ) + raise pytest.UsageError( + f"{func.__name__} is asynchronous and explicitly " + f'requests the "event_loop" fixture. Asynchronous fixtures and ' + f'test functions should use "asyncio.get_running_loop()" ' + f"instead." ) _inject_fixture_argnames(fixturedef, event_loop_fixture_id) _synchronize_async_fixture(fixturedef, event_loop_fixture_id) @@ -274,16 +272,12 @@ def _synchronize_async_fixture( def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], - event_loop_fixture_id: str, - 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_fixture_id in sig.parameters: - ret[event_loop_fixture_id] = event_loop return ret @@ -315,9 +309,7 @@ def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): fixture, request.instance, fixturedef.unittest ) event_loop = kwargs.pop(event_loop_fixture_id) - gen_obj = func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + gen_obj = func(**_add_kwargs(func, kwargs, request)) async def setup(): res = await gen_obj.__anext__() @@ -356,9 +348,7 @@ def _async_fixture_wrapper(request: SubRequest, **kwargs: Any): event_loop = kwargs.pop(event_loop_fixture_id) async def setup(): - res = await func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + res = await func(**_add_kwargs(func, kwargs, request)) return res return event_loop.run_until_complete(setup()) diff --git a/tests/test_explicit_event_loop_fixture_request.py b/tests/test_explicit_event_loop_fixture_request.py index 8c4b732c..ec97639f 100644 --- a/tests/test_explicit_event_loop_fixture_request.py +++ b/tests/test_explicit_event_loop_fixture_request.py @@ -69,7 +69,7 @@ async def test_coroutine_emits_warning(event_loop): ) -def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_fixture( +def test_raises_error_when_event_loop_is_explicitly_requested_in_coroutine_fixture( pytester: Pytester, ): pytester.makepyfile( @@ -79,17 +79,17 @@ def test_emit_warning_when_event_loop_is_explicitly_requested_in_coroutine_fixtu import pytest_asyncio @pytest_asyncio.fixture - async def emits_warning(event_loop): + async def raises_error(event_loop): pass @pytest.mark.asyncio - async def test_uses_fixture(emits_warning): + async def test_uses_fixture(raises_error): pass """ ) ) result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) + result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] ) @@ -105,20 +105,17 @@ def test_emit_warning_when_event_loop_is_explicitly_requested_in_async_gen_fixtu import pytest_asyncio @pytest_asyncio.fixture - async def emits_warning(event_loop): + async def raises_error(event_loop): yield @pytest.mark.asyncio - async def test_uses_fixture(emits_warning): + async def test_uses_fixture(raises_error): pass """ ) ) result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) - result.stdout.fnmatch_lines( - ['*is asynchronous and explicitly requests the "event_loop" fixture*'] - ) + result.assert_outcomes(errors=1) def test_does_not_emit_warning_when_event_loop_is_explicitly_requested_in_sync_function( From 9286c5f989cdb70d6d1b84bf453dff9e241ba825 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:26:35 +0100 Subject: [PATCH 06/11] [refactor] Fixture synchronizers use the existing event loop rather than the value provided by the event loop fixture. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 3e4213ef..7695a15a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -308,7 +308,8 @@ def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - event_loop = kwargs.pop(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id) + event_loop = asyncio.get_event_loop() gen_obj = func(**_add_kwargs(func, kwargs, request)) async def setup(): @@ -345,13 +346,13 @@ def _async_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - event_loop = kwargs.pop(event_loop_fixture_id) + kwargs.pop(event_loop_fixture_id) async def setup(): res = await func(**_add_kwargs(func, kwargs, request)) return res - return event_loop.run_until_complete(setup()) + return asyncio.get_event_loop().run_until_complete(setup()) fixturedef.func = _async_fixture_wrapper From bd29f2b87e4d481f8d0d7767a0106938b6e165fe Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:36:01 +0100 Subject: [PATCH 07/11] [feat!] Fixture synchronizers no longer depend on a fixture providing an event loop. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 1 + pytest_asyncio/plugin.py | 33 ++++--------- .../async_fixtures/test_parametrized_loop.py | 46 ------------------- ...event_loop_fixture_override_deprecation.py | 27 ----------- 4 files changed, 11 insertions(+), 96 deletions(-) delete mode 100644 tests/async_fixtures/test_parametrized_loop.py diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index 9c1176ff..2ad2213c 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -5,6 +5,7 @@ Changelog 1.0.0 (UNRELEASED) ================== - BREAKING: Asynchronous fixtures can no longer request the *event_loop* fixture +- BREAKING: Parametrizations and custom implementations of the *event_loop* fixture no longer have any effect on async fixtures 0.23.0 (UNRELEASED) =================== diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 7695a15a..31383c0b 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -210,13 +210,6 @@ def _preprocess_async_fixtures( config = collector.config asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") - marker = collector.get_closest_marker("asyncio") - scope = marker.kwargs.get("scope", "function") if marker else "function" - if scope == "function": - event_loop_fixture_id = "event_loop" - else: - event_loop_node = _retrieve_scope_root(collector, scope) - event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None) for fixtures in fixturemanager._arg2fixturedefs.values(): for fixturedef in fixtures: func = fixturedef.func @@ -237,36 +230,32 @@ def _preprocess_async_fixtures( f'test functions should use "asyncio.get_running_loop()" ' f"instead." ) - _inject_fixture_argnames(fixturedef, event_loop_fixture_id) - _synchronize_async_fixture(fixturedef, event_loop_fixture_id) + _inject_fixture_argnames(fixturedef) + _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: +def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: """ - Ensures that `request` and `event_loop` are arguments of the specified fixture. + Ensures that `request` is an argument of the specified fixture. """ to_add = [] - for name in ("request", event_loop_fixture_id): + for name in ("request",): 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( @@ -300,7 +289,7 @@ 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) @@ -308,7 +297,6 @@ def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - kwargs.pop(event_loop_fixture_id) event_loop = asyncio.get_event_loop() gen_obj = func(**_add_kwargs(func, kwargs, request)) @@ -338,7 +326,7 @@ 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) @@ -346,7 +334,6 @@ def _async_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - kwargs.pop(event_loop_fixture_id) async def setup(): res = await func(**_add_kwargs(func, kwargs, request)) diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py deleted file mode 100644 index 2bdbe5e8..00000000 --- a/tests/async_fixtures/test_parametrized_loop.py +++ /dev/null @@ -1,46 +0,0 @@ -from textwrap import dedent - -from pytest import Pytester - - -def test_event_loop_parametrization(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - - import pytest - import pytest_asyncio - - TESTS_COUNT = 0 - - - def teardown_module(): - # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' - 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_asyncio.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 - """ - ) - ) - result = pytester.runpytest_subprocess("--asyncio-mode=strict") - result.assert_outcomes(passed=4) diff --git a/tests/test_event_loop_fixture_override_deprecation.py b/tests/test_event_loop_fixture_override_deprecation.py index 3484ef76..69ba59c7 100644 --- a/tests/test_event_loop_fixture_override_deprecation.py +++ b/tests/test_event_loop_fixture_override_deprecation.py @@ -82,30 +82,3 @@ def test_emits_no_warning(): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1, warnings=0) - - -def test_emit_warning_when_redefined_event_loop_is_used_by_fixture(pytester: Pytester): - pytester.makepyfile( - dedent( - """\ - import asyncio - import pytest - import pytest_asyncio - - @pytest.fixture - def event_loop(): - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest_asyncio.fixture - async def uses_event_loop(): - pass - - def test_emits_warning(uses_event_loop): - pass - """ - ) - ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1, warnings=1) From cb9953aca8bc64ff1f6817d9cbb601bccf500e87 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:37:34 +0100 Subject: [PATCH 08/11] [refactor] Simplified code in _inject_fixture_argnames. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 31383c0b..82bb9491 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -240,12 +240,8 @@ def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: """ Ensures that `request` is an argument of the specified fixture. """ - to_add = [] - for name in ("request",): - if name not in fixturedef.argnames: - to_add.append(name) - if to_add: - fixturedef.argnames += tuple(to_add) + if "request" not in fixturedef.argnames: + fixturedef.argnames += ("request",) def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: From e801947171473fdbe817435d8b03e167f5243f7e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:46:09 +0100 Subject: [PATCH 09/11] [refactor] Synchronize fixtures in pytest_fixture_setup rather than in _preprocess_async_fixtures. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 82bb9491..b38a9fce 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -230,9 +230,6 @@ def _preprocess_async_fixtures( f'test functions should use "asyncio.get_running_loop()" ' f"instead." ) - _inject_fixture_argnames(fixturedef) - _synchronize_async_fixture(fixturedef) - assert _is_asyncio_fixture_function(fixturedef.func) processed_fixturedefs.add(fixturedef) @@ -704,7 +701,9 @@ def pytest_fixture_setup( pass policy.set_event_loop(loop) return - + elif _is_asyncio_fixture_function(fixturedef.func): + _inject_fixture_argnames(fixturedef) + _synchronize_async_fixture(fixturedef) yield From 4b9b1f2c9bc7ad18c55d54a2fe5b1b69cfab388d Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 17:55:21 +0100 Subject: [PATCH 10/11] [refactor] The UsageError regarding explicit requests of the event_loop fixture in async fixtures is issue during fixture setup rather than during collection time. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index b38a9fce..c4d3c1d1 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -222,14 +222,6 @@ def _preprocess_async_fixtures( # This applies to pytest_trio fixtures, for example continue _make_asyncio_fixture_function(func) - function_signature = inspect.signature(func) - if "event_loop" in function_signature.parameters: - raise pytest.UsageError( - f"{func.__name__} is asynchronous and explicitly " - f'requests the "event_loop" fixture. Asynchronous fixtures and ' - f'test functions should use "asyncio.get_running_loop()" ' - f"instead." - ) processed_fixturedefs.add(fixturedef) @@ -702,6 +694,13 @@ def pytest_fixture_setup( policy.set_event_loop(loop) return elif _is_asyncio_fixture_function(fixturedef.func): + if "event_loop" in inspect.signature(fixturedef.func).parameters: + raise pytest.UsageError( + f"{fixturedef.func.__name__} is asynchronous and explicitly " + f'requests the "event_loop" fixture. Asynchronous fixtures and ' + f'test functions should use "asyncio.get_running_loop()" ' + f"instead." + ) _inject_fixture_argnames(fixturedef) _synchronize_async_fixture(fixturedef) yield From cc1ee5bca2ae763273bfff57920b70d12e85d75b Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 10 Nov 2023 18:07:44 +0100 Subject: [PATCH 11/11] [refactor] Removed obsolete preprocessing of async fixtures. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 51 +++++++--------------------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c4d3c1d1..7317ff1d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -20,7 +20,6 @@ List, Literal, Optional, - Set, TypeVar, Union, overload, @@ -203,28 +202,6 @@ def pytest_report_header(config: Config) -> List[str]: return [f"asyncio: mode={mode}"] -def _preprocess_async_fixtures( - collector: Collector, - processed_fixturedefs: Set[FixtureDef], -) -> None: - config = collector.config - asyncio_mode = _get_asyncio_mode(config) - fixturemanager = config.pluginmanager.get_plugin("funcmanage") - for fixtures in fixturemanager._arg2fixturedefs.values(): - for fixturedef in fixtures: - func = fixturedef.func - if fixturedef in processed_fixturedefs or not _is_coroutine_or_asyncgen( - func - ): - continue - if not _is_asyncio_fixture_function(func) and asyncio_mode == Mode.STRICT: - # Ignore async fixtures without explicit asyncio mark in strict mode - # This applies to pytest_trio fixtures, for example - continue - _make_asyncio_fixture_function(func) - processed_fixturedefs.add(fixturedef) - - def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: """ Ensures that `request` is an argument of the specified fixture. @@ -460,24 +437,6 @@ def runtest(self) -> None: super().runtest() -_HOLDER: Set[FixtureDef] = set() - - -# The function name needs to start with "pytest_" -# see https://github.com/pytest-dev/pytest/issues/11307 -@pytest.hookimpl(specname="pytest_pycollect_makeitem", tryfirst=True) -def pytest_pycollect_makeitem_preprocess_async_fixtures( - collector: Union[pytest.Module, pytest.Class], name: str, obj: object -) -> Union[ - pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None -]: - """A pytest hook to collect asyncio coroutines.""" - if not collector.funcnamefilter(name): - return None - _preprocess_async_fixtures(collector, _HOLDER) - return None - - # 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) @@ -655,6 +614,7 @@ def pytest_fixture_setup( fixturedef: FixtureDef, request: SubRequest ) -> Optional[object]: """Adjust the event loop policy when an event loop is produced.""" + async_mode = _get_asyncio_mode(request.config) if fixturedef.argname == "event_loop": # The use of a fixture finalizer is preferred over the # pytest_fixture_post_finalizer hook. The fixture finalizer is invoked once @@ -693,7 +653,14 @@ def pytest_fixture_setup( pass policy.set_event_loop(loop) return - elif _is_asyncio_fixture_function(fixturedef.func): + elif inspect.iscoroutinefunction(fixturedef.func) or inspect.isasyncgenfunction( + fixturedef.func + ): + if async_mode == Mode.STRICT and not _is_asyncio_fixture_function( + fixturedef.func + ): + yield + return if "event_loop" in inspect.signature(fixturedef.func).parameters: raise pytest.UsageError( f"{fixturedef.func.__name__} is asynchronous and explicitly "