Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent import of unrealted packages during test collection #741

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/source/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
Changelog
=========

0.23.4 (UNRELEASED)
===================
- pytest-asyncio no longer imports additional, unrelated packages during test collection `#729 <https://github.com/pytest-dev/pytest-asyncio/issues/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 <https://github.com/pytest-dev/pytest-asyncio/issues/706>`_
Expand Down
108 changes: 97 additions & 11 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +30,7 @@
)

import pytest
from _pytest.pathlib import visit
from pytest import (
Class,
Collector,
Expand Down Expand Up @@ -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")) + "::<event_loop>"
)
# 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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tests/markers/test_package_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""\
Expand Down
42 changes: 41 additions & 1 deletion tests/test_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""\
Expand All @@ -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)