diff --git a/.travis.yml b/.travis.yml index cc1ed984..4bcaf218 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,13 @@ language: python -python: 3.5 - -env: - - TOX_ENV=py33 - - TOX_ENV=py34 - - TOX_ENV=py35 +python: + - "3.3" + - "3.4" + - "3.5" install: - - pip install tox + - pip install tox tox-travis -script: tox -e $TOX_ENV +script: tox after_success: - pip install coveralls && cd tests && coveralls diff --git a/README.rst b/README.rst index 44387eb6..90704b4e 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,7 @@ Features - fixtures for injecting unused tcp ports - pytest markers for treating tests as asyncio coroutines - easy testing with non-default event loops - +- support of `async def` fixtures and async generator fixtures Installation ------------ @@ -122,6 +122,23 @@ when several unused TCP ports are required in a test. port1, port2 = unused_tcp_port_factory(), unused_tcp_port_factory() ... +``async fixtures`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This fixtures may be defined as common pytest fixture: + +.. code-block:: python + + @pytest.fixture(scope='function') + async def async_gen_fixture(): + yield await asyncio.sleep(0.1) + + @pytest.fixture(scope='function') + async def async_fixture(): + return await asyncio.sleep(0.1) + +They behave just like a common fixtures, except that they **must** be function-scoped. +That ensures that they a run in the same event loop as test function. + Markers ------- @@ -172,6 +189,7 @@ Changelog - Using ``forbid_global_loop`` now allows tests to use ``asyncio`` subprocesses. `#36 `_ +- support for async and async gen fixtures 0.5.0 (2016-09-07) ~~~~~~~~~~~~~~~~~~ diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index ae998961..8c082014 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,4 +1,2 @@ """The main point for importing pytest-asyncio items.""" __version__ = '0.5.0' - -from .plugin import async_fixture diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index c3ad3aad..ef5c8bf9 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,19 +1,15 @@ """pytest-asyncio implementation.""" import asyncio -import functools +import contextlib import inspect import socket - +import sys from concurrent.futures import ProcessPoolExecutor -from contextlib import closing import pytest - -from _pytest.fixtures import FixtureFunctionMarker from _pytest.python import transfer_markers - class ForbiddenEventLoopPolicy(asyncio.DefaultEventLoopPolicy): """An event loop policy that raises errors on most operations. @@ -87,6 +83,35 @@ def pytest_fixture_setup(fixturedef, request): return outcome +@asyncio.coroutine +def initialize_async_fixtures(funcargs, testargs): + """ + Get async generator fixtures first value, and await coroutine fixtures + """ + for name, value in funcargs.items(): + if name not in testargs: + continue + if sys.version_info >= (3, 6) and inspect.isasyncgen(value): + try: + testargs[name] = yield from value.__anext__() + except StopAsyncIteration: + raise RuntimeError("async generator didn't yield") from None + elif sys.version_info >= (3, 5) and inspect.iscoroutine(value): + testargs[name] = yield from value + + +@asyncio.coroutine +def finalize_async_fixtures(funcargs, testargs): + for name, value in funcargs.items(): + if sys.version_info >= (3, 6) and inspect.isasyncgen(value): + try: + yield from value.__anext__() + except StopAsyncIteration: + continue + else: + raise RuntimeError("async generator didn't stop") + + @pytest.mark.tryfirst def pytest_pyfunc_call(pyfuncitem): """ @@ -100,8 +125,17 @@ def pytest_pyfunc_call(pyfuncitem): funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} - event_loop.run_until_complete( - asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop)) + + @asyncio.coroutine + def func_executor(event_loop): + """Ensure that test function and async fixtures run in one loop""" + yield from initialize_async_fixtures(funcargs, testargs) + try: + yield from asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop) + finally: + yield from finalize_async_fixtures(funcargs, testargs) + + event_loop.run_until_complete(func_executor(event_loop)) return True @@ -140,7 +174,7 @@ def event_loop_process_pool(event_loop): @pytest.fixture def unused_tcp_port(): """Find an unused localhost TCP port from 1024-65535 and return it.""" - with closing(socket.socket()) as sock: + with contextlib.closing(socket.socket()) as sock: sock.bind(('127.0.0.1', 0)) return sock.getsockname()[1] @@ -161,34 +195,3 @@ def factory(): return port return factory - - -class AsyncFixtureFunctionMarker(FixtureFunctionMarker): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def __call__(self, coroutine): - """The parameter is the actual fixture coroutine.""" - if not _is_coroutine(coroutine): - raise ValueError('Only coroutine functions supported') - - @functools.wraps(coroutine) - def inner(*args, **kwargs): - loop = None - return loop.run_until_complete(coroutine(*args, **kwargs)) - - inner._pytestfixturefunction = self - return inner - - -def async_fixture(scope='function', params=None, autouse=False, ids=None): - if callable(scope) and params is None and not autouse: - # direct invocation - marker = AsyncFixtureFunctionMarker( - 'function', params, autouse) - return marker(scope) - if params is not None and not isinstance(params, (list, tuple)): - params = list(params) - return AsyncFixtureFunctionMarker( - scope, params, autouse, ids=ids) diff --git a/tests/async_fixtures/__init__.py b/tests/async_fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/async_fixtures/test_async_fixtures_35.py b/tests/async_fixtures/test_async_fixtures_35.py new file mode 100644 index 00000000..b7c8b2cc --- /dev/null +++ b/tests/async_fixtures/test_async_fixtures_35.py @@ -0,0 +1,41 @@ +import asyncio +import unittest.mock + +import pytest + +START = object() +END = object() +RETVAL = object() + + +@pytest.fixture +def mock(): + return unittest.mock.Mock(return_value=RETVAL) + + +@pytest.fixture +async def async_fixture(mock): + return await asyncio.sleep(0.1, result=mock(START)) + + +@pytest.mark.asyncio +async def test_async_fixture(async_fixture, mock): + assert mock.call_count == 1 + assert mock.call_args_list[-1] == unittest.mock.call(START) + assert async_fixture is RETVAL + + +@pytest.fixture(scope='module') +async def async_fixture_module_cope(): + return await asyncio.sleep(0.1, result=RETVAL) + + +@pytest.mark.asyncio +async def test_async_fixture_module_cope1(async_fixture_module_cope): + assert async_fixture_module_cope is RETVAL + + +@pytest.mark.asyncio +@pytest.mark.xfail(reason='Only function scoped async fixtures are supported') +async def test_async_fixture_module_cope2(async_fixture_module_cope): + assert async_fixture_module_cope is RETVAL \ No newline at end of file diff --git a/tests/async_fixtures/test_async_gen_fixtures_36.py b/tests/async_fixtures/test_async_gen_fixtures_36.py new file mode 100644 index 00000000..8c85bc6d --- /dev/null +++ b/tests/async_fixtures/test_async_gen_fixtures_36.py @@ -0,0 +1,39 @@ +import asyncio +import unittest.mock + +import pytest + +START = object() +END = object() +RETVAL = object() + + +@pytest.fixture(scope='module') +def mock(): + return unittest.mock.Mock(return_value=RETVAL) + + +@pytest.fixture +async def async_gen_fixture(mock): + try: + yield mock(START) + except Exception as e: + mock(e) + else: + mock(END) + + +@pytest.mark.asyncio +async def test_async_gen_fixture(async_gen_fixture, mock): + assert mock.called + assert mock.call_args_list[-1] == unittest.mock.call(START) + assert async_gen_fixture is RETVAL + + +@pytest.mark.asyncio +async def test_async_gen_fixture_finalized(mock): + try: + assert mock.called + assert mock.call_args_list[-1] == unittest.mock.call(END) + finally: + mock.reset_mock() \ No newline at end of file diff --git a/tests/async_fixtures/test_coroutine_fixtures.py b/tests/async_fixtures/test_coroutine_fixtures.py new file mode 100644 index 00000000..0cb5df5d --- /dev/null +++ b/tests/async_fixtures/test_coroutine_fixtures.py @@ -0,0 +1,29 @@ +import asyncio +import unittest.mock + +import pytest + +START = object() +END = object() +RETVAL = object() + +pytestmark = pytest.mark.skip(reason='@asyncio.coroutine fixtures are not supported yet') + + +@pytest.fixture +def mock(): + return unittest.mock.Mock(return_value=RETVAL) + + +@pytest.fixture +@asyncio.coroutine +def coroutine_fixture(mock): + yield from asyncio.sleep(0.1, result=mock(START)) + + +@pytest.mark.asyncio +@asyncio.coroutine +def test_coroutine_fixture(coroutine_fixture, mock): + assert mock.call_count == 1 + assert mock.call_args_list[-1] == unittest.mock.call(START) + assert coroutine_fixture is RETVAL \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index f63be63d..42059ac5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,9 @@ collect_ignore.append("test_simple_35.py") collect_ignore.append("markers/test_class_marker_35.py") collect_ignore.append("markers/test_module_marker_35.py") + collect_ignore.append("async_fixtures/test_async_fixtures_35.py") +if sys.version_info[:2] < (3, 6): + collect_ignore.append("async_fixtures/test_async_gen_fixtures_36.py") @pytest.yield_fixture() diff --git a/tests/test_async_fixtures.py b/tests/test_async_fixtures.py deleted file mode 100644 index a10e0eb6..00000000 --- a/tests/test_async_fixtures.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Tests for async fixtures.""" -import asyncio - -import pytest -from pytest_asyncio import async_fixture - -@pytest.fixture -def shared_state(): - """Some shared state, so we can assert the order of operations.""" - return {} - - -@async_fixture -def minimal_async_fixture(shared_state): - """A minimal asyncio fixture.""" - shared_state['async_fixture'] = 1 - yield from asyncio.sleep(0.01) - shared_state['async_fixture'] += 1 - - -@pytest.mark.asyncio -def test_minimal_asynx_fixture(shared_state, minimal_async_fixture): - """Test minimal async fixture working.""" - assert shared_state['async_fixture'] == 2 - yield from asyncio.sleep(0)