diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index b6f57af2..dfcc7c05 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,14 @@ Changelog ========= +0.23.4 (UNRELEASED) +=================== +- pytest-asyncio no longer imports additional, unrelated packages during test collection `#729 `_ + +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.3 (2024-01-01) =================== - Fixes a bug that caused event loops to be closed prematurely when using async generator fixtures with class scope or wider in a function-scoped test `#706 `_ diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index eb013f46..cfc28e8d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -8,6 +8,8 @@ import sys import warnings from asyncio import AbstractEventLoopPolicy +from pathlib import Path +from tempfile import NamedTemporaryFile from textwrap import dedent from typing import ( Any, @@ -28,6 +30,7 @@ ) import pytest +from _pytest.pathlib import visit from pytest import ( Class, Collector, @@ -620,6 +623,100 @@ def _patched_collect(): collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop return collector.__original_collect() + collector.__original_collect = collector.collect + collector.collect = _patched_collect + elif type(collector) is Package: + if not collector.funcnamefilter(collector.name): + return + + def _patched_collect(): + # pytest.Package collects all files and sub-packages. Pytest 8 changes + # this logic to only collect a single directory. Sub-packages are then + # collected by a separate Package collector. Therefore, this logic can be + # dropped, once we move to pytest 8. + collector_dir = Path(collector.path.parent) + for direntry in visit(str(collector_dir), recurse=collector._recurse): + if not direntry.name == "__init__.py": + # No need to register a package-scoped fixture, if we aren't + # collecting a (sub-)package + continue + pkgdir = Path(direntry.path).parent + pkg_nodeid = str(pkgdir.relative_to(collector_dir)) + if pkg_nodeid == ".": + pkg_nodeid = "" + # Pytest's fixture matching algorithm compares a fixture's baseid with + # an Item's nodeid to determine whether a fixture is available for a + # specific Item. Package.nodeid ends with __init__.py, so the + # fixture's baseid will also end with __init__.py and prevents + # the fixture from being matched to test items in the package. + # Furthermore, Package also collects any sub-packages, which means + # the ID of the scoped event loop for the package must change for + # each sub-package. + # As the fixture matching is purely based on string comparison, we + # can assemble a path based on the root package path + # (i.e. Package.path.parent) and the sub-package path + # (i.e. Path(direntry.path).parent)). This makes the fixture visible + # to all items in the package. + # see also https://github.com/pytest-dev/pytest/issues/11662#issuecomment-1879310072 # noqa + # Possibly related to https://github.com/pytest-dev/pytest/issues/4085 + fixture_id = ( + str(Path(pkg_nodeid).joinpath("__init__.py")) + "::" + ) + # When collector is a Package, collector.obj is the package's + # __init__.py. Accessing the __init__.py to attach the fixture function + # may trigger additional module imports or change the order of imports, + # which leads to a number of problems. + # see https://github.com/pytest-dev/pytest-asyncio/issues/729 + # Moreover, Package.obj has been removed in pytest 8. + # Therefore, pytest-asyncio creates a temporary Python module inside the + # collected package. The sole purpose of that module is to house a + # fixture function for the pacakge-scoped event loop fixture. Once the + # fixture has been evaluated by pytest, the temporary module + # can be removed. + with NamedTemporaryFile( + dir=pkgdir, + prefix="pytest_asyncio_virtual_module_", + suffix=".py", + ) as virtual_module_file: + virtual_module = Module.from_parent( + collector, path=Path(virtual_module_file.name) + ) + virtual_module_file.write( + dedent( + f"""\ + import asyncio + import pytest + from pytest_asyncio.plugin \ + import _temporary_event_loop_policy + @pytest.fixture( + scope="{collector_scope}", + name="{fixture_id}", + ) + def scoped_event_loop( + *args, + event_loop_policy, + ): + new_loop_policy = event_loop_policy + with _temporary_event_loop_policy(new_loop_policy): + loop = asyncio.new_event_loop() + loop.__pytest_asyncio = True + asyncio.set_event_loop(loop) + yield loop + loop.close() + """ + ).encode() + ) + virtual_module_file.flush() + fixturemanager = collector.config.pluginmanager.get_plugin( + "funcmanage" + ) + # Collect the fixtures in the virtual module with the node ID of + # the current sub-package to ensure correct fixture matching. + # see also https://github.com/pytest-dev/pytest/issues/11662#issuecomment-1879310072 # noqa + fixturemanager.parsefactories(virtual_module.obj, nodeid=pkg_nodeid) + yield virtual_module + yield from collector.__original_collect() + collector.__original_collect = collector.collect collector.collect = _patched_collect else: @@ -628,17 +725,6 @@ def _patched_collect(): if pyobject is None: return pyobject.__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: diff --git a/tests/markers/test_package_scope.py b/tests/markers/test_package_scope.py index 1dc8a5c9..e0f44322 100644 --- a/tests/markers/test_package_scope.py +++ b/tests/markers/test_package_scope.py @@ -46,6 +46,7 @@ async def test_this_runs_in_same_loop(self): ), ) subpkg = pytester.mkpydir(subpackage_name) + subpkg.joinpath("__init__.py").touch() subpkg.joinpath("test_subpkg.py").write_text( dedent( f"""\ diff --git a/tests/test_import.py b/tests/test_import.py index 77352150..9912ae0c 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -3,7 +3,7 @@ from pytest import Pytester -def test_import_warning(pytester: Pytester): +def test_import_warning_does_not_cause_internal_error(pytester: Pytester): pytester.makepyfile( dedent( """\ @@ -16,3 +16,43 @@ async def test_errors_out(): ) result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(errors=1) + + +def test_import_warning_in_package_does_not_cause_internal_error(pytester: Pytester): + pytester.makepyfile( + __init__=dedent( + """\ + raise ImportWarning() + """ + ), + test_a=dedent( + """\ + async def test_errors_out(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(errors=1) + + +def test_does_not_import_unrelated_packages(pytester: Pytester): + pkg_dir = pytester.mkpydir("mypkg") + pkg_dir.joinpath("__init__.py").write_text( + dedent( + """\ + raise ImportError() + """ + ), + ) + test_dir = pytester.mkdir("tests") + test_dir.joinpath("test_a.py").write_text( + dedent( + """\ + async def test_passes(): + pass + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1)