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 event_loop is run after other fixtures #156

Merged
merged 3 commits into from May 3, 2020
Merged

Fix event_loop is run after other fixtures #156

merged 3 commits into from May 3, 2020

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
Projects
None yet
3 participants