From 2cc4c25df2f87c96ed4d9655a8653b1c4bd5e5a4 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 15 Nov 2023 13:43:06 +0100 Subject: [PATCH 1/4] [test] Add explcitit warning filter to tests that expect warnings. This avoids failing tests when running the tests with "python -m pytest" and installing a warning filter on the interpreter level. This can be useful for running tests in development mode, i.e. "python -Xdev -m pytest". Signed-off-by: Michael Seifert --- tests/markers/test_function_scope.py | 2 +- tests/markers/test_module_scope.py | 7 +++++-- tests/test_event_loop_fixture_finalizer.py | 7 +++++-- tests/test_event_loop_fixture_override_deprecation.py | 6 +++--- tests/test_explicit_event_loop_fixture_request.py | 10 +++++----- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py index be45e5dd..df2c3e47 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -43,7 +43,7 @@ async def test_remember_loop(event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( '*is asynchronous and explicitly requests the "event_loop" fixture*' diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 1cd8ac65..1034af83 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -48,8 +48,11 @@ def sample_fixture(): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=2) + result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=2, warnings=2) + result.stdout.fnmatch_lines( + '*is asynchronous and explicitly requests the "event_loop" fixture*' + ) def test_asyncio_mark_provides_module_scoped_loop_strict_mode(pytester: Pytester): diff --git a/tests/test_event_loop_fixture_finalizer.py b/tests/test_event_loop_fixture_finalizer.py index 5f8f9574..eabb54a3 100644 --- a/tests/test_event_loop_fixture_finalizer.py +++ b/tests/test_event_loop_fixture_finalizer.py @@ -84,8 +84,11 @@ async def test_async_with_explicit_fixture_request(event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) + result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines( + '*is asynchronous and explicitly requests the "event_loop" fixture*' + ) def test_event_loop_fixture_finalizer_raises_warning_when_fixture_leaves_loop_unclosed( diff --git a/tests/test_event_loop_fixture_override_deprecation.py b/tests/test_event_loop_fixture_override_deprecation.py index 3484ef76..45afc542 100644 --- a/tests/test_event_loop_fixture_override_deprecation.py +++ b/tests/test_event_loop_fixture_override_deprecation.py @@ -22,7 +22,7 @@ async def test_emits_warning(): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ["*event_loop fixture provided by pytest-asyncio has been redefined*"] @@ -50,7 +50,7 @@ async def test_emits_warning_when_requested_explicitly(event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=2) result.stdout.fnmatch_lines( ["*event_loop fixture provided by pytest-asyncio has been redefined*"] @@ -107,5 +107,5 @@ def test_emits_warning(uses_event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") 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..4cac85f7 100644 --- a/tests/test_explicit_event_loop_fixture_request.py +++ b/tests/test_explicit_event_loop_fixture_request.py @@ -17,7 +17,7 @@ async def test_coroutine_emits_warning(event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] @@ -39,7 +39,7 @@ async def test_coroutine_emits_warning(self, event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] @@ -62,7 +62,7 @@ async def test_coroutine_emits_warning(event_loop): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] @@ -88,7 +88,7 @@ async def test_uses_fixture(emits_warning): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] @@ -114,7 +114,7 @@ async def test_uses_fixture(emits_warning): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") + result = pytester.runpytest("--asyncio-mode=strict", "-W default") result.assert_outcomes(passed=1, warnings=1) result.stdout.fnmatch_lines( ['*is asynchronous and explicitly requests the "event_loop" fixture*'] From a6fc9359adcc51b3ed489432e74e99873752f782 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 15 Nov 2023 13:53:48 +0100 Subject: [PATCH 2/4] [test] Added missing warning assertion to hypothesis test. Signed-off-by: Michael Seifert --- tests/hypothesis/test_base.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index aef20d79..299c18fa 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -54,8 +54,14 @@ async def test_explicit_fixture_request(event_loop, n): """ ) ) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(passed=1) + result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1, warnings=2) + result.stdout.fnmatch_lines( + [ + '*is asynchronous and explicitly requests the "event_loop" fixture*', + "*event_loop fixture provided by pytest-asyncio has been redefined*", + ] + ) def test_async_auto_marked(pytester: Pytester): From 257e7024976777afef4157ff39b954160a6312bf Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 15 Nov 2023 13:54:22 +0100 Subject: [PATCH 3/4] [refactor] Use Pytester for one of the Hypothesis tests. Signed-off-by: Michael Seifert --- tests/hypothesis/test_base.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index 299c18fa..c2a7ea6a 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -8,10 +8,22 @@ from pytest import Pytester -@given(st.integers()) -@pytest.mark.asyncio -async def test_mark_inner(n): - assert isinstance(n, int) +def test_hypothesis_given_decorator_before_asyncio_mark(pytester: Pytester): + pytester.makepyfile( + dedent( + """\ + import pytest + from hypothesis import given, strategies as st + + @given(st.integers()) + @pytest.mark.asyncio + async def test_mark_inner(n): + assert isinstance(n, int) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict", "-W default") + result.assert_outcomes(passed=1) @pytest.mark.asyncio From fbc32e0904291dc34c412fd809514f0bc6d43dd8 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Wed, 15 Nov 2023 13:55:24 +0100 Subject: [PATCH 4/4] [fix] Fixes a bug that caused tests to run in the wrong event loop when requesting larger-scoped fixtures in a narrower-scoped test. Previously, pytest-asyncio relied on marks applied to pytest Collectors (e.g. classes and modules) to determine the loop scope. This logic is no longer applicable for fixtures, because pytest-asyncio now relies on the asyncio mark applied to tests. As a result, fixtures were looking for an "asyncio" mark on surrounding collectors to no avail and defaulted to choosing a function-scoped loop. This patch chooses the loop scope based on the fixture scope. Signed-off-by: Michael Seifert --- ...d_loop_with_fixture_strict_mode_example.py | 2 +- pytest_asyncio/plugin.py | 38 ++++-- .../test_async_fixtures_with_finalizer.py | 4 +- tests/markers/test_class_scope.py | 31 +++++ tests/markers/test_module_scope.py | 61 +++++++++ tests/markers/test_package_scope.py | 91 +++++++++++++ tests/markers/test_session_scope.py | 121 ++++++++++++++++++ 7 files changed, 336 insertions(+), 12 deletions(-) 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 f912dec9..538f1bd2 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 @@ -9,7 +9,7 @@ class TestClassScopedLoop: loop: asyncio.AbstractEventLoop - @pytest_asyncio.fixture + @pytest_asyncio.fixture(scope="class") async def my_fixture(self): TestClassScopedLoop.loop = asyncio.get_running_loop() diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 942ad4de..dfbd9958 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -204,13 +204,6 @@ def _preprocess_async_fixtures( config = collector.config asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") - marker = collector.get_closest_marker("asyncio") - scope = marker.kwargs.get("scope", "function") if marker else "function" - if scope == "function": - event_loop_fixture_id = "event_loop" - else: - event_loop_node = _retrieve_scope_root(collector, scope) - event_loop_fixture_id = event_loop_node.stash.get(_event_loop_fixture_id, None) for fixtures in fixturemanager._arg2fixturedefs.values(): for fixturedef in fixtures: func = fixturedef.func @@ -222,6 +215,14 @@ def _preprocess_async_fixtures( # Ignore async fixtures without explicit asyncio mark in strict mode # This applies to pytest_trio fixtures, for example continue + scope = fixturedef.scope + 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 + ) _make_asyncio_fixture_function(func) function_signature = inspect.signature(func) if "event_loop" in function_signature.parameters: @@ -589,6 +590,12 @@ def scoped_event_loop( yield loop loop.close() asyncio.set_event_loop_policy(old_loop_policy) + # When a test uses both a scoped event loop and the event_loop fixture, + # the "_provide_clean_event_loop" finalizer of the event_loop fixture + # will already have installed a fresh event loop, in order to shield + # subsequent tests from side-effects. We close this loop before restoring + # the old loop to avoid ResourceWarnings. + asyncio.get_event_loop().close() asyncio.set_event_loop(old_loop) # @pytest.fixture does not register the fixture anywhere, so pytest doesn't @@ -680,7 +687,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: ) # 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) + # The fixture needs to be appended to avoid messing up the fixture evaluation + # order + metafunc.fixturenames.append(event_loop_fixture_id) metafunc._arg2fixturedefs[ event_loop_fixture_id ] = fixturemanager._arg2fixturedefs[event_loop_fixture_id] @@ -885,8 +894,13 @@ def pytest_runtest_setup(item: pytest.Item) -> None: fixturenames = item.fixturenames # type: ignore[attr-defined] # inject an event loop fixture for all async tests if "event_loop" in fixturenames: + # Move the "event_loop" fixture to the beginning of the fixture evaluation + # closure for backwards compatibility fixturenames.remove("event_loop") - fixturenames.insert(0, event_loop_fixture_id) + fixturenames.insert(0, "event_loop") + else: + if event_loop_fixture_id not in fixturenames: + fixturenames.append(event_loop_fixture_id) obj = getattr(item, "obj", None) if not getattr(obj, "hypothesis", False) and getattr( obj, "is_hypothesis_test", False @@ -944,6 +958,12 @@ def _session_event_loop( yield loop loop.close() asyncio.set_event_loop_policy(old_loop_policy) + # When a test uses both a scoped event loop and the event_loop fixture, + # the "_provide_clean_event_loop" finalizer of the event_loop fixture + # will already have installed a fresh event loop, in order to shield + # subsequent tests from side-effects. We close this loop before restoring + # the old loop to avoid ResourceWarnings. + asyncio.get_event_loop().close() asyncio.set_event_loop(old_loop) diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py index aa2ce3d7..699ac49d 100644 --- a/tests/async_fixtures/test_async_fixtures_with_finalizer.py +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -4,13 +4,13 @@ import pytest -@pytest.mark.asyncio +@pytest.mark.asyncio(scope="module") async def test_module_with_event_loop_finalizer(port_with_event_loop_finalizer): await asyncio.sleep(0.01) assert port_with_event_loop_finalizer -@pytest.mark.asyncio +@pytest.mark.asyncio(scope="module") async def test_module_with_get_event_loop_finalizer(port_with_get_event_loop_finalizer): await asyncio.sleep(0.01) assert port_with_get_event_loop_finalizer diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 33e5d2db..1f664774 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -220,3 +220,34 @@ async def test_runs_is_same_loop_as_fixture(self, my_fixture): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_class_scoped_fixture_with_function_scoped_test( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + class TestMixedScopes: + @pytest_asyncio.fixture(scope="class") + async def async_fixture(self): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="function") + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 1034af83..b778c9a9 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -219,3 +219,64 @@ async def test_runs_is_same_loop_as_fixture(my_fixture): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_module_scoped_fixture_with_class_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_module_scoped_fixture_with_function_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="function") + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index fde2e836..3d898c8d 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -223,3 +223,94 @@ async def test_runs_in_same_loop_as_fixture(my_fixture): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_package_scoped_fixture_with_module_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="module") + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_package_scoped_fixture_with_class_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_package_scoped_fixture_with_function_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="package") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py index 1242cfee..a9a8b7a8 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -227,3 +227,124 @@ async def test_runs_in_same_loop_as_fixture(my_fixture): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_session_scoped_fixture_with_package_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="package") + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_session_scoped_fixture_with_module_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="module") + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_session_scoped_fixture_with_class_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(scope="class") + class TestMixedScopes: + async def test_runs_in_different_loop_as_fixture(self, async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_asyncio_mark_allows_combining_session_scoped_fixture_with_function_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1)