diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index a3fab017..2ad2213c 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,13 +2,24 @@ 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) =================== -- 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/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..7317ff1d 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 @@ -19,21 +20,22 @@ List, Literal, Optional, - Set, TypeVar, Union, overload, ) 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, @@ -200,85 +202,33 @@ 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") - 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 - 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) - 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." - ) - ) - _inject_fixture_argnames(fixturedef, event_loop_fixture_id) - _synchronize_async_fixture(fixturedef, event_loop_fixture_id) - 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): - 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, event_loop_fixture_id: str -) -> None: +def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: """ Wraps the fixture function of an async fixture in a synchronous function. """ if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) + _wrap_asyncgen_fixture(fixturedef) elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef, event_loop_fixture_id) + _wrap_async_fixture(fixturedef) def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], - event_loop_fixture_id: str, - event_loop: asyncio.AbstractEventLoop, request: 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 @@ -301,7 +251,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) @@ -309,10 +259,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) - gen_obj = func( - **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) - ) + event_loop = asyncio.get_event_loop() + gen_obj = func(**_add_kwargs(func, kwargs, request)) async def setup(): res = await gen_obj.__anext__() @@ -340,7 +288,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) @@ -348,15 +296,12 @@ 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) 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()) + return asyncio.get_event_loop().run_until_complete(setup()) fixturedef.func = _async_fixture_wrapper @@ -492,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) @@ -542,54 +469,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 +559,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 +579,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) @@ -659,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 @@ -697,7 +653,23 @@ def pytest_fixture_setup( pass policy.set_event_loop(loop) return - + 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 " + 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 @@ -844,11 +816,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 +837,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 +871,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/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/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/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/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) 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( 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