diff --git a/README.rst b/README.rst index 92ae8f40..77e735b7 100644 --- a/README.rst +++ b/README.rst @@ -175,28 +175,32 @@ Only test coroutines will be affected (by default, coroutines prefixed by Changelog --------- +0.13.0 (2020-XX-XX) +~~~~~~~~~~~~~~~~~~~ +- Fix `#162 `_, and ``event_loop`` fixture behavior now is coherent on all scopes. + `#164 `_ 0.12.0 (2020-05-04) ~~~~~~~~~~~~~~~~~~~ - Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. - `#156` + `#156 `_ 0.11.0 (2020-04-20) ~~~~~~~~~~~~~~~~~~~ - Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. - `#152` + `#152 `_ - Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. - `#142` + `#142 `_ - Better ``pytest.skip`` support. - `#126` + `#126 `_ 0.10.0 (2019-01-08) ~~~~~~~~~~~~~~~~~~~~ - ``pytest-asyncio`` integrates with `Hypothesis `_ to support ``@given`` on async test functions using ``asyncio``. - `#102` + `#102 `_ - Pytest 4.1 support. - `#105` + `#105 `_ 0.9.0 (2018-07-28) ~~~~~~~~~~~~~~~~~~ @@ -208,7 +212,7 @@ Changelog 0.8.0 (2017-09-23) ~~~~~~~~~~~~~~~~~~ - Improve integration with other packages (like aiohttp) with more careful event loop handling. - `#64` + `#64 `_ 0.7.0 (2017-09-08) ~~~~~~~~~~~~~~~~~~ diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c0b65da2..2fdc5f4e 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -48,36 +48,61 @@ def pytest_pycollect_makeitem(collector, name, obj): return list(collector._genfunctions(name, obj)) +class FixtureStripper: + """Include additional Fixture, and then strip them""" + REQUEST = "request" + EVENT_LOOP = "event_loop" + + def __init__(self, fixturedef): + self.fixturedef = fixturedef + self.to_strip = set() + + def add(self, name): + """Add fixture name to fixturedef + and record in to_strip list (If not previously included)""" + if name in self.fixturedef.argnames: + return + self.fixturedef.argnames += (name, ) + self.to_strip.add(name) + + def get_and_strip_from(self, name, data_dict): + """Strip name from data, and return value""" + result = data_dict[name] + if name in self.to_strip: + del data_dict[name] + return result + +@pytest.hookimpl(trylast=True) +def pytest_fixture_post_finalizer(fixturedef, request): + """Called after fixture teardown""" + if fixturedef.argname == "event_loop": + # Set empty loop policy, so that subsequent get_event_loop() provides a new loop + 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" and 'asyncio' in request.keywords: + if fixturedef.argname == "event_loop": outcome = yield loop = outcome.get_result() policy = asyncio.get_event_loop_policy() - try: - old_loop = policy.get_event_loop() - except RuntimeError as exc: - if 'no current event loop' not in str(exc): - raise - old_loop = None policy.set_event_loop(loop) - fixturedef.addfinalizer(lambda: policy.set_event_loop(old_loop)) return if isasyncgenfunction(fixturedef.func): # This is an async generator function. Wrap it accordingly. generator = fixturedef.func - strip_request = False - if 'request' not in fixturedef.argnames: - fixturedef.argnames += ('request', ) - strip_request = True + fixture_stripper = FixtureStripper(fixturedef) + fixture_stripper.add(FixtureStripper.EVENT_LOOP) + fixture_stripper.add(FixtureStripper.REQUEST) + def wrapper(*args, **kwargs): - request = kwargs['request'] - if strip_request: - del kwargs['request'] + 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) @@ -96,21 +121,26 @@ async def async_finalizer(): msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) - asyncio.get_event_loop().run_until_complete(async_finalizer()) + loop.run_until_complete(async_finalizer()) request.addfinalizer(finalizer) - return asyncio.get_event_loop().run_until_complete(setup()) + return loop.run_until_complete(setup()) fixturedef.func = wrapper elif inspect.iscoroutinefunction(fixturedef.func): coro = fixturedef.func + fixture_stripper = FixtureStripper(fixturedef) + fixture_stripper.add(FixtureStripper.EVENT_LOOP) + def wrapper(*args, **kwargs): + loop = fixture_stripper.get_and_strip_from(FixtureStripper.EVENT_LOOP, kwargs) + async def setup(): res = await coro(*args, **kwargs) return res - return asyncio.get_event_loop().run_until_complete(setup()) + return loop.run_until_complete(setup()) fixturedef.func = wrapper yield @@ -144,15 +174,9 @@ def wrap_in_sync(func, _loop): def inner(**kwargs): coro = func(**kwargs) if coro is not None: + task = asyncio.ensure_future(coro, loop=_loop) try: - loop = asyncio.get_event_loop() - except RuntimeError as exc: - if 'no current event loop' not in str(exc): - raise - loop = _loop - task = asyncio.ensure_future(coro, loop=loop) - try: - loop.run_until_complete(task) + _loop.run_until_complete(task) except BaseException: # run_until_complete doesn't get the result from exceptions # that are not subclasses of `Exception`. Consume all diff --git a/tests/async_fixtures/test_async_fixtures_with_finalizer.py b/tests/async_fixtures/test_async_fixtures_with_finalizer.py new file mode 100644 index 00000000..44b5bbe4 --- /dev/null +++ b/tests/async_fixtures/test_async_fixtures_with_finalizer.py @@ -0,0 +1,50 @@ +import asyncio +import functools +import pytest + + +@pytest.mark.asyncio +async def test_module_with_event_loop_finalizer(port_with_event_loop_finalizer): + await asyncio.sleep(0.01) + assert port_with_event_loop_finalizer + +@pytest.mark.asyncio +async def test_module_with_get_event_loop_finalizer(port_with_get_event_loop_finalizer): + await asyncio.sleep(0.01) + assert port_with_get_event_loop_finalizer + +@pytest.fixture(scope="module") +def event_loop(): + """Change event_loop fixture to module level.""" + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="module") +async def port_with_event_loop_finalizer(request, event_loop): + def port_finalizer(finalizer): + async def port_afinalizer(): + # await task using loop provided by event_loop fixture + # RuntimeError is raised if task is created on a different loop + await finalizer + event_loop.run_until_complete(port_afinalizer()) + + worker = asyncio.ensure_future(asyncio.sleep(0.2)) + request.addfinalizer(functools.partial(port_finalizer, worker)) + return True + + +@pytest.fixture(scope="module") +async def port_with_get_event_loop_finalizer(request, event_loop): + def port_finalizer(finalizer): + async def port_afinalizer(): + # await task using loop provided by asyncio.get_event_loop() + # RuntimeError is raised if task is created on a different loop + await finalizer + asyncio.get_event_loop().run_until_complete(port_afinalizer()) + + worker = asyncio.ensure_future(asyncio.sleep(0.2)) + request.addfinalizer(functools.partial(port_finalizer, worker)) + return True