From b13b1a0bfa526b6e5cadef82925aa0f9049429b3 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sat, 6 Jan 2024 11:26:30 +0100 Subject: [PATCH 1/3] [fix] Fixes a bug that caused an INTERNALERROR when an __init__.py raised an error. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 29 ++++++++++++++++++----------- tests/test_import.py | 20 +++++++++++++++++++- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index eb013f46..4da72455 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -620,6 +620,24 @@ 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: + + def _patched_collect(): + # 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 + collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop + fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage") + package_node_id = _removesuffix(collector.nodeid, "__init__.py") + fixturemanager.parsefactories(collector.obj, nodeid=package_node_id) + return collector.__original_collect() + collector.__original_collect = collector.collect collector.collect = _patched_collect else: @@ -628,17 +646,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/test_import.py b/tests/test_import.py index 77352150..05435675 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,21 @@ 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) From be54d605f4ea820152571179942dc0e7d7e789c9 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 7 Jan 2024 15:02:51 +0100 Subject: [PATCH 2/3] [refactor] Attach package-scoped fixtures to a virtual/temporary Python module in the package, rather than attaching the fixture to the package's __init__.py. Signed-off-by: Michael Seifert --- pytest_asyncio/plugin.py | 75 +++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 4da72455..bb5e7898 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, @@ -625,18 +627,67 @@ def _patched_collect(): elif type(collector) is Package: def _patched_collect(): - # 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 - collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop - fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage") - package_node_id = _removesuffix(collector.nodeid, "__init__.py") - fixturemanager.parsefactories(collector.obj, nodeid=package_node_id) - return collector.__original_collect() + # 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=collector.path.parent, + 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="{collector.nodeid}::", + ) + 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() + # 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. Since Package.nodeid ends with __init__.py, the + # fixture's baseid will also end with __init__.py, which prevents + # the fixture from being matched to test items in the current package. + # Since the fixture matching is purely based on string comparison, we + # strip the __init__.py suffix from the Package's node ID and + # tell the fixturemanager to collect the fixture with the modified + # nodeid. 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 + fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage") + package_node_id = _removesuffix(collector.nodeid, "__init__.py") + fixturemanager.parsefactories( + virtual_module.obj, nodeid=package_node_id + ) + yield virtual_module + yield from collector.__original_collect() collector.__original_collect = collector.collect collector.collect = _patched_collect From d8824cc12370c7496af8d37c785e73a3f49820ae Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sun, 7 Jan 2024 16:46:34 +0100 Subject: [PATCH 3/3] [fix] Fixes a bug that caused pytest-asyncio to import additional, unrelated packages during test collection. This was caused by a missing call to collector.funcnamefilter when setting up the packaged-scoped event loop fixture function. The new code respects funcnamefilter and monkeypatches Package.collect to install a package-scoped loop whenever an __init__.py is encountered. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 8 ++ pytest_asyncio/plugin.py | 138 +++++++++++++++++----------- tests/markers/test_package_scope.py | 1 + tests/test_import.py | 22 +++++ 4 files changed, 114 insertions(+), 55 deletions(-) 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 bb5e7898..cfc28e8d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -30,6 +30,7 @@ ) import pytest +from _pytest.pathlib import visit from pytest import ( Class, Collector, @@ -625,68 +626,95 @@ def _patched_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(): - # 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=collector.path.parent, - 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="{collector.nodeid}::", - ) - 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() + # 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. Since Package.nodeid ends with __init__.py, the - # fixture's baseid will also end with __init__.py, which prevents - # the fixture from being matched to test items in the current package. - # Since the fixture matching is purely based on string comparison, we - # strip the __init__.py suffix from the Package's node ID and - # tell the fixturemanager to collect the fixture with the modified - # nodeid. This makes the fixture visible to all items in the package. + # 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 - fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage") - package_node_id = _removesuffix(collector.nodeid, "__init__.py") - fixturemanager.parsefactories( - virtual_module.obj, nodeid=package_node_id + fixture_id = ( + str(Path(pkg_nodeid).joinpath("__init__.py")) + "::" ) - yield virtual_module + # 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 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 05435675..9912ae0c 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -34,3 +34,25 @@ async def test_errors_out(): ) 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)