Skip to content

Commit 84703df

Browse files
committed
[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 <[email protected]>
1 parent e426a59 commit 84703df

File tree

4 files changed

+114
-55
lines changed

4 files changed

+114
-55
lines changed

docs/source/reference/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
Changelog
33
=========
44

5+
0.23.4 (UNRELEASED)
6+
===================
7+
- pytest-asyncio no longer imports additional, unrelated packages during test collection `#729 <https://github.com/pytest-dev/pytest-asyncio/issues/729>`_
8+
9+
Known issues
10+
------------
11+
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.
12+
513
0.23.3 (2024-01-01)
614
===================
715
- 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 <https://github.com/pytest-dev/pytest-asyncio/issues/706>`_

pytest_asyncio/plugin.py

Lines changed: 83 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131

3232
import pytest
33+
from _pytest.pathlib import visit
3334
from pytest import (
3435
Class,
3536
Collector,
@@ -625,68 +626,95 @@ def _patched_collect():
625626
collector.__original_collect = collector.collect
626627
collector.collect = _patched_collect
627628
elif type(collector) is Package:
629+
if not collector.funcnamefilter(collector.name):
630+
return
628631

629632
def _patched_collect():
630-
# When collector is a Package, collector.obj is the package's __init__.py.
631-
# Accessing the __init__.py to attach the fixture function may trigger
632-
# additional module imports or change the order of imports, which leads to
633-
# a number of problems.
634-
# see https://github.com/pytest-dev/pytest-asyncio/issues/729
635-
# Moreover, Package.obj has been removed in pytest 8.
636-
# Therefore, pytest-asyncio creates a temporary Python module inside the
637-
# collected package. The sole purpose of that module is to house a fixture
638-
# function for the pacakge-scoped event loop fixture. Once the fixture
639-
# has been evaluated by pytest, the temporary module can be removed.
640-
with NamedTemporaryFile(
641-
dir=collector.path.parent,
642-
prefix="pytest_asyncio_virtual_module_",
643-
suffix=".py",
644-
) as virtual_module_file:
645-
virtual_module = Module.from_parent(
646-
collector, path=Path(virtual_module_file.name)
647-
)
648-
virtual_module_file.write(
649-
dedent(
650-
f"""\
651-
import asyncio
652-
import pytest
653-
from pytest_asyncio.plugin import _temporary_event_loop_policy
654-
@pytest.fixture(
655-
scope="{collector_scope}",
656-
name="{collector.nodeid}::<event_loop>",
657-
)
658-
def scoped_event_loop(
659-
*args,
660-
event_loop_policy,
661-
):
662-
new_loop_policy = event_loop_policy
663-
with _temporary_event_loop_policy(new_loop_policy):
664-
loop = asyncio.new_event_loop()
665-
loop.__pytest_asyncio = True
666-
asyncio.set_event_loop(loop)
667-
yield loop
668-
loop.close()
669-
"""
670-
).encode()
671-
)
672-
virtual_module_file.flush()
633+
# pytest.Package collects all files and sub-packages. Pytest 8 changes
634+
# this logic to only collect a single directory. Sub-packages are then
635+
# collected by a separate Package collector. Therefore, this logic can be
636+
# dropped, once we move to pytest 8.
637+
collector_dir = Path(collector.path.parent)
638+
for direntry in visit(str(collector_dir), recurse=collector._recurse):
639+
if not direntry.name == "__init__.py":
640+
# No need to register a package-scoped fixture, if we aren't
641+
# collecting a (sub-)package
642+
continue
643+
pkgdir = Path(direntry.path).parent
644+
pkg_nodeid = str(pkgdir.relative_to(collector_dir))
645+
if pkg_nodeid == ".":
646+
pkg_nodeid = ""
673647
# Pytest's fixture matching algorithm compares a fixture's baseid with
674648
# an Item's nodeid to determine whether a fixture is available for a
675-
# specific Item. Since Package.nodeid ends with __init__.py, the
676-
# fixture's baseid will also end with __init__.py, which prevents
677-
# the fixture from being matched to test items in the current package.
678-
# Since the fixture matching is purely based on string comparison, we
679-
# strip the __init__.py suffix from the Package's node ID and
680-
# tell the fixturemanager to collect the fixture with the modified
681-
# nodeid. This makes the fixture visible to all items in the package.
649+
# specific Item. Package.nodeid ends with __init__.py, so the
650+
# fixture's baseid will also end with __init__.py and prevents
651+
# the fixture from being matched to test items in the package.
652+
# Furthermore, Package also collects any sub-packages, which means
653+
# the ID of the scoped event loop for the package must change for
654+
# each sub-package.
655+
# As the fixture matching is purely based on string comparison, we
656+
# can assemble a path based on the root package path
657+
# (i.e. Package.path.parent) and the sub-package path
658+
# (i.e. Path(direntry.path).parent)). This makes the fixture visible
659+
# to all items in the package.
682660
# see also https://github.com/pytest-dev/pytest/issues/11662#issuecomment-1879310072 # noqa
683661
# Possibly related to https://github.com/pytest-dev/pytest/issues/4085
684-
fixturemanager = collector.config.pluginmanager.get_plugin("funcmanage")
685-
package_node_id = _removesuffix(collector.nodeid, "__init__.py")
686-
fixturemanager.parsefactories(
687-
virtual_module.obj, nodeid=package_node_id
662+
fixture_id = (
663+
str(Path(pkg_nodeid).joinpath("__init__.py")) + "::<event_loop>"
688664
)
689-
yield virtual_module
665+
# When collector is a Package, collector.obj is the package's
666+
# __init__.py. Accessing the __init__.py to attach the fixture function
667+
# may trigger additional module imports or change the order of imports,
668+
# which leads to a number of problems.
669+
# see https://github.com/pytest-dev/pytest-asyncio/issues/729
670+
# Moreover, Package.obj has been removed in pytest 8.
671+
# Therefore, pytest-asyncio creates a temporary Python module inside the
672+
# collected package. The sole purpose of that module is to house a
673+
# fixture function for the pacakge-scoped event loop fixture. Once the
674+
# fixture has been evaluated by pytest, the temporary module
675+
# can be removed.
676+
with NamedTemporaryFile(
677+
dir=pkgdir,
678+
prefix="pytest_asyncio_virtual_module_",
679+
suffix=".py",
680+
) as virtual_module_file:
681+
virtual_module = Module.from_parent(
682+
collector, path=Path(virtual_module_file.name)
683+
)
684+
virtual_module_file.write(
685+
dedent(
686+
f"""\
687+
import asyncio
688+
import pytest
689+
from pytest_asyncio.plugin \
690+
import _temporary_event_loop_policy
691+
@pytest.fixture(
692+
scope="{collector_scope}",
693+
name="{fixture_id}",
694+
)
695+
def scoped_event_loop(
696+
*args,
697+
event_loop_policy,
698+
):
699+
new_loop_policy = event_loop_policy
700+
with _temporary_event_loop_policy(new_loop_policy):
701+
loop = asyncio.new_event_loop()
702+
loop.__pytest_asyncio = True
703+
asyncio.set_event_loop(loop)
704+
yield loop
705+
loop.close()
706+
"""
707+
).encode()
708+
)
709+
virtual_module_file.flush()
710+
fixturemanager = collector.config.pluginmanager.get_plugin(
711+
"funcmanage"
712+
)
713+
# Collect the fixtures in the virtual module with the node ID of
714+
# the current sub-package to ensure correct fixture matching.
715+
# see also https://github.com/pytest-dev/pytest/issues/11662#issuecomment-1879310072 # noqa
716+
fixturemanager.parsefactories(virtual_module.obj, nodeid=pkg_nodeid)
717+
yield virtual_module
690718
yield from collector.__original_collect()
691719

692720
collector.__original_collect = collector.collect

tests/markers/test_package_scope.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def test_this_runs_in_same_loop(self):
4646
),
4747
)
4848
subpkg = pytester.mkpydir(subpackage_name)
49+
subpkg.joinpath("__init__.py").touch()
4950
subpkg.joinpath("test_subpkg.py").write_text(
5051
dedent(
5152
f"""\

tests/test_import.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,25 @@ async def test_errors_out():
3434
)
3535
result = pytester.runpytest("--asyncio-mode=auto")
3636
result.assert_outcomes(errors=1)
37+
38+
39+
def test_does_not_import_unrelated_packages(pytester: Pytester):
40+
pkg_dir = pytester.mkpydir("mypkg")
41+
pkg_dir.joinpath("__init__.py").write_text(
42+
dedent(
43+
"""\
44+
raise ImportError()
45+
"""
46+
),
47+
)
48+
test_dir = pytester.mkdir("tests")
49+
test_dir.joinpath("test_a.py").write_text(
50+
dedent(
51+
"""\
52+
async def test_passes():
53+
pass
54+
"""
55+
),
56+
)
57+
result = pytester.runpytest("--asyncio-mode=auto")
58+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)