diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index a3fab017..d902ff06 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -4,11 +4,17 @@ 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. 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 `_ 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/fixtures/index.rst b/docs/source/reference/fixtures/index.rst index 354077f5..7b8dc818 100644 --- a/docs/source/reference/fixtures/index.rst +++ b/docs/source/reference/fixtures/index.rst @@ -6,8 +6,8 @@ event_loop ========== Creates a new asyncio event loop based on the current event loop policy. The new loop is available as the return value of this fixture for synchronous functions, or via `asyncio.get_running_loop `__ for asynchronous functions. -The event loop is closed when the fixture scope ends. The fixture scope defaults -to ``function`` scope. +The event loop is closed when the fixture scope ends. +The fixture scope defaults to ``function`` scope. .. include:: event_loop_example.py :code: python @@ -15,8 +15,6 @@ to ``function`` scope. Note that, when using the ``event_loop`` fixture, you need to interact with the event loop using methods like ``event_loop.run_until_complete``. If you want to *await* code inside your test function, you need to write a coroutine and use it as a test function. The `asyncio <#pytest-mark-asyncio>`__ marker is used to mark coroutines that should be treated as test functions. -If your tests require an asyncio event loop with class or module scope, apply the `asyncio_event_loop mark <./markers.html/#pytest-mark-asyncio-event-loop>`__ to the respective class or module. - If you need to change the type of the event loop, prefer setting a custom event loop policy over redefining the ``event_loop`` fixture. If the ``pytest.mark.asyncio`` decorator is applied to a test function, the ``event_loop`` 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..a875b90d 100644 --- a/docs/source/reference/markers/index.rst +++ b/docs/source/reference/markers/index.rst @@ -4,62 +4,41 @@ 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 +.. include:: function_scoped_loop_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. +Multiple async tests in a single class or module can be marked using |pytestmark|_. -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_pytestmark_strict_mode_example.py :code: python -In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: - -.. include:: class_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. -Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` mark to the module: +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:: module_scoped_loop_auto_mode_example.py +.. include:: class_scoped_loop_strict_mode_example.py :code: python -The `asyncio_event_loop` mark supports an optional `policy` keyword argument to set the asyncio event loop policy. +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:: class_scoped_loop_custom_policy_strict_mode_example.py +.. include:: module_scoped_loop_strict_mode_example.py :code: python +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. -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 - :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 +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 .. |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..942ad4de 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 @@ -26,14 +27,16 @@ ) import pytest -from _pytest.mark.structures import get_unpacked_marks from pytest import ( + Class, Collector, Config, FixtureRequest, Function, Item, Metafunc, + Module, + Package, Parser, PytestCollectionWarning, PytestDeprecationWarning, @@ -185,12 +188,6 @@ def pytest_configure(config: Config) -> None: "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) - config.addinivalue_line( - "markers", - "asyncio_event_loop: " - "Provides an asyncio event loop in the scope of the marked test " - "class or module", - ) @pytest.hookimpl(tryfirst=True) @@ -207,11 +204,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 @@ -542,54 +541,78 @@ 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", + Session: "session", +} @pytest.hookimpl 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}::" + # 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 - - @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 + return + 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. + # 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=_fixture_scope_by_collector_type[type(collector)], + 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 + # 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( @@ -608,7 +631,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") @@ -618,40 +643,47 @@ def pytest_collection_modifyitems( %s:%d Replacing the event_loop fixture with a custom implementation is deprecated and will lead to errors in the future. - If you want to request an asyncio event loop with a class or module scope, - please attach the asyncio_event_loop mark to the respective class or module. + If you want to request an asyncio event loop with a scope other than function + scope, use the "scope" argument to the asyncio mark when marking the tests. + If you want to return different types of event loops, use the event_loop_policy + fixture. """ ) @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 + # event loop fixture 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.format( + test_name=metafunc.definition.nodeid, + scope=scope, + scoped_loop_node=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) @@ -831,11 +863,11 @@ def inner(*args, **kwargs): _MULTIPLE_LOOPS_REQUESTED_ERROR = dedent( """\ Multiple asyncio event loops with different scopes have been requested - by %s. The test explicitly requests the event_loop fixture, while another - event loop is provided by %s. + by {test_name}. The test explicitly requests the event_loop fixture, while + another event loop with {scope} scope is provided by {scoped_loop_node}. Remove "event_loop" from the requested fixture in your test to run the test - in a larger-scoped event loop or remove the "asyncio_event_loop" mark to run - the test in a function-scoped event loop. + in a {scope}-scoped event loop or remove the scope argument from the "asyncio" + mark to run the test in a function-scoped event loop. """ ) @@ -844,11 +876,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 +897,24 @@ 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, + "package": Package, + "session": Session, + } + 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.""" @@ -880,6 +931,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_class_marker.py b/tests/markers/test_class_scope.py similarity index 80% rename from tests/markers/test_class_marker.py rename to tests/markers/test_class_scope.py index e06a34d8..33e5d2db 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( @@ -140,9 +137,7 @@ def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): pass - @pytest.mark.asyncio_event_loop class TestUsesCustomEventLoop: - @pytest.fixture(scope="class") def event_loop_policy(self): return CustomEventLoopPolicy() @@ -167,7 +162,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 +173,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 +182,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 +193,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 +204,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/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) 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) 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