diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 1fc0e6a130f..da7848c5a98 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -191,6 +191,7 @@ Will McGugan Willem de Groot Wilson Ong Yannick Koechlin +Yannick PĂ©roux Yegor Roganov Young-Ho Cha Yuriy Shatrov diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index cb8725bc9fa..a528cf05e30 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -6,6 +6,7 @@ import cgi import datetime import functools +import inspect import os import re import sys @@ -237,6 +238,12 @@ def current_task(loop=None): return task +def isasyncgenfunction(obj): + if hasattr(inspect, 'isasyncgenfunction'): + return inspect.isasyncgenfunction(obj) + return False + + def parse_mimetype(mimetype): """Parses a MIME type into its components. diff --git a/aiohttp/pytest_plugin.py b/aiohttp/pytest_plugin.py index f9af593daf7..9c8679562fb 100644 --- a/aiohttp/pytest_plugin.py +++ b/aiohttp/pytest_plugin.py @@ -7,6 +7,7 @@ import pytest from py import path +from aiohttp.helpers import isasyncgenfunction from aiohttp.web import Application from .test_utils import unused_port as _unused_port @@ -37,6 +38,62 @@ def pytest_addoption(parser): help='enable event loop debug mode') +def pytest_fixture_setup(fixturedef, request): + """ + Allow fixtures to be coroutines. Run coroutine fixtures in an event loop. + """ + func = fixturedef.func + + if isasyncgenfunction(func): + # async generator fixture + is_async_gen = True + elif asyncio.iscoroutinefunction(func): + # regular async fixture + is_async_gen = False + else: + # not an async fixture, nothing to do + return + + strip_request = False + if 'request' not in fixturedef.argnames: + fixturedef.argnames += ('request',) + strip_request = True + + def wrapper(*args, **kwargs): + request = kwargs['request'] + if strip_request: + del kwargs['request'] + + # if neither the fixture nor the test use the 'loop' fixture, + # 'getfixturevalue' will fail because the test is not parameterized + # (this can be removed someday if 'loop' is no longer parameterized) + if 'loop' not in request.fixturenames: + raise Exception( + "Asynchronous fixtures must depend on the 'loop' fixture or " + "be used in tests depending from it." + ) + + _loop = request.getfixturevalue('loop') + + if is_async_gen: + # for async generators, we need to advance the generator once, + # then advance it again in a finalizer + gen = func(*args, **kwargs) + + def finalizer(): + try: + return _loop.run_until_complete(gen.__anext__()) + except StopAsyncIteration: # NOQA + pass + + request.addfinalizer(finalizer) + return _loop.run_until_complete(gen.__anext__()) + else: + return _loop.run_until_complete(func(*args, **kwargs)) + + fixturedef.func = wrapper + + @pytest.fixture def fast(request): """ --fast config option """ diff --git a/changes/2223.feature b/changes/2223.feature new file mode 100644 index 00000000000..e07b8da0bd9 --- /dev/null +++ b/changes/2223.feature @@ -0,0 +1 @@ +Accept coroutine fixtures in pytest plugin diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 9b6665d1f47..a62d5ab3b38 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -3,6 +3,8 @@ import pytest +from aiohttp.pytest_plugin import LOOP_FACTORIES + pytest_plugins = 'pytester' @@ -180,3 +182,116 @@ async def test_bad(): stdout, _ = capsys.readouterr() assert ("test_warning_checks.py:__LINE__:coroutine 'foobar' was " "never awaited" in re.sub('\d{2,}', '__LINE__', stdout)) + + +def test_aiohttp_plugin_async_fixture(testdir, capsys): + testdir.makepyfile("""\ +import asyncio +import pytest + +from aiohttp import web + + +pytest_plugins = 'aiohttp.pytest_plugin' + + +@asyncio.coroutine +def hello(request): + return web.Response(body=b'Hello, world') + + +def create_app(loop): + app = web.Application() + app.router.add_route('GET', '/', hello) + return app + + +@pytest.fixture +@asyncio.coroutine +def cli(test_client): + client = yield from test_client(create_app) + return client + + +@pytest.fixture +@asyncio.coroutine +def foo(): + return 42 + + +@pytest.fixture +@asyncio.coroutine +def bar(request): + # request should be accessible in async fixtures if needed + return request.function + + +@asyncio.coroutine +def test_hello(cli): + resp = yield from cli.get('/') + assert resp.status == 200 + + +def test_foo(loop, foo): + assert foo == 42 + + +def test_foo_without_loop(foo): + # will raise an error because there is no loop + pass + + +def test_bar(loop, bar): + assert bar is test_bar +""") + nb_loops = len(LOOP_FACTORIES) + result = testdir.runpytest('-p', 'no:sugar') + result.assert_outcomes(passed=3 * nb_loops, error=1) + result.stdout.fnmatch_lines( + "*Asynchronous fixtures must depend on the 'loop' fixture " + "or be used in tests depending from it." + ) + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason='old python') +def test_aiohttp_plugin_async_gen_fixture(testdir): + testdir.makepyfile("""\ +import asyncio +import pytest +from unittest import mock + +from aiohttp import web + + +pytest_plugins = 'aiohttp.pytest_plugin' + +canary = mock.Mock() + + +async def hello(request): + return web.Response(body=b'Hello, world') + + +def create_app(loop): + app = web.Application() + app.router.add_route('GET', '/', hello) + return app + + +@pytest.fixture +async def cli(test_client): + yield await test_client(create_app) + canary() + + +async def test_hello(cli): + resp = await cli.get('/') + assert resp.status == 200 + + +def test_finalized(): + assert canary.called is True +""") + nb_loops = len(LOOP_FACTORIES) + result = testdir.runpytest('-p', 'no:sugar') + result.assert_outcomes(passed=1 * nb_loops + 1)