Skip to content

Fix event_loop is run after other fixtures#156

Merged
Tinche merged 3 commits intopytest-dev:masterfrom
simonfagerholm:fix_issue_154
May 3, 2020
Merged

Fix event_loop is run after other fixtures#156
Tinche merged 3 commits intopytest-dev:masterfrom
simonfagerholm:fix_issue_154

Conversation

@simonfagerholm
Copy link
Contributor

@simonfagerholm simonfagerholm commented Apr 22, 2020

Resolves #154, resolves #157, resolves #158
Also adds unittests for the use case.

Not ready for merge, breaks test_asyncio_marker_without_loop, which was added in #64.

class TestUnexistingLoop:
@pytest.fixture
def remove_loop(self):
old_loop = asyncio.get_event_loop()
asyncio.set_event_loop(None)
yield
asyncio.set_event_loop(old_loop)
@pytest.mark.asyncio
async def test_asyncio_marker_without_loop(self, remove_loop):
"""Test the asyncio pytest marker in a Test class."""
ret = await async_coro()
assert ret == 'ok'

Discussion for possible fix of broken test below

@simonfagerholm
Copy link
Contributor Author

simonfagerholm commented Apr 22, 2020

I did something that seems to fix the test, however I'm really not sure the behaviour is wanted:

diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 9860e04..c0b65da 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -125,14 +125,18 @@ def pytest_pyfunc_call(pyfuncitem): 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 + pyfuncitem.obj.hypothesis.inner_test, + _loop=pyfuncitem.funcargs['event_loop'] ) else: - pyfuncitem.obj = wrap_in_sync(pyfuncitem.obj) + pyfuncitem.obj = wrap_in_sync( + pyfuncitem.obj, + _loop=pyfuncitem.funcargs['event_loop'] + ) yield -def wrap_in_sync(func): +def wrap_in_sync(func, _loop): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -140,9 +144,15 @@ def wrap_in_sync(func): def inner(**kwargs): coro = func(**kwargs) if coro is not None: - task = asyncio.ensure_future(coro) try: - asyncio.get_event_loop().run_until_complete(task) + 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) except BaseException: # run_until_complete doesn't get the result from exceptions # that are not subclasses of `Exception`. Consume all

Basically the wrapper will attach the loop from the event_loop if get_event_loop fails because no current event loop is available.

@rsebille
Copy link

Hi,

I think the behavior is fine (second time that I look to the source of pytest-asyncio s don't take my word for it :D), but I would take it up a notch to "An asynchronous test function should run on the event loop that was used for its fixtures".

So I tried the following changes, and tests continue to pass but can't keep thinking that is probably too easy :).

diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 9860e04..08cf9d2 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -125,14 +125,18 @@ def pytest_pyfunc_call(pyfuncitem): 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 + pyfuncitem.obj.hypothesis.inner_test, + _loop=pyfuncitem.funcargs['event_loop'], ) else: - pyfuncitem.obj = wrap_in_sync(pyfuncitem.obj) + pyfuncitem.obj = wrap_in_sync( + pyfuncitem.obj, + _loop=pyfuncitem.funcargs['event_loop'], + ) yield -def wrap_in_sync(func): +def wrap_in_sync(func, _loop): """Return a sync wrapper around an async function executing it in the current event loop.""" @@ -140,9 +144,9 @@ def wrap_in_sync(func): def inner(**kwargs): coro = func(**kwargs) if coro is not None: - task = asyncio.ensure_future(coro) + task = asyncio.ensure_future(coro, loop=_loop) try: - asyncio.get_event_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
@simonfagerholm
Copy link
Contributor Author

simonfagerholm commented Apr 23, 2020

@rsebille The behavior and your comment differ I think:
In your change you always attach the loop from event_loop, which may or may not bethe same loop the fixtures run in.
I try to get the current loop first, enabling fixtures to change the loop if needed. As a fallback I use the event_loop, in case there is no loop (which should mean a fixture has set it to None as we know we started the event_loop-fixture before running fixtures).
Thus I think my original suggestion matches your comment more than the code you submitted.

@simonfagerholm
Copy link
Contributor Author

@rsebille I forgot to add that I appreciate that you took time to look into this and any future support :)

@rsebille
Copy link

I totally assume (slaps ensues) that asynchronous fixtures were explicitly run in the event_loop event loop to avoid "different loop" error and that the plugin would not allow changing the event loop without overriding event_loop.

@simonfagerholm No problem, thank to you for taking the time on this.

@Tinche
Copy link
Member

Tinche commented Apr 24, 2020

Thanks for this, I will look at this over the weekend!

@simonfagerholm
Copy link
Contributor Author

simonfagerholm commented Apr 25, 2020

@Tinche Thats great! In my digging I found out that the test is supposed to test for problems with pytest-aiohttp.
So I used the following as a test bench and discovered that it:

  • passes on pytest-asyncio==0.10
  • fails on pytest-asyncio==0.11
  • passes on my current branch
  • passes on my suggested addition to the branch fixing the unittest

As it fails on pytest-asyncio==0.11 and passes on this PR, it seems the unittest did not protect against that problem.

import pytest from aiohttp import web async def hello(request): return web.Response(body=b'Hello, world') def create_app(loop): app = web.Application(loop=loop) app.router.add_route('GET', '/', hello) return app async def test_hello(aiohttp_client): client = await aiohttp_client(create_app) resp = await client.get('/') assert resp.status == 200 text = await resp.text() assert 'Hello, world' in text @pytest.fixture async def async_fixture(): yield 'Hi from async_fixture()!' @pytest.mark.asyncio async def test_async_fixture_fixed(async_fixture): assert async_fixture == 'Hi from async_fixture()!'
============================= test session starts ============================= platform win32 -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1 -- python.exe cachedir: .pytest_cache rootdir: aiohttp_test plugins: aiohttp-0.3.0, asyncio-0.11.0 collecting ... collected 2 items test_aiohttp_error.py::test_hello[pyloop] test_aiohttp_error.py::test_async_fixture_fixed PASSED [ 50%]ERROR [100%] test setup failed args = (), kwargs = {} request = <SubRequest 'async_fixture' for <Function test_async_fixture_fixed>> setup = <function pytest_fixture_setup.<locals>.wrapper.<locals>.setup at 0x0000019B771C0678> finalizer = <function pytest_fixture_setup.<locals>.wrapper.<locals>.finalizer at 0x0000019B771C0F78> def wrapper(*args, **kwargs): request = kwargs['request'] if strip_request: del kwargs['request'] gen_obj = generator(*args, **kwargs) async def setup(): res = await gen_obj.__anext__() return res def finalizer(): """Yield again, to finalize.""" async def async_finalizer(): try: await gen_obj.__anext__() except StopAsyncIteration: pass else: msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) asyncio.get_event_loop().run_until_complete(async_finalizer()) request.addfinalizer(finalizer) > return asyncio.get_event_loop().run_until_complete(setup()) venv\lib\site-packages\pytest_asyncio\plugin.py:102: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <asyncio.windows_events.WindowsSelectorEventLoopPolicy object at 0x0000019B771580C8> def get_event_loop(self): """Get the event loop for the current context. Returns an instance of EventLoop or raises an exception. """ if (self._local._loop is None and not self._local._set_called and isinstance(threading.current_thread(), threading._MainThread)): self.set_event_loop(self.new_event_loop()) if self._local._loop is None: raise RuntimeError('There is no current event loop in thread %r.' > % threading.current_thread().name) E RuntimeError: There is no current event loop in thread 'MainThread'. C:\Program Files\Python\Python37\lib\asyncio\events.py:644: RuntimeError test_aiohttp_error.py::test_async_fixture_fixed ERROR [100%] test_aiohttp_error.py:27 (test_async_fixture_fixed) def finalizer(): """Yield again, to finalize.""" async def async_finalizer(): try: await gen_obj.__anext__() except StopAsyncIteration: pass else: msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) > asyncio.get_event_loop().run_until_complete(async_finalizer()) venv\lib\site-packages\pytest_asyncio\plugin.py:99: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <asyncio.windows_events.WindowsSelectorEventLoopPolicy object at 0x0000019B771580C8> def get_event_loop(self): """Get the event loop for the current context. Returns an instance of EventLoop or raises an exception. """ if (self._local._loop is None and not self._local._set_called and isinstance(threading.current_thread(), threading._MainThread)): self.set_event_loop(self.new_event_loop()) if self._local._loop is None: raise RuntimeError('There is no current event loop in thread %r.' > % threading.current_thread().name) E RuntimeError: There is no current event loop in thread 'MainThread'. C:\Program Files\Python\Python37\lib\asyncio\events.py:644: RuntimeError =================================== ERRORS ==================================== 
@simonfagerholm
Copy link
Contributor Author

I also tested the examples in #157 and verified that this PR solves #157.

@simonfagerholm simonfagerholm changed the title Fix "attached to a different loop", issue #154 Fix "attached to a different loop" and "event loop is closed" Apr 25, 2020
@Tinche
Copy link
Member

Tinche commented Apr 26, 2020

Yeah, I guess that unittest from #64 was useless.

On @simonfagerholm's branch, and applied the linked diff (which I'm ok with). So I tried installing pytest-aiohttp to run the provided example, and having pytest-aiohttp installed breaks a bunch of our own tests. I guess that can't be avoided? It was this way back on 0.10.0 too.

If you could add a changelog entry, I'll release this, with thanks :)

@simonfagerholm
Copy link
Contributor Author

@Tinche Pushed a commit of my diff above. Thanks for your help in the investigation, do you have any ideas on how to test the compatibility with aiohttp? I somewhat dislike the inclusion of aiohttp in the tests, but it might not be possible in other ways.
Maybe a "test suite" of compatibility with other packages including aiohttp, that can be separated from the other tests.

@Tinche
Copy link
Member

Tinche commented Apr 27, 2020

Actually I don't really have a good idea. It would be ideal if they started depending on us, since I feel they are a higher level component...

nolar added a commit to nolar/kopf-fork-from-zalando-incubator that referenced this pull request Apr 27, 2020
@simonfagerholm simonfagerholm changed the title Fix "attached to a different loop" and "event loop is closed" Fix event_loop is run after other fixtures Apr 28, 2020
@Tinche Tinche merged commit b45de23 into pytest-dev:master May 3, 2020
@Tinche
Copy link
Member

Tinche commented May 3, 2020

Thanks a lot @simonfagerholm !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants