diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6f240e0a..065c87fc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: id: version run: python ./tools/get-version.py >> $GITHUB_OUTPUT - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist @@ -84,10 +84,10 @@ jobs: run: python -m tox - name: Store coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: "!endsWith(matrix.os, 'windows')" with: - name: coverage-per-interpreter + name: coverage-python-${{ matrix.python-version }} path: .coverage.* check: @@ -109,9 +109,10 @@ jobs: set -xe python -m pip install --upgrade coverage[toml] - name: Download coverage data for all test runs - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: coverage-per-interpreter + pattern: coverage-* + merge-multiple: true - name: Combine coverage data and create report run: | coverage combine @@ -136,7 +137,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Download distributions - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: dist path: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed7d0cb0..ee649b19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,10 +37,12 @@ repos: - id: check-yaml - id: debug-statements - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.8.0 hooks: - id: mypy exclude: ^(docs|tests)/.* + additional_dependencies: + - pytest - repo: https://github.com/pycqa/flake8 rev: 6.1.0 hooks: diff --git a/dependencies/default/constraints.txt b/dependencies/default/constraints.txt index 73bb677a..453e3570 100644 --- a/dependencies/default/constraints.txt +++ b/dependencies/default/constraints.txt @@ -1,11 +1,11 @@ attrs==23.2.0 -coverage==7.4.0 +coverage==7.4.1 exceptiongroup==1.2.0 -hypothesis==6.92.5 +hypothesis==6.98.2 iniconfig==2.0.0 packaging==23.2 -pluggy==1.3.0 -pytest==7.4.4 +pluggy==1.4.0 +pytest==8.0.0 sortedcontainers==2.4.0 tomli==2.0.1 typing_extensions==4.9.0 diff --git a/dependencies/default/requirements.txt b/dependencies/default/requirements.txt index 107f59f1..3ac25aba 100644 --- a/dependencies/default/requirements.txt +++ b/dependencies/default/requirements.txt @@ -1,3 +1,3 @@ # Always adjust install_requires in setup.cfg and pytest-min-requirements.txt # when changing runtime dependencies -pytest >= 7.0.0,<8 +pytest >= 7.0.0,<9 diff --git a/dependencies/docs/constraints.txt b/dependencies/docs/constraints.txt index a8810c2d..f860409c 100644 --- a/dependencies/docs/constraints.txt +++ b/dependencies/docs/constraints.txt @@ -1,23 +1,23 @@ -alabaster==0.7.15 +alabaster==0.7.16 Babel==2.14.0 -certifi==2023.11.17 +certifi==2024.2.2 charset-normalizer==3.3.2 docutils==0.18.1 idna==3.6 imagesize==1.4.1 -Jinja2==3.1.2 -MarkupSafe==2.1.3 +Jinja2==3.1.3 +MarkupSafe==2.1.5 packaging==23.2 Pygments==2.17.2 requests==2.31.0 snowballstemmer==2.2.0 Sphinx==7.2.6 sphinx-rtd-theme==2.0.0 -sphinxcontrib-applehelp==1.0.7 -sphinxcontrib-devhelp==1.0.5 -sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-applehelp==1.0.8 +sphinxcontrib-devhelp==1.0.6 +sphinxcontrib-htmlhelp==2.0.5 sphinxcontrib-jquery==4.1 sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.6 -sphinxcontrib-serializinghtml==1.1.9 -urllib3==2.1.0 +sphinxcontrib-qthelp==1.0.7 +sphinxcontrib-serializinghtml==1.1.10 +urllib3==2.2.0 diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index c0bdf5da..31f2d71e 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,17 @@ Changelog ========= +0.23.5 (2024-02-09) +=================== +- Declare compatibility with pytest 8 `#737 `_ +- Fix typing errors with recent versions of mypy `#769 `_ +- Prevent DeprecationWarning about internal use of `asyncio.get_event_loop()` from affecting test cases `#757 `_ + +Known issues +------------ +As of v0.23, pytest-asyncio attaches an asyncio event loop to each item of the test suite (i.e. session, packages, modules, classes, functions) and allows tests to be run in those loops when marked accordingly. Pytest-asyncio currently assumes that async fixture scope is correlated with the new event loop scope. This prevents fixtures from being evaluated independently from the event loop scope and breaks some existing test suites (see `#706`_). For example, a test suite may require all fixtures and tests to run in the same event loop, but have async fixtures that are set up and torn down for each module. If you're affected by this issue, please continue using the v0.21 release, until it is resolved. + + 0.23.4 (2024-01-28) =================== - pytest-asyncio no longer imports additional, unrelated packages during test collection `#729 `_ diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index b0744f0d..36066cf2 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -16,11 +16,14 @@ Awaitable, Callable, Dict, + Generator, Iterable, Iterator, List, Literal, + Mapping, Optional, + Sequence, Set, Type, TypeVar, @@ -47,16 +50,14 @@ StashKey, ) -_R = TypeVar("_R") - _ScopeName = Literal["session", "package", "module", "class", "function"] _T = TypeVar("_T") SimpleFixtureFunction = TypeVar( - "SimpleFixtureFunction", bound=Callable[..., Awaitable[_R]] + "SimpleFixtureFunction", bound=Callable[..., Awaitable[object]] ) FactoryFixtureFunction = TypeVar( - "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[_R]] + "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[object]] ) FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] @@ -204,6 +205,7 @@ def _preprocess_async_fixtures( config = collector.config asyncio_mode = _get_asyncio_mode(config) fixturemanager = config.pluginmanager.get_plugin("funcmanage") + assert fixturemanager is not None for fixtures in fixturemanager._arg2fixturedefs.values(): for fixturedef in fixtures: func = fixturedef.func @@ -217,11 +219,13 @@ def _preprocess_async_fixtures( continue scope = fixturedef.scope if scope == "function": - event_loop_fixture_id = "event_loop" + event_loop_fixture_id: Optional[str] = "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 + # Type ignored because of non-optimal mypy inference. + _event_loop_fixture_id, # type: ignore[arg-type] + None, ) _make_asyncio_fixture_function(func) function_signature = inspect.signature(func) @@ -234,8 +238,15 @@ def _preprocess_async_fixtures( f"instead." ) ) - _inject_fixture_argnames(fixturedef, event_loop_fixture_id) - _synchronize_async_fixture(fixturedef, event_loop_fixture_id) + assert event_loop_fixture_id + _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) @@ -481,9 +492,11 @@ class AsyncHypothesisTest(PytestAsyncioFunction): @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj - return getattr( - func, "is_hypothesis_test", False - ) and asyncio.iscoroutinefunction(func.hypothesis.inner_test) + return ( + getattr(func, "is_hypothesis_test", False) # type: ignore[return-value] + and getattr(func, "hypothesis", None) + and asyncio.iscoroutinefunction(func.hypothesis.inner_test) + ) def runtest(self) -> None: self.obj.hypothesis.inner_test = wrap_in_sync( @@ -510,25 +523,26 @@ def pytest_pycollect_makeitem_preprocess_async_fixtures( return None +# TODO: #778 Narrow down return type of function when dropping support for pytest 7 # 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) def pytest_pycollect_makeitem_convert_async_functions_to_subclass( collector: Union[pytest.Module, pytest.Class], name: str, obj: object -) -> Union[ - pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None -]: +) -> Generator[None, Any, None]: """ Converts coroutines and async generators collected as pytest.Functions to AsyncFunction items. """ hook_result = yield - node_or_list_of_nodes = hook_result.get_result() + node_or_list_of_nodes: Union[ + pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None + ] = hook_result.get_result() if not node_or_list_of_nodes: return - try: + if isinstance(node_or_list_of_nodes, Sequence): node_iterator = iter(node_or_list_of_nodes) - except TypeError: + else: # Treat single node as a single-element iterable node_iterator = iter((node_or_list_of_nodes,)) updated_node_collection = [] @@ -547,8 +561,8 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( hook_result.force_result(updated_node_collection) -_event_loop_fixture_id = StashKey[str] -_fixture_scope_by_collector_type = { +_event_loop_fixture_id = StashKey[str]() +_fixture_scope_by_collector_type: Mapping[Type[pytest.Collector], _ScopeName] = { Class: "class", # Package is a subclass of module and the dict is used in isinstance checks # Therefore, the order matters and Package needs to appear before Module @@ -563,7 +577,7 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( @pytest.hookimpl -def pytest_collectstart(collector: pytest.Collector): +def pytest_collectstart(collector: pytest.Collector) -> None: try: collector_scope = next( scope @@ -637,8 +651,8 @@ def _patched_collect(): pass return collector.__original_collect() - collector.__original_collect = collector.collect - collector.collect = _patched_collect + collector.__original_collect = collector.collect # type: ignore[attr-defined] + collector.collect = _patched_collect # type: ignore[method-assign] elif isinstance(collector, Class): collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop @@ -653,7 +667,9 @@ def _removesuffix(s: str, suffix: str) -> str: def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: old_loop_policy = asyncio.get_event_loop_policy() try: - old_loop = asyncio.get_event_loop() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + old_loop = asyncio.get_event_loop() except RuntimeError: old_loop = None asyncio.set_event_loop_policy(policy) @@ -706,6 +722,7 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: if event_loop_fixture_id in metafunc.fixturenames: return fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage") + assert fixturemanager is not None if "event_loop" in metafunc.fixturenames: raise MultipleEventLoopsRequestedError( _MULTIPLE_LOOPS_REQUESTED_ERROR.format( @@ -724,10 +741,11 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: ) +# TODO: #778 Narrow down return type of function when dropping support for pytest 7 @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup( - fixturedef: FixtureDef, request: SubRequest -) -> Optional[object]: + fixturedef: FixtureDef, +) -> Generator[None, Any, None]: """Adjust the event loop policy when an event loop is produced.""" if fixturedef.argname == "event_loop": # The use of a fixture finalizer is preferred over the @@ -742,7 +760,7 @@ def pytest_fixture_setup( _provide_clean_event_loop, ) outcome = yield - loop = outcome.get_result() + loop: asyncio.AbstractEventLoop = outcome.get_result() # Weird behavior was observed when checking for an attribute of FixtureDef.func # Instead, we now check for a special attribute of the returned event loop fixture_filename = inspect.getsourcefile(fixturedef.func) @@ -944,6 +962,7 @@ def _retrieve_scope_root(item: Union[Collector, Item], scope: str) -> Collector: scope_root_type = node_type_by_scope[scope] for node in reversed(item.listchain()): if isinstance(node, scope_root_type): + assert isinstance(node, pytest.Collector) return node error_message = ( f"{item.name} is marked to be run in an event loop with scope {scope}, " diff --git a/setup.cfg b/setup.cfg index 45d70b37..9fba05ae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,12 +35,12 @@ classifiers = [options] python_requires = >=3.8 -packages = find: +packages = pytest_asyncio include_package_data = True # Always adjust requirements.txt and pytest-min-requirements.txt when changing runtime dependencies install_requires = - pytest >= 7.0.0,<8 + pytest >= 7.0.0,<9 [options.extras_require] testing = diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hypothesis/__init__.py b/tests/hypothesis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/loop_fixture_scope/__init__.py b/tests/loop_fixture_scope/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/markers/__init__.py b/tests/markers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/markers/test_class_scope.py b/tests/markers/test_class_scope.py index 9ec3ed9c..3c77bab0 100644 --- a/tests/markers/test_class_scope.py +++ b/tests/markers/test_class_scope.py @@ -287,3 +287,22 @@ async def test_does_not_fail(self, sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( + pytester: pytest.Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="class") + class TestClass: + async def test_anything(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py index 25ff609f..7a5f8533 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -179,3 +179,21 @@ async def test_does_not_fail(sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index cf6b2f60..01fdc324 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -344,3 +344,21 @@ async def test_does_not_fail(sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="module") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index e0f44322..c80289be 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -350,3 +350,22 @@ async def test_does_not_fail(sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_module=dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="package") + async def test_anything(): + pass + """ + ), + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py index bd0baee5..b8b747a0 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -44,7 +44,9 @@ async def test_this_runs_in_same_loop(self): """ ), ) - subpackage_name = "subpkg" + + # subpackage_name must alphabetically come after test_module_one.py + subpackage_name = "z_subpkg" subpkg = pytester.mkpydir(subpackage_name) subpkg.joinpath("test_subpkg.py").write_text( dedent( @@ -413,3 +415,21 @@ async def test_does_not_fail(sets_event_loop_to_none, n): ) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_standalone_test_does_not_trigger_warning_about_no_current_event_loop_being_set( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import pytest + + @pytest.mark.asyncio(scope="session") + async def test_anything(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(warnings=0, passed=1) diff --git a/tests/modes/__init__.py b/tests/modes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_is_async_test.py b/tests/test_is_async_test.py index 512243b3..12e791c1 100644 --- a/tests/test_is_async_test.py +++ b/tests/test_is_async_test.py @@ -77,8 +77,10 @@ def pytest_collection_modifyitems(items): if pytest.version_tuple < (7, 2): # Probably related to https://github.com/pytest-dev/pytest/pull/10012 result.assert_outcomes(failed=1) - else: + elif pytest.version_tuple < (8,): result.assert_outcomes(skipped=1) + else: + result.assert_outcomes(failed=1) def test_returns_true_for_unmarked_coroutine_item_in_auto_mode(pytester: Pytester):