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

Fix fixture ordering and event loop being set to None in fixture #684

Merged
merged 3 commits into from
Nov 26, 2023
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
75 changes: 36 additions & 39 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,21 +582,11 @@ def scoped_event_loop(
event_loop_policy,
) -> Iterator[asyncio.AbstractEventLoop]:
new_loop_policy = event_loop_policy
old_loop_policy = asyncio.get_event_loop_policy()
old_loop = asyncio.get_event_loop()
asyncio.set_event_loop_policy(new_loop_policy)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
asyncio.set_event_loop_policy(old_loop_policy)
# When a test uses both a scoped event loop and the event_loop fixture,
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
# will already have installed a fresh event loop, in order to shield
# subsequent tests from side-effects. We close this loop before restoring
# the old loop to avoid ResourceWarnings.
asyncio.get_event_loop().close()
asyncio.set_event_loop(old_loop)
with _temporary_event_loop_policy(new_loop_policy):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()

# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
# know it exists. We work around this by attaching the fixture function to the
Expand All @@ -622,6 +612,30 @@ def _removesuffix(s: str, suffix: str) -> str:
return s.removesuffix(suffix)


@contextlib.contextmanager
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
old_loop_policy = asyncio.get_event_loop_policy()
try:
old_loop = asyncio.get_event_loop()
except RuntimeError:
old_loop = None
asyncio.set_event_loop_policy(policy)
try:
yield
finally:
asyncio.set_event_loop_policy(old_loop_policy)
# When a test uses both a scoped event loop and the event_loop fixture,
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
# will already have installed a fresh event loop, in order to shield
# subsequent tests from side-effects. We close this loop before restoring
# the old loop to avoid ResourceWarnings.
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
asyncio.set_event_loop(old_loop)


def pytest_collection_modifyitems(
session: Session, config: Config, items: List[Item]
) -> None:
Expand Down Expand Up @@ -892,15 +906,8 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
else:
event_loop_fixture_id = "event_loop"
fixturenames = item.fixturenames # type: ignore[attr-defined]
# inject an event loop fixture for all async tests
if "event_loop" in fixturenames:
# Move the "event_loop" fixture to the beginning of the fixture evaluation
# closure for backwards compatibility
fixturenames.remove("event_loop")
fixturenames.insert(0, "event_loop")
else:
if event_loop_fixture_id not in fixturenames:
fixturenames.append(event_loop_fixture_id)
if event_loop_fixture_id not in fixturenames:
fixturenames.append(event_loop_fixture_id)
obj = getattr(item, "obj", None)
if not getattr(obj, "hypothesis", False) and getattr(
obj, "is_hypothesis_test", False
Expand Down Expand Up @@ -950,21 +957,11 @@ def _session_event_loop(
request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy
) -> Iterator[asyncio.AbstractEventLoop]:
new_loop_policy = event_loop_policy
old_loop_policy = asyncio.get_event_loop_policy()
old_loop = asyncio.get_event_loop()
asyncio.set_event_loop_policy(new_loop_policy)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
asyncio.set_event_loop_policy(old_loop_policy)
# When a test uses both a scoped event loop and the event_loop fixture,
# the "_provide_clean_event_loop" finalizer of the event_loop fixture
# will already have installed a fresh event loop, in order to shield
# subsequent tests from side-effects. We close this loop before restoring
# the old loop to avoid ResourceWarnings.
asyncio.get_event_loop().close()
asyncio.set_event_loop(old_loop)
with _temporary_event_loop_policy(new_loop_policy):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()


@pytest.fixture(scope="session", autouse=True)
Expand Down
35 changes: 35 additions & 0 deletions tests/markers/test_class_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,38 @@ async def test_runs_in_different_loop_as_fixture(self, async_fixture):
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture(
pytester: pytest.Pytester,
):
pytester.makepyfile(
dedent(
"""\
import pytest
import asyncio
class TestClass:
@pytest.fixture(scope="class")
def sets_event_loop_to_none(self):
# asyncio.run() creates a new event loop without closing the
# existing one. For any test, but the first one, this leads to
# a ResourceWarning when the discarded loop is destroyed by the
# garbage collector. We close the current loop to avoid this.
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
return asyncio.run(asyncio.sleep(0))
# asyncio.run() sets the current event loop to None when finished
@pytest.mark.asyncio(scope="class")
# parametrization may impact fixture ordering
@pytest.mark.parametrize("n", (0, 1))
async def test_does_not_fail(self, sets_event_loop_to_none, n):
pass
"""
)
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)
34 changes: 34 additions & 0 deletions tests/markers/test_function_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,37 @@ async def test_runs_is_same_loop_as_fixture(my_fixture):
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import pytest
import asyncio
@pytest.fixture
def sets_event_loop_to_none():
# asyncio.run() creates a new event loop without closing the existing
# one. For any test, but the first one, this leads to a ResourceWarning
# when the discarded loop is destroyed by the garbage collector.
# We close the current loop to avoid this
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
return asyncio.run(asyncio.sleep(0))
# asyncio.run() sets the current event loop to None when finished
@pytest.mark.asyncio
# parametrization may impact fixture ordering
@pytest.mark.parametrize("n", (0, 1))
async def test_does_not_fail(sets_event_loop_to_none, n):
pass
"""
)
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)
34 changes: 34 additions & 0 deletions tests/markers/test_module_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,37 @@ async def test_runs_in_different_loop_as_fixture(async_fixture):
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import pytest
import asyncio
@pytest.fixture(scope="module")
def sets_event_loop_to_none():
# asyncio.run() creates a new event loop without closing the existing
# one. For any test, but the first one, this leads to a ResourceWarning
# when the discarded loop is destroyed by the garbage collector.
# We close the current loop to avoid this
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
return asyncio.run(asyncio.sleep(0))
# asyncio.run() sets the current event loop to None when finished
@pytest.mark.asyncio(scope="module")
# parametrization may impact fixture ordering
@pytest.mark.parametrize("n", (0, 1))
async def test_does_not_fail(sets_event_loop_to_none, n):
pass
"""
)
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)
35 changes: 35 additions & 0 deletions tests/markers/test_package_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,38 @@ async def test_runs_in_different_loop_as_fixture(async_fixture):
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture(
pytester: Pytester,
):
pytester.makepyfile(
__init__="",
test_loop_is_none=dedent(
"""\
import pytest
import asyncio
@pytest.fixture(scope="package")
def sets_event_loop_to_none():
# asyncio.run() creates a new event loop without closing the existing
# one. For any test, but the first one, this leads to a ResourceWarning
# when the discarded loop is destroyed by the garbage collector.
# We close the current loop to avoid this
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
return asyncio.run(asyncio.sleep(0))
# asyncio.run() sets the current event loop to None when finished
@pytest.mark.asyncio(scope="package")
# parametrization may impact fixture ordering
@pytest.mark.parametrize("n", (0, 1))
async def test_does_not_fail(sets_event_loop_to_none, n):
pass
"""
),
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)
34 changes: 34 additions & 0 deletions tests/markers/test_session_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,37 @@ async def test_runs_in_different_loop_as_fixture(async_fixture):
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=1)


def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture(
pytester: Pytester,
):
pytester.makepyfile(
dedent(
"""\
import pytest
import asyncio
@pytest.fixture(scope="session")
def sets_event_loop_to_none():
# asyncio.run() creates a new event loop without closing the existing
# one. For any test, but the first one, this leads to a ResourceWarning
# when the discarded loop is destroyed by the garbage collector.
# We close the current loop to avoid this
try:
asyncio.get_event_loop().close()
except RuntimeError:
pass
return asyncio.run(asyncio.sleep(0))
# asyncio.run() sets the current event loop to None when finished
@pytest.mark.asyncio(scope="session")
# parametrization may impact fixture ordering
@pytest.mark.parametrize("n", (0, 1))
async def test_does_not_fail(sets_event_loop_to_none, n):
pass
"""
)
)
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2)
Loading