Skip to content

Commit

Permalink
Close event loops when replacing them
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche committed Apr 18, 2021
1 parent a516134 commit 740af18
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 29 deletions.
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.PHONY: clean clean-build clean-pyc clean-test

clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts

clean-build: ## remove build artifacts
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +

clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +

clean-test: ## remove test and coverage artifacts
rm -fr .tox/
rm -f .coverage
rm -fr htmlcov/
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Changelog
- Abandon support for Python 3.5. If you still require support for Python 3.5, please use pytest-asyncio v0.14 or earlier.
- Set ``unused_tcp_port_factory`` fixture scope to 'session'.
`#163 <https://github.com/pytest-dev/pytest-asyncio/pull/163>`_
- Properly close event loops when replacing them.
`#208 <https://github.com/pytest-dev/pytest-asyncio/issues/208>`_

0.14.0 (2020-06-24)
~~~~~~~~~~~~~~~~~~~
Expand Down
77 changes: 48 additions & 29 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import socket

import pytest

try:
from _pytest.python import transfer_markers
except ImportError: # Pytest 4.1.0 removes the transfer_marker api (#104)

def transfer_markers(*args, **kwargs): # noqa
"""Noop when over pytest 4.1.0"""
pass


from inspect import isasyncgenfunction


Expand All @@ -23,10 +26,12 @@ def _is_coroutine(obj):

def pytest_configure(config):
"""Inject documentation."""
config.addinivalue_line("markers",
"asyncio: "
"mark the test as a coroutine, it will be "
"run using an asyncio event loop")
config.addinivalue_line(
"markers",
"asyncio: "
"mark the test as a coroutine, it will be "
"run using an asyncio event loop",
)


@pytest.mark.tryfirst
Expand All @@ -41,12 +46,13 @@ def pytest_pycollect_makeitem(collector, name, obj):
transfer_markers(obj, item.cls, item.module)
item = pytest.Function.from_parent(collector, name=name) # To reload keywords.

if 'asyncio' in item.keywords:
if "asyncio" in item.keywords:
return list(collector._genfunctions(name, obj))


class FixtureStripper:
"""Include additional Fixture, and then strip them"""

REQUEST = "request"
EVENT_LOOP = "event_loop"

Expand All @@ -59,7 +65,7 @@ def add(self, name):
and record in to_strip list (If not previously included)"""
if name in self.fixturedef.argnames:
return
self.fixturedef.argnames += (name, )
self.fixturedef.argnames += (name,)
self.to_strip.add(name)

def get_and_strip_from(self, name, data_dict):
Expand All @@ -69,6 +75,7 @@ def get_and_strip_from(self, name, data_dict):
del data_dict[name]
return result


@pytest.hookimpl(trylast=True)
def pytest_fixture_post_finalizer(fixturedef, request):
"""Called after fixture teardown"""
Expand All @@ -77,14 +84,16 @@ def pytest_fixture_post_finalizer(fixturedef, request):
asyncio.set_event_loop_policy(None)



@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
"""Adjust the event loop policy when an event loop is produced."""
if fixturedef.argname == "event_loop":
outcome = yield
loop = outcome.get_result()
policy = asyncio.get_event_loop_policy()
old_loop = policy.get_event_loop()
if old_loop is not loop:
old_loop.close()
policy.set_event_loop(loop)
return

Expand All @@ -96,10 +105,13 @@ def pytest_fixture_setup(fixturedef, request):
fixture_stripper.add(FixtureStripper.EVENT_LOOP)
fixture_stripper.add(FixtureStripper.REQUEST)


def wrapper(*args, **kwargs):
loop = fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs)
request = fixture_stripper.get_and_strip_from(FixtureStripper.REQUEST, kwargs)
loop = fixture_stripper.get_and_strip_from(
FixtureStripper.EVENT_LOOP, kwargs
)
request = fixture_stripper.get_and_strip_from(
FixtureStripper.REQUEST, kwargs
)

gen_obj = generator(*args, **kwargs)

Expand All @@ -109,6 +121,7 @@ async def setup():

def finalizer():
"""Yield again, to finalize."""

async def async_finalizer():
try:
await gen_obj.__anext__()
Expand All @@ -118,6 +131,7 @@ async def async_finalizer():
msg = "Async generator fixture didn't stop."
msg += "Yield only once."
raise ValueError(msg)

loop.run_until_complete(async_finalizer())

request.addfinalizer(finalizer)
Expand All @@ -131,7 +145,9 @@ async def async_finalizer():
fixture_stripper.add(FixtureStripper.EVENT_LOOP)

def wrapper(*args, **kwargs):
loop = fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs)
loop = fixture_stripper.get_and_strip_from(
FixtureStripper.EVENT_LOOP, kwargs
)

async def setup():
res = await coro(*args, **kwargs)
Expand All @@ -149,16 +165,15 @@ def pytest_pyfunc_call(pyfuncitem):
Run asyncio marked test functions in an event loop instead of a normal
function call.
"""
if 'asyncio' in pyfuncitem.keywords:
if getattr(pyfuncitem.obj, 'is_hypothesis_test', False):
if "asyncio" in pyfuncitem.keywords:
if getattr(pyfuncitem.obj, "is_hypothesis_test", False):
pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync(
pyfuncitem.obj.hypothesis.inner_test,
_loop=pyfuncitem.funcargs['event_loop']
_loop=pyfuncitem.funcargs["event_loop"],
)
else:
pyfuncitem.obj = wrap_in_sync(
pyfuncitem.obj,
_loop=pyfuncitem.funcargs['event_loop']
pyfuncitem.obj, _loop=pyfuncitem.funcargs["event_loop"]
)
yield

Expand All @@ -181,22 +196,25 @@ def inner(**kwargs):
if task.done() and not task.cancelled():
task.exception()
raise

return inner


def pytest_runtest_setup(item):
if 'asyncio' in item.keywords:
if "asyncio" in item.keywords:
# inject an event loop fixture for all async tests
if 'event_loop' in item.fixturenames:
item.fixturenames.remove('event_loop')
item.fixturenames.insert(0, 'event_loop')
if item.get_closest_marker("asyncio") is not None \
and not getattr(item.obj, 'hypothesis', False) \
and getattr(item.obj, 'is_hypothesis_test', False):
pytest.fail(
'test function `%r` is using Hypothesis, but pytest-asyncio '
'only works with Hypothesis 3.64.0 or later.' % item
)
if "event_loop" in item.fixturenames:
item.fixturenames.remove("event_loop")
item.fixturenames.insert(0, "event_loop")
if (
item.get_closest_marker("asyncio") is not None
and not getattr(item.obj, "hypothesis", False)
and getattr(item.obj, "is_hypothesis_test", False)
):
pytest.fail(
"test function `%r` is using Hypothesis, but pytest-asyncio "
"only works with Hypothesis 3.64.0 or later." % item
)


@pytest.fixture
Expand All @@ -210,7 +228,7 @@ def event_loop(request):
def _unused_tcp_port():
"""Find an unused localhost TCP port from 1024-65535 and return it."""
with contextlib.closing(socket.socket()) as sock:
sock.bind(('127.0.0.1', 0))
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]


Expand All @@ -219,7 +237,7 @@ def unused_tcp_port():
return _unused_tcp_port()


@pytest.fixture(scope='session')
@pytest.fixture(scope="session")
def unused_tcp_port_factory():
"""A factory function, producing different unused TCP ports."""
produced = set()
Expand All @@ -234,4 +252,5 @@ def factory():
produced.add(port)

return port

return factory
19 changes: 19 additions & 0 deletions tests/sessionloop/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import asyncio

import pytest


class CustomSelectorLoopSession(asyncio.SelectorEventLoop):
"""A subclass with no overrides, just to test for presence."""

pass


loop = CustomSelectorLoopSession()


@pytest.fixture(scope="package")
def event_loop():
"""Create an instance of the default event loop for each test case."""
yield loop
loop.close()
16 changes: 16 additions & 0 deletions tests/sessionloop/test_session_loops.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Unit tests for overriding the event loop with a session scoped one."""
import asyncio

import pytest


@pytest.mark.asyncio
async def test_for_custom_loop():
"""This test should be executed using the custom loop."""
await asyncio.sleep(0.01)
assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoopSession"


@pytest.mark.asyncio
async def test_dependent_fixture(dependent_fixture):
await asyncio.sleep(0.1)
1 change: 1 addition & 0 deletions tests/test_event_loop_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
These tests need to be run together.
"""
import asyncio

import pytest


Expand Down

0 comments on commit 740af18

Please sign in to comment.