Skip to content

Commit d697088

Browse files
authored
Emit ResourceWarning on event_loop teardown when loop is unclosed (#492)
* [tests] Run tests for event loop policy inside Pytester to avoid pollution of other tests with a custom event loop. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [tests] test_async_fixtures_scope closes the event loop on fixture teardown Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Substituted use of pytest_fixture_post_finalizer hook with fixture finalizer. The fixture finalizer is invoked once for each fixture, whereas the hook may be invoked multiple times for any specific fixture. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Replaced use of the testdir fixture with pytester in hypothesis/test_base Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Isolate module-scoped event loop fixture in hypothesis/test_base using pytester. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [feat] Emit ResourceWarning when event_loop is torn down and the current event loop has not been closed. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Split up event_loop fixture finalizer into a finalizer for closing the loop and a finalizer for providing a new loop. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Created convenience function to add multiple finalizers to a single fixture. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> * [refactor] Created convenience function to add multiple finalizers to a single fixture. Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de> --------- Signed-off-by: Michael Seifert <m.seifert@digitalernachschub.de>
1 parent ca8b890 commit d697088

File tree

8 files changed

+186
-81
lines changed

8 files changed

+186
-81
lines changed

docs/source/reference/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Changelog
55
UNRELEASED
66
=================
77
- Drop compatibility with pytest 6.1. Pytest-asyncio now depends on pytest 7.0 or newer.
8+
- event_loop fixture teardown emits a ResourceWarning when the current event loop has not been closed.
89

910
0.20.3 (22-12-08)
1011
=================

pytest_asyncio/plugin.py

+66-27
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import socket
88
import sys
99
import warnings
10+
from textwrap import dedent
1011
from typing import (
1112
Any,
1213
AsyncIterator,
@@ -370,39 +371,22 @@ def _hypothesis_test_wraps_coroutine(function: Any) -> bool:
370371
return _is_coroutine(function.hypothesis.inner_test)
371372

372373

373-
@pytest.hookimpl(trylast=True)
374-
def pytest_fixture_post_finalizer(fixturedef: FixtureDef, request: SubRequest) -> None:
375-
"""
376-
Called after fixture teardown.
377-
378-
Note that this function may be called multiple times for any specific fixture.
379-
see https://github.com/pytest-dev/pytest/issues/5848
380-
"""
381-
if fixturedef.argname == "event_loop":
382-
policy = asyncio.get_event_loop_policy()
383-
try:
384-
loop = policy.get_event_loop()
385-
except RuntimeError:
386-
loop = None
387-
if loop is not None:
388-
# Clean up existing loop to avoid ResourceWarnings
389-
loop.close()
390-
# At this point, the event loop for the current thread is closed.
391-
# When a user calls asyncio.get_event_loop(), they will get a closed loop.
392-
# In order to avoid this side effect from pytest-asyncio, we need to replace
393-
# the current loop with a fresh one.
394-
# Note that we cannot set the loop to None, because get_event_loop only creates
395-
# a new loop, when set_event_loop has not been called.
396-
new_loop = policy.new_event_loop()
397-
policy.set_event_loop(new_loop)
398-
399-
400374
@pytest.hookimpl(hookwrapper=True)
401375
def pytest_fixture_setup(
402376
fixturedef: FixtureDef, request: SubRequest
403377
) -> Optional[object]:
404378
"""Adjust the event loop policy when an event loop is produced."""
405379
if fixturedef.argname == "event_loop":
380+
# The use of a fixture finalizer is preferred over the
381+
# pytest_fixture_post_finalizer hook. The fixture finalizer is invoked once
382+
# for each fixture, whereas the hook may be invoked multiple times for
383+
# any specific fixture.
384+
# see https://github.com/pytest-dev/pytest/issues/5848
385+
_add_finalizers(
386+
fixturedef,
387+
_close_event_loop,
388+
_provide_clean_event_loop,
389+
)
406390
outcome = yield
407391
loop = outcome.get_result()
408392
policy = asyncio.get_event_loop_policy()
@@ -421,6 +405,61 @@ def pytest_fixture_setup(
421405
yield
422406

423407

408+
def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None:
409+
"""
410+
Regsiters the specified fixture finalizers in the fixture.
411+
412+
Finalizers need to specified in the exact order in which they should be invoked.
413+
414+
:param fixturedef: Fixture definition which finalizers should be added to
415+
:param finalizers: Finalizers to be added
416+
"""
417+
for finalizer in reversed(finalizers):
418+
fixturedef.addfinalizer(finalizer)
419+
420+
421+
_UNCLOSED_EVENT_LOOP_WARNING = dedent(
422+
"""\
423+
unclosed event loop %r.
424+
Possible causes are:
425+
1. A custom "event_loop" fixture is used which doesn't close the loop
426+
2. Your code or one of your dependencies created a new event loop during
427+
the test run
428+
"""
429+
)
430+
431+
432+
def _close_event_loop() -> None:
433+
policy = asyncio.get_event_loop_policy()
434+
try:
435+
loop = policy.get_event_loop()
436+
except RuntimeError:
437+
loop = None
438+
if loop is not None:
439+
# Emit ResourceWarnings in the context of the fixture/test case
440+
# rather than waiting for the interpreter to trigger the warning when
441+
# garbage collecting the event loop.
442+
if not loop.is_closed():
443+
warnings.warn(
444+
_UNCLOSED_EVENT_LOOP_WARNING % loop,
445+
ResourceWarning,
446+
source=loop,
447+
)
448+
loop.close()
449+
450+
451+
def _provide_clean_event_loop() -> None:
452+
# At this point, the event loop for the current thread is closed.
453+
# When a user calls asyncio.get_event_loop(), they will get a closed loop.
454+
# In order to avoid this side effect from pytest-asyncio, we need to replace
455+
# the current loop with a fresh one.
456+
# Note that we cannot set the loop to None, because get_event_loop only creates
457+
# a new loop, when set_event_loop has not been called.
458+
policy = asyncio.get_event_loop_policy()
459+
new_loop = policy.new_event_loop()
460+
policy.set_event_loop(new_loop)
461+
462+
424463
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
425464
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]:
426465
"""

tests/async_fixtures/test_async_fixtures_scope.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
@pytest.fixture(scope="module")
1111
def event_loop():
1212
"""A module-scoped event loop."""
13-
return asyncio.new_event_loop()
13+
loop = asyncio.new_event_loop()
14+
yield loop
15+
loop.close()
1416

1517

1618
@pytest.fixture(scope="module")

tests/hypothesis/test_base.py

+35-20
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
"""Tests for the Hypothesis integration, which wraps async functions in a
22
sync shim for Hypothesis.
33
"""
4-
import asyncio
54
from textwrap import dedent
65

76
import pytest
87
from hypothesis import given, strategies as st
9-
10-
11-
@pytest.fixture(scope="module")
12-
def event_loop():
13-
loop = asyncio.get_event_loop_policy().new_event_loop()
14-
yield loop
15-
loop.close()
8+
from pytest import Pytester
169

1710

1811
@given(st.integers())
@@ -35,16 +28,38 @@ async def test_mark_and_parametrize(x, y):
3528
assert y in (1, 2)
3629

3730

38-
@given(st.integers())
39-
@pytest.mark.asyncio
40-
async def test_can_use_fixture_provided_event_loop(event_loop, n):
41-
semaphore = asyncio.Semaphore(value=0)
42-
event_loop.call_soon(semaphore.release)
43-
await semaphore.acquire()
31+
def test_can_use_explicit_event_loop_fixture(pytester: Pytester):
32+
pytester.makepyfile(
33+
dedent(
34+
"""\
35+
import asyncio
36+
import pytest
37+
from hypothesis import given
38+
import hypothesis.strategies as st
39+
40+
pytest_plugins = 'pytest_asyncio'
41+
42+
@pytest.fixture(scope="module")
43+
def event_loop():
44+
loop = asyncio.get_event_loop_policy().new_event_loop()
45+
yield loop
46+
loop.close()
47+
48+
@given(st.integers())
49+
@pytest.mark.asyncio
50+
async def test_explicit_fixture_request(event_loop, n):
51+
semaphore = asyncio.Semaphore(value=0)
52+
event_loop.call_soon(semaphore.release)
53+
await semaphore.acquire()
54+
"""
55+
)
56+
)
57+
result = pytester.runpytest("--asyncio-mode=strict")
58+
result.assert_outcomes(passed=1)
4459

4560

46-
def test_async_auto_marked(testdir):
47-
testdir.makepyfile(
61+
def test_async_auto_marked(pytester: Pytester):
62+
pytester.makepyfile(
4863
dedent(
4964
"""\
5065
import asyncio
@@ -60,13 +75,13 @@ async def test_hypothesis(n: int):
6075
"""
6176
)
6277
)
63-
result = testdir.runpytest("--asyncio-mode=auto")
78+
result = pytester.runpytest("--asyncio-mode=auto")
6479
result.assert_outcomes(passed=1)
6580

6681

67-
def test_sync_not_auto_marked(testdir):
82+
def test_sync_not_auto_marked(pytester: Pytester):
6883
"""Assert that synchronous Hypothesis functions are not marked with asyncio"""
69-
testdir.makepyfile(
84+
pytester.makepyfile(
7085
dedent(
7186
"""\
7287
import asyncio
@@ -84,5 +99,5 @@ def test_hypothesis(request, n: int):
8499
"""
85100
)
86101
)
87-
result = testdir.runpytest("--asyncio-mode=auto")
102+
result = pytester.runpytest("--asyncio-mode=auto")
88103
result.assert_outcomes(passed=1)

tests/respect_event_loop_policy/conftest.py

-16
This file was deleted.

tests/respect_event_loop_policy/test_respects_event_loop_policy.py

-17
This file was deleted.

tests/test_event_loop_fixture.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from textwrap import dedent
2+
3+
from pytest import Pytester
4+
5+
6+
def test_event_loop_fixture_respects_event_loop_policy(pytester: Pytester):
7+
pytester.makeconftest(
8+
dedent(
9+
"""\
10+
'''Defines and sets a custom event loop policy'''
11+
import asyncio
12+
from asyncio import DefaultEventLoopPolicy, SelectorEventLoop
13+
14+
class TestEventLoop(SelectorEventLoop):
15+
pass
16+
17+
class TestEventLoopPolicy(DefaultEventLoopPolicy):
18+
def new_event_loop(self):
19+
return TestEventLoop()
20+
21+
# This statement represents a code which sets a custom event loop policy
22+
asyncio.set_event_loop_policy(TestEventLoopPolicy())
23+
"""
24+
)
25+
)
26+
pytester.makepyfile(
27+
dedent(
28+
"""\
29+
'''Tests that any externally provided event loop policy remains unaltered'''
30+
import asyncio
31+
32+
import pytest
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_uses_loop_provided_by_custom_policy():
37+
'''Asserts that test cases use the event loop
38+
provided by the custom event loop policy'''
39+
assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop"
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_custom_policy_is_not_overwritten():
44+
'''
45+
Asserts that any custom event loop policy stays the same
46+
across test cases.
47+
'''
48+
assert type(asyncio.get_event_loop()).__name__ == "TestEventLoop"
49+
"""
50+
)
51+
)
52+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
53+
result.assert_outcomes(passed=2)

tests/test_event_loop_fixture_finalizer.py

+28
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,31 @@ async def test_async_with_explicit_fixture_request(event_loop):
8686
)
8787
result = pytester.runpytest("--asyncio-mode=strict")
8888
result.assert_outcomes(passed=1)
89+
90+
91+
def test_event_loop_fixture_finalizer_raises_warning_when_loop_is_unclosed(
92+
pytester: Pytester,
93+
):
94+
pytester.makepyfile(
95+
dedent(
96+
"""\
97+
import asyncio
98+
import pytest
99+
import pytest_asyncio
100+
101+
pytest_plugins = 'pytest_asyncio'
102+
103+
@pytest.fixture
104+
def event_loop():
105+
loop = asyncio.get_event_loop_policy().new_event_loop()
106+
yield loop
107+
108+
@pytest.mark.asyncio
109+
async def test_ends_with_unclosed_loop():
110+
pass
111+
"""
112+
)
113+
)
114+
result = pytester.runpytest("--asyncio-mode=strict", "-W", "default")
115+
result.assert_outcomes(passed=1, warnings=1)
116+
result.stdout.fnmatch_lines("*unclosed event loop*")

0 commit comments

Comments
 (0)