diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc81f2f5..4e5d2f8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: - id: mypy exclude: ^(docs|tests)/.* - repo: https://github.com/pycqa/flake8 - rev: 5.0.4 + rev: 6.1.0 hooks: - id: flake8 language_version: python3 diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index 77204145..13c5080b 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -6,6 +6,8 @@ Changelog ================== - Remove support for Python 3.7 - Declare support for Python 3.12 +- Class-scoped and module-scoped event loops can be requested + via the _asyncio_event_loop_ mark. `#620 `_ 0.21.1 (2023-07-12) =================== diff --git a/docs/source/reference/markers.rst b/docs/source/reference/markers.rst index eb89592c..68d5efd3 100644 --- a/docs/source/reference/markers.rst +++ b/docs/source/reference/markers.rst @@ -30,5 +30,151 @@ In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is automatically to *async* test functions. +``pytest.mark.asyncio_event_loop`` +================================== +Test classes or modules with this mark provide a class-scoped or module-scoped asyncio event loop. + +This functionality is orthogonal to the `asyncio` mark. +That means the presence of this mark does not imply that async test functions inside the class or module are collected by pytest-asyncio. +The collection happens automatically in `auto` mode. +However, if you're using strict mode, you still have to apply the `asyncio` mark to your async test functions. + +The following code example uses the `asyncio_event_loop` mark to provide a shared event loop for all tests in `TestClassScopedLoop`: + +.. code-block:: python + + import asyncio + + import pytest + + + @pytest.mark.asyncio_event_loop + 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 + +In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted: + +.. code-block:: python + + 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 + +Similarly, a module-scoped loop is provided when adding the `asyncio_event_loop` mark to the module: + +.. code-block:: python + + 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 + +The `asyncio_event_loop` mark supports an optional `policy` keyword argument to set the asyncio event loop policy. + +.. code-block:: python + + import asyncio + + import pytest + + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + + @pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) + class TestUsesCustomEventLoopPolicy: + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(self): + assert isinstance(asyncio.get_event_loop_policy(), CustomEventLoopPolicy) + + +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: + +.. code-block:: python + + import asyncio + + import pytest + + import pytest_asyncio + + + @pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + uvloop.EventLoopPolicy(), + ] + ) + class TestWithDifferentLoopPolicies: + @pytest.mark.asyncio + async def test_parametrized_loop(self): + pass + + +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: + +.. code-block:: python + + import asyncio + + import pytest + + import pytest_asyncio + + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + 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 + + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c07dfced..7d41779f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -21,19 +21,22 @@ Set, TypeVar, Union, - cast, overload, ) import pytest +from _pytest.mark.structures import get_unpacked_marks from pytest import ( + Collector, Config, FixtureRequest, Function, Item, + Metafunc, Parser, PytestPluginManager, Session, + StashKey, ) _R = TypeVar("_R") @@ -55,6 +58,14 @@ SubRequest = Any +class PytestAsyncioError(Exception): + """Base class for exceptions raised by pytest-asyncio""" + + +class MultipleEventLoopsRequestedError(PytestAsyncioError): + """Raised when a test requests multiple asyncio event loops.""" + + class Mode(str, enum.Enum): AUTO = "auto" STRICT = "strict" @@ -176,6 +187,12 @@ 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) @@ -186,11 +203,17 @@ def pytest_report_header(config: Config) -> List[str]: def _preprocess_async_fixtures( - config: Config, + 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 @@ -203,37 +226,42 @@ def _preprocess_async_fixtures( # This applies to pytest_trio fixtures, for example continue _make_asyncio_fixture_function(func) - _inject_fixture_argnames(fixturedef) - _synchronize_async_fixture(fixturedef) + _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) -> None: +def _inject_fixture_argnames( + fixturedef: FixtureDef, event_loop_fixture_id: str +) -> None: """ Ensures that `request` and `event_loop` are arguments of the specified fixture. """ to_add = [] - for name in ("request", "event_loop"): + for name in ("request", event_loop_fixture_id): if name not in fixturedef.argnames: to_add.append(name) if to_add: fixturedef.argnames += tuple(to_add) -def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: +def _synchronize_async_fixture( + fixturedef: FixtureDef, event_loop_fixture_id: str +) -> None: """ Wraps the fixture function of an async fixture in a synchronous function. """ if inspect.isasyncgenfunction(fixturedef.func): - _wrap_asyncgen_fixture(fixturedef) + _wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id) elif inspect.iscoroutinefunction(fixturedef.func): - _wrap_async_fixture(fixturedef) + _wrap_async_fixture(fixturedef, event_loop_fixture_id) def _add_kwargs( func: Callable[..., Any], kwargs: Dict[str, Any], + event_loop_fixture_id: str, event_loop: asyncio.AbstractEventLoop, request: SubRequest, ) -> Dict[str, Any]: @@ -241,8 +269,8 @@ def _add_kwargs( ret = kwargs.copy() if "request" in sig.parameters: ret["request"] = request - if "event_loop" in sig.parameters: - ret["event_loop"] = event_loop + if event_loop_fixture_id in sig.parameters: + ret[event_loop_fixture_id] = event_loop return ret @@ -265,17 +293,18 @@ def _perhaps_rebind_fixture_func( return func -def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: +def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: fixture = fixturedef.func @functools.wraps(fixture) - def _asyncgen_fixture_wrapper( - event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any - ): + def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any): func = _perhaps_rebind_fixture_func( fixture, request.instance, fixturedef.unittest ) - gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + event_loop = kwargs.pop(event_loop_fixture_id) + gen_obj = func( + **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + ) async def setup(): res = await gen_obj.__anext__() @@ -303,19 +332,20 @@ async def async_finalizer() -> None: fixturedef.func = _asyncgen_fixture_wrapper -def _wrap_async_fixture(fixturedef: FixtureDef) -> None: +def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None: fixture = fixturedef.func @functools.wraps(fixture) - def _async_fixture_wrapper( - event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any - ): + 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, request)) + res = await func( + **_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request) + ) return res return event_loop.run_until_complete(setup()) @@ -335,10 +365,69 @@ def pytest_pycollect_makeitem( """A pytest hook to collect asyncio coroutines.""" if not collector.funcnamefilter(name): return None - _preprocess_async_fixtures(collector.config, _HOLDER) + _preprocess_async_fixtures(collector, _HOLDER) return None +_event_loop_fixture_id = StashKey[str] + + +@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, consider_mro=True) + for mark in marks: + if not mark.name == "asyncio_event_loop": + continue + event_loop_policy = mark.kwargs.get("policy", asyncio.get_event_loop_policy()) + policy_params = ( + event_loop_policy + if isinstance(event_loop_policy, Iterable) + else (event_loop_policy,) + ) + + # There seem to be issues when a fixture is shadowed by another fixture + # and both differ in their params. + # https://github.com/pytest-dev/pytest/issues/2043 + # https://github.com/pytest-dev/pytest/issues/11350 + # As such, we assign a unique name for each event_loop fixture. + # The fixture name is stored in the collector's Stash, so it can + # be injected when setting up the test + event_loop_fixture_id = f"{collector.nodeid}::" + collector.stash[_event_loop_fixture_id] = event_loop_fixture_id + + @pytest.fixture( + scope="class" if isinstance(collector, pytest.Class) else "module", + name=event_loop_fixture_id, + params=policy_params, + ids=tuple(type(policy).__name__ for policy in policy_params), + ) + def scoped_event_loop( + *args, # Function needs to accept "cls" when collected by pytest.Class + request, + ) -> Iterator[asyncio.AbstractEventLoop]: + new_loop_policy = request.param + 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 + + def pytest_collection_modifyitems( session: Session, config: Config, items: List[Item] ) -> None: @@ -372,6 +461,36 @@ def _hypothesis_test_wraps_coroutine(function: Any) -> bool: return _is_coroutine(function.hypothesis.inner_test) +@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 + + @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup( fixturedef: FixtureDef, request: SubRequest @@ -472,19 +591,15 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: """ marker = pyfuncitem.get_closest_marker("asyncio") if marker is not None: - funcargs: Dict[str, object] = pyfuncitem.funcargs # type: ignore[name-defined] - loop = cast(asyncio.AbstractEventLoop, funcargs["event_loop"]) if _is_hypothesis_test(pyfuncitem.obj): pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( pyfuncitem, pyfuncitem.obj.hypothesis.inner_test, - _loop=loop, ) else: pyfuncitem.obj = wrap_in_sync( pyfuncitem, pyfuncitem.obj, - _loop=loop, ) yield @@ -496,7 +611,6 @@ def _is_hypothesis_test(function: Any) -> bool: def wrap_in_sync( pyfuncitem: pytest.Function, func: Callable[..., Awaitable[Any]], - _loop: asyncio.AbstractEventLoop, ): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -522,6 +636,7 @@ def inner(*args, **kwargs): ) ) return + _loop = asyncio.get_event_loop() task = asyncio.ensure_future(coro, loop=_loop) try: _loop.run_until_complete(task) @@ -537,15 +652,32 @@ def inner(*args, **kwargs): return inner +_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. + 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. + """ +) + + 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 fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests if "event_loop" in fixturenames: fixturenames.remove("event_loop") - fixturenames.insert(0, "event_loop") + fixturenames.insert(0, event_loop_fixture_id) obj = getattr(item, "obj", None) if not getattr(obj, "hypothesis", False) and getattr( obj, "is_hypothesis_test", False diff --git a/tests/async_fixtures/test_parametrized_loop.py b/tests/async_fixtures/test_parametrized_loop.py index 2fb8befa..2bdbe5e8 100644 --- a/tests/async_fixtures/test_parametrized_loop.py +++ b/tests/async_fixtures/test_parametrized_loop.py @@ -1,31 +1,46 @@ -import asyncio +from textwrap import dedent -import pytest +from pytest import Pytester -TESTS_COUNT = 0 +def test_event_loop_parametrization(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio -def teardown_module(): - # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' - assert TESTS_COUNT == 4 + import pytest + import pytest_asyncio + TESTS_COUNT = 0 -@pytest.fixture(scope="module", params=[1, 2]) -def event_loop(request): - request.param - loop = asyncio.new_event_loop() - yield loop - loop.close() + def teardown_module(): + # parametrized 2 * 2 times: 2 for 'event_loop' and 2 for 'fix' + assert TESTS_COUNT == 4 -@pytest.fixture(params=["a", "b"]) -async def fix(request): - await asyncio.sleep(0) - return request.param + @pytest.fixture(scope="module", params=[1, 2]) + def event_loop(request): + request.param + loop = asyncio.new_event_loop() + yield loop + loop.close() -@pytest.mark.asyncio -async def test_parametrized_loop(fix): - await asyncio.sleep(0) - global TESTS_COUNT - TESTS_COUNT += 1 + + @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/loop_fixture_scope/conftest.py b/tests/loop_fixture_scope/conftest.py index 223160c2..6b9a7649 100644 --- a/tests/loop_fixture_scope/conftest.py +++ b/tests/loop_fixture_scope/conftest.py @@ -7,11 +7,9 @@ class CustomSelectorLoop(asyncio.SelectorEventLoop): """A subclass with no overrides, just to test for presence.""" -loop = CustomSelectorLoop() - - @pytest.fixture(scope="module") def event_loop(): """Create an instance of the default event loop for each test case.""" + loop = CustomSelectorLoop() yield loop loop.close() diff --git a/tests/markers/test_class_marker.py b/tests/markers/test_class_marker.py index d46c3af7..68425575 100644 --- a/tests/markers/test_class_marker.py +++ b/tests/markers/test_class_marker.py @@ -1,5 +1,6 @@ """Test if pytestmark works when defined on a class.""" import asyncio +from textwrap import dedent import pytest @@ -23,3 +24,196 @@ async def inc(): @pytest.fixture def sample_fixture(): return None + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_strict_mode( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + 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 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_auto_mode( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + 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 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_is_inherited_to_subclasses(pytester: pytest.Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + @pytest.mark.asyncio_event_loop + class TestSuperClassWithMark: + 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) + + +def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop( + 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 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *") + + +def test_asyncio_event_loop_mark_allows_specifying_the_loop_policy( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + class CustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + pass + + @pytest.mark.asyncio_event_loop(policy=CustomEventLoopPolicy()) + class TestUsesCustomEventLoopPolicy: + + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(self): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + + @pytest.mark.asyncio + async def test_does_not_use_custom_event_loop_policy(): + assert not isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + @pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ] + ) + class TestWithDifferentLoopPolicies: + @pytest.mark.asyncio + async def test_parametrized_loop(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest.mark.asyncio_event_loop + class TestClassScopedLoop: + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + 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 + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/markers/test_module_marker.py b/tests/markers/test_module_marker.py index 2f69dbc9..f6cd8762 100644 --- a/tests/markers/test_module_marker.py +++ b/tests/markers/test_module_marker.py @@ -1,39 +1,245 @@ -"""Test if pytestmark works when defined in a module.""" -import asyncio +from textwrap import dedent -import pytest +from pytest import Pytester -pytestmark = pytest.mark.asyncio +def test_asyncio_mark_works_on_module_level(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio -class TestPyTestMark: - async def test_is_asyncio(self, event_loop, sample_fixture): - assert asyncio.get_event_loop() + import pytest - counter = 1 + pytestmark = pytest.mark.asyncio - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) - await asyncio.ensure_future(inc()) - assert counter == 2 + class TestPyTestMark: + async def test_is_asyncio(self, event_loop, sample_fixture): + assert asyncio.get_event_loop() + counter = 1 -async def test_is_asyncio(event_loop, sample_fixture): - assert asyncio.get_event_loop() - counter = 1 + async def inc(): + nonlocal counter + counter += 1 + await asyncio.sleep(0) - async def inc(): - nonlocal counter - counter += 1 - await asyncio.sleep(0) + await asyncio.ensure_future(inc()) + assert counter == 2 - await asyncio.ensure_future(inc()) - assert counter == 2 + async def test_is_asyncio(event_loop, sample_fixture): + assert asyncio.get_event_loop() + counter = 1 -@pytest.fixture -def sample_fixture(): - return None + async def inc(): + nonlocal counter + counter += 1 + await asyncio.sleep(0) + + await asyncio.ensure_future(inc()) + assert counter == 2 + + + @pytest.fixture + def sample_fixture(): + return None + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + 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 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + 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, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytestmark = pytest.mark.asyncio_event_loop + + @pytest.mark.asyncio + 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_event_loop_mark_allows_specifying_the_loop_policy( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + 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_event_loop(policy=CustomEventLoopPolicy()) + + @pytest.mark.asyncio + async def test_uses_custom_event_loop_policy(): + assert isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + test_does_not_use_custom_policy=dedent( + """\ + import asyncio + import pytest + + from .custom_policy import CustomEventLoopPolicy + + @pytest.mark.asyncio + async def test_does_not_use_custom_event_loop_policy(): + assert not isinstance( + asyncio.get_event_loop_policy(), + CustomEventLoopPolicy, + ) + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_allows_specifying_multiple_loop_policies( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + + pytestmark = pytest.mark.asyncio_event_loop( + policy=[ + asyncio.DefaultEventLoopPolicy(), + asyncio.DefaultEventLoopPolicy(), + ] + ) + + @pytest.mark.asyncio + async def test_parametrized_loop(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + pytestmark = pytest.mark.asyncio_event_loop + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture + 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 + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/multiloop/conftest.py b/tests/multiloop/conftest.py deleted file mode 100644 index ebcb627a..00000000 --- a/tests/multiloop/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -import asyncio - -import pytest - - -class CustomSelectorLoop(asyncio.SelectorEventLoop): - """A subclass with no overrides, just to test for presence.""" - - -@pytest.fixture -def event_loop(): - """Create an instance of the default event loop for each test case.""" - loop = CustomSelectorLoop() - yield loop - loop.close() diff --git a/tests/multiloop/test_alternative_loops.py b/tests/multiloop/test_alternative_loops.py deleted file mode 100644 index 5f66c967..00000000 --- a/tests/multiloop/test_alternative_loops.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Unit tests for overriding the event loop.""" -import asyncio - -import pytest - - -@pytest.mark.asyncio -async def test_for_custom_loop(): - """This test should be executed using the custom loop.""" - await asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" - - -@pytest.mark.asyncio -async def test_dependent_fixture(dependent_fixture): - await asyncio.sleep(0.1) diff --git a/tests/test_multiloop.py b/tests/test_multiloop.py new file mode 100644 index 00000000..6c47d68c --- /dev/null +++ b/tests/test_multiloop.py @@ -0,0 +1,70 @@ +from textwrap import dedent + +from pytest import Pytester + + +def test_event_loop_override(pytester: Pytester): + pytester.makeconftest( + dedent( + '''\ + import asyncio + + import pytest + + + @pytest.fixture + def dependent_fixture(event_loop): + """A fixture dependent on the event_loop fixture, doing some cleanup.""" + counter = 0 + + async def just_a_sleep(): + """Just sleep a little while.""" + nonlocal event_loop + await asyncio.sleep(0.1) + nonlocal counter + counter += 1 + + event_loop.run_until_complete(just_a_sleep()) + yield + event_loop.run_until_complete(just_a_sleep()) + + assert counter == 2 + + + class CustomSelectorLoop(asyncio.SelectorEventLoop): + """A subclass with no overrides, just to test for presence.""" + + + @pytest.fixture + def event_loop(): + """Create an instance of the default event loop for each test case.""" + loop = CustomSelectorLoop() + yield loop + loop.close() + ''' + ) + ) + pytester.makepyfile( + dedent( + '''\ + """Unit tests for overriding the event loop.""" + import asyncio + + import pytest + + + @pytest.mark.asyncio + async def test_for_custom_loop(): + """This test should be executed using the custom loop.""" + await asyncio.sleep(0.01) + assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" + + + @pytest.mark.asyncio + async def test_dependent_fixture(dependent_fixture): + await asyncio.sleep(0.1) + ''' + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=2)