From c152ace1b22766ef3663d26ea0eab558e0794992 Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Tue, 24 May 2016 22:05:40 +0600 Subject: [PATCH 1/6] Bump version --- pytest_asyncio/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index 0404d810..f0ca8cfe 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '0.4.0-dev' From a3a6b83c995a734bf8a107d4431a236b57b16116 Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Wed, 25 May 2016 13:48:07 +0600 Subject: [PATCH 2/6] Big refactor: no more implicit loops, disallow global loop by default --- README.rst | 135 +++++++++++++--------- pytest_asyncio/plugin.py | 97 +++++++--------- tests/conftest.py | 18 +++ tests/multiloop/conftest.py | 2 +- tests/multiloop/test_alternative_loops.py | 32 +++-- tests/test_autouse.py | 12 ++ tests/test_autouse_35.py | 18 +++ tests/test_simple.py | 54 ++++----- tests/test_simple_35.py | 38 +++--- 9 files changed, 239 insertions(+), 167 deletions(-) create mode 100644 tests/test_autouse.py create mode 100644 tests/test_autouse_35.py diff --git a/README.rst b/README.rst index bdee5adb..93bf1cd3 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,8 @@ pytest-asyncio: pytest support for asyncio ========================================== -.. image:: https://img.shields.io/pypi/v/pytest-asyncio.svg - :target: https://pypi.python.org/pypi/pytest-asyncio -.. image:: https://travis-ci.org/pytest-dev/pytest-asyncio.svg?branch=master - :target: https://travis-ci.org/pytest-dev/pytest-asyncio -.. image:: https://coveralls.io/repos/pytest-dev/pytest-asyncio/badge.svg - :target: https://coveralls.io/r/pytest-dev/pytest-asyncio +.. image:: https://travis-ci.org/malinoff/pytest-asyncio.svg?branch=master + :target: https://travis-ci.org/malinoff/pytest-asyncio pytest-asyncio is an Apache2 licensed library, written in Python, for testing asyncio code with pytest. @@ -15,30 +11,102 @@ asyncio code is usually written in the form of coroutines, which makes it slightly more difficult to test using normal testing tools. pytest-asyncio provides useful fixtures and markers to make testing easier. +Original readme can be found in original `pytest-asyncio`_ repository. + +This fork completely changes how ``pytest-asyncio`` works with coroutine-based +tests and loops: + + * No more closing the loop after each test coroutine function. It's up + to the developer to choose when the loop is closed. + + * The use of global event loop is now forbidden by default. You can accept + it by providing ``accept_global_loop=True`` to ``@asyncio.mark.asyncio`` + + * No more implicit loops defined by the plugin. In order to use a loop, + you must explicitly create and request ``loop`` fixture, otherwise + the plugin will raise a ``MissingLoopFixture`` exception. This fixture + can be named anything, but requires to return an instance of + ``asyncio.BaseEventLoop``. There is one exception: if + ``accept_global_loop`` is ``True`` AND a ``loop`` fixture is not requested, + the plugin will use the global loop. + +The advantages are: + + * You do not rely on implicit event loops created by the plugin. + Want to use ``concurrent.futures.ThreadPoolExecutor``? Easy! + + .. code-block:: python + + @pytest.yield_fixture + def threadpooled_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + loop.set_default_executor(concurrent.futures.ThreadPoolExecutor()) + yield loop + loop.close() + + * Lifetimes of loop fixtures can be expanded to ``module`` or ``session`` + scopes easily (in the original plugin it is not possible because the loop + closes after each test coroutine function): + + .. code-block:: python + + @pytest.yield_fixture(scope='module') + def loop(): + ... + +Examples compared to the original examples: + .. code-block:: python + # Original @pytest.mark.asyncio async def test_some_asyncio_code(): res = await library.do_something() assert b'expected result' == res + # Fork + import asyncio + @pytest.yield_fixture + def loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + @pytest.mark.asyncio + async def test_some_asyncio_code(loop): + res = await library.do_something(loop=loop) + assert b'expected result' == res + or, if you're using the pre-Python 3.5 syntax: .. code-block:: python + # Original @pytest.mark.asyncio def test_some_asyncio_code(): res = yield from library.do_something() assert b'expected result' == res + # Fork + import asyncio + @pytest.fixture + def loop(): + return asyncio.get_event_loop_policy().new_event_loop() + + @pytest.mark.asyncio + async def test_some_asyncio_code(loop): + res = await library.do_something(loop=loop) + assert b'expected result' == res + pytest-asyncio has been strongly influenced by pytest-tornado_. +.. _pytest-asyncio: https://github.com/pytest-dev/pytest-asyncio/blob/master/README.rst .. _pytest-tornado: https://github.com/eugeniy/pytest-tornado Features -------- -- fixtures for creating and injecting versions of the asyncio event loop +- pluggable fixtures of the asyncio event loops - fixtures for injecting unused tcp ports - pytest markers for treating tests as asyncio coroutines - easy testing with non-default event loops @@ -51,48 +119,13 @@ To install pytest-asyncio, simply: .. code-block:: bash - $ pip install pytest-asyncio + $ pip install git+https://github.com/malinoff/pytest-asyncio This is enough for pytest to pick up pytest-asyncio. Fixtures -------- -``event_loop`` -~~~~~~~~~~~~~~ -Creates and injects a new instance of the default asyncio event loop. The loop -will be closed at the end of the test. - -Note that just using the ``event_loop`` fixture won't make your test function -a coroutine. You'll need to interact with the event loop directly, using methods -like ``event_loop.run_until_complete``. See the ``pytest.mark.asyncio`` marker -for treating test functions like coroutines. - -.. code-block:: python - - def test_http_client(event_loop): - url = 'http://httpbin.org/get' - resp = event_loop.run_until_complete(http_client(url)) - assert b'HTTP/1.1 200 OK' in resp - -This fixture can be easily overridden in any of the standard pytest locations -(e.g. directly in the test file, or in ``conftest.py``) to use a non-default -event loop. This will take effect even if you're using the -``pytest.mark.asyncio`` marker and not the ``event_loop`` fixture directly. - -.. code-block:: python - - @pytest.fixture() - def event_loop(): - return MyCustomLoop() - - -``event_loop_process_pool`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``event_loop_process_pool`` fixture is almost identical to the -``event_loop`` fixture, except the created event loop will have a -``concurrent.futures.ProcessPoolExecutor`` set as the default executor. - ``unused_tcp_port`` ~~~~~~~~~~~~~~~~~~~ Finds and yields a single unused TCP port on the localhost interface. Useful for @@ -112,25 +145,17 @@ when several unused TCP ports are required in a test. Markers ------- -``pytest.mark.asyncio(forbid_global_loop=False)`` +``pytest.mark.asyncio(accept_global_loop=False)`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Mark your test coroutine with this marker and pytest will execute it as an -asyncio task using the event loop provided by the ``event_loop`` fixture. See +asyncio task using the event loop provided by a ``loop`` fixture. See the introductory section for an example. -The event loop used can be overriden by overriding the ``event_loop`` fixture -(see above). +A different event loop can be provided easily, see the introductory section. -If ``forbid_global_loop`` is true, ``asyncio.get_event_loop()`` will result +If ``accept_global_loop`` is false, ``asyncio.get_event_loop()`` will result in exceptions, ensuring your tests are always passing the event loop explicitly. -``pytest.mark.asyncio_process_pool(forbid_global_loop=False)`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``asyncio_process_pool`` marker is almost identical to the ``asyncio`` -marker, except the event loop used will have a -``concurrent.futures.ProcessPoolExecutor`` set as the default executor. - - Contributing ------------ Contributions are very welcome. Tests can be run with ``tox``, please ensure diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 52338294..5f374086 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -6,6 +6,11 @@ import pytest +class MissingLoopFixture(Exception): + """Raised if a test coroutine function does not request a loop fixture.""" + pass + + class ForbiddenEventLoopPolicy(asyncio.AbstractEventLoopPolicy): """An event loop policy that raises errors on any operation.""" pass @@ -32,8 +37,7 @@ def pytest_configure(config): def pytest_pycollect_makeitem(collector, name, obj): if collector.funcnamefilter(name) and _is_coroutine(obj): item = pytest.Function(name, parent=collector) - if ('asyncio' in item.keywords or - 'asyncio_process_pool' in item.keywords): + if 'asyncio' in item.keywords: return list(collector._genfunctions(name, obj)) @@ -43,60 +47,41 @@ def pytest_pyfunc_call(pyfuncitem): Run asyncio marked test functions in an event loop instead of a normal function call. """ - for marker_name, fixture_name in _markers_2_fixtures.items(): - if marker_name in pyfuncitem.keywords: - event_loop = pyfuncitem.funcargs[fixture_name] - - forbid_global_loop = pyfuncitem.keywords[marker_name].kwargs.get('forbid_global_loop') - - policy = asyncio.get_event_loop_policy() - if forbid_global_loop: - asyncio.set_event_loop_policy(ForbiddenEventLoopPolicy()) - else: - policy.set_event_loop(event_loop) - - funcargs = pyfuncitem.funcargs - testargs = {arg: funcargs[arg] - for arg in pyfuncitem._fixtureinfo.argnames} - try: - event_loop.run_until_complete( - asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop)) - finally: - if forbid_global_loop: - asyncio.set_event_loop_policy(policy) - event_loop.close() - return True - - -def pytest_runtest_setup(item): - for marker, fixture in _markers_2_fixtures.items(): - if marker in item.keywords and fixture not in item.fixturenames: - # inject an event loop fixture for all async tests - item.fixturenames.append(fixture) - - -# maps marker to the name of the event loop fixture that will be available -# to marked test functions -_markers_2_fixtures = { - 'asyncio': 'event_loop', - 'asyncio_process_pool': 'event_loop_process_pool', -} - - -@pytest.fixture -def event_loop(request): - """Create an instance of the default event loop for each test case.""" - policy = asyncio.get_event_loop_policy() - return policy.new_event_loop() - - -@pytest.fixture -def event_loop_process_pool(event_loop): - """Create a fresh instance of the default event loop. - - The event loop will have a process pool set as the default executor.""" - event_loop.set_default_executor(ProcessPoolExecutor()) - return event_loop + if 'asyncio' in pyfuncitem.keywords: + marker_kwargs = pyfuncitem.keywords['asyncio'].kwargs + accept_global_loop = marker_kwargs.get('accept_global_loop', False) + + event_loop = None + for name, value in pyfuncitem.funcargs.items(): + if isinstance(value, asyncio.BaseEventLoop): + event_loop = value + break + else: + if not accept_global_loop: + raise MissingLoopFixture('A loop fixture must be provided ' + 'to run test coroutine functions') + + policy = asyncio.get_event_loop_policy() + current_event_loop = policy.get_event_loop() + if accept_global_loop and event_loop is None: + event_loop = current_event_loop + + if not accept_global_loop: + asyncio.set_event_loop_policy(ForbiddenEventLoopPolicy()) + else: + policy.set_event_loop(event_loop) + + + funcargs = pyfuncitem.funcargs + testargs = {arg: funcargs[arg] + for arg in pyfuncitem._fixtureinfo.argnames} + try: + event_loop.run_until_complete( + asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop)) + return True + finally: + if not accept_global_loop: + asyncio.set_event_loop_policy(policy) @pytest.fixture diff --git a/tests/conftest.py b/tests/conftest.py index b943874d..96459c31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,23 @@ import sys +import pytest +import asyncio +import concurrent.futures collect_ignore = [] if sys.version_info[:2] < (3, 5): collect_ignore.append("test_simple_35.py") + collect_ignore.append("test_autouse_35.py") + + +@pytest.yield_fixture +def loop(): + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def loop_process_pool(loop): + loop.set_default_executor(concurrent.futures.ProcessPoolExecutor()) + return loop diff --git a/tests/multiloop/conftest.py b/tests/multiloop/conftest.py index 72527fa3..1d6b8507 100644 --- a/tests/multiloop/conftest.py +++ b/tests/multiloop/conftest.py @@ -9,6 +9,6 @@ class CustomSelectorLoop(asyncio.SelectorEventLoop): @pytest.fixture() -def event_loop(): +def loop(): """Create an instance of the default event loop for each test case.""" return CustomSelectorLoop() diff --git a/tests/multiloop/test_alternative_loops.py b/tests/multiloop/test_alternative_loops.py index f8c5595d..6bfdcd16 100644 --- a/tests/multiloop/test_alternative_loops.py +++ b/tests/multiloop/test_alternative_loops.py @@ -5,15 +5,33 @@ @pytest.mark.asyncio -def test_for_custom_loop(): +def test_for_custom_loop(loop): """This test should be executed using the custom loop.""" - yield from asyncio.sleep(0.01) - assert type(asyncio.get_event_loop()).__name__ == "CustomSelectorLoop" + yield from asyncio.sleep(0.01, loop=loop) + assert type(loop).__name__ == "CustomSelectorLoop" -@pytest.mark.asyncio(forbid_global_loop=True) -def test_forbid_global_loop(event_loop): +@pytest.mark.asyncio +def test_forbid_global_loop(loop): """Test forbidding fetching the global loop using get_event_loop.""" - yield from asyncio.sleep(0.01, loop=event_loop) - with pytest.raises(Exception): + yield from asyncio.sleep(0.01, loop=loop) + with pytest.raises(NotImplementedError): asyncio.get_event_loop() + + +@pytest.mark.asyncio(accept_global_loop=True) +def test_accept_global_loop(loop): + """Test accepting fetching the global loop using get_event_loop.""" + yield from asyncio.sleep(0.01, loop=loop) + global_loop = asyncio.get_event_loop() + assert global_loop is loop + + +@pytest.mark.asyncio(accept_global_loop=True) +def test_no_loop_fixture(): + """Test accepting running the test coroutine when using the global loop + is accepted and a loop fixture is not provided. + """ + global_loop = asyncio.get_event_loop() + yield from asyncio.sleep(0.01) + assert True diff --git a/tests/test_autouse.py b/tests/test_autouse.py new file mode 100644 index 00000000..299fde4c --- /dev/null +++ b/tests/test_autouse.py @@ -0,0 +1,12 @@ +import asyncio +import pytest + + +@pytest.fixture(autouse=True) +def loop(loop): + return loop + + +@pytest.mark.asyncio +def test_autoused_loop(): + yield # sleep(0) diff --git a/tests/test_autouse_35.py b/tests/test_autouse_35.py new file mode 100644 index 00000000..28751b92 --- /dev/null +++ b/tests/test_autouse_35.py @@ -0,0 +1,18 @@ +import asyncio +import pytest + + +@pytest.fixture(autouse=True) +def loop(loop): + return loop + + +async def async_coro(): + await asyncio.sleep(0) + return 'ok' + + +@pytest.mark.asyncio +async def test_autoused_loop(): + ret = await async_coro() + assert ret == 'ok' diff --git a/tests/test_simple.py b/tests/test_simple.py index 6ada9361..1ea438a5 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,6 +1,6 @@ """Quick'n'dirty unit tests for provided fixtures and markers.""" -import asyncio import os +import asyncio import pytest import pytest_asyncio.plugin @@ -12,48 +12,44 @@ def async_coro(loop): return 'ok' -def test_event_loop_fixture(event_loop): - """Test the injection of the event_loop fixture.""" - assert event_loop - ret = event_loop.run_until_complete(async_coro(event_loop)) +def test_loop_fixture(loop): + assert loop + ret = loop.run_until_complete(async_coro(loop)) assert ret == 'ok' -def test_event_loop_processpool_fixture(event_loop_process_pool): - """Test the injection of the event_loop with a process pool fixture.""" - assert event_loop_process_pool - - ret = event_loop_process_pool.run_until_complete( - async_coro(event_loop_process_pool)) +def test_loop_process_pool_fixture(loop_process_pool): + assert loop_process_pool + ret = loop_process_pool.run_until_complete( + async_coro(loop_process_pool)) assert ret == 'ok' this_pid = os.getpid() - future = event_loop_process_pool.run_in_executor(None, os.getpid) - pool_pid = event_loop_process_pool.run_until_complete(future) + future = loop_process_pool.run_in_executor(None, os.getpid) + pool_pid = loop_process_pool.run_until_complete(future) assert this_pid != pool_pid @pytest.mark.asyncio -def test_asyncio_marker(): +def test_asyncio_marker(loop): """Test the asyncio pytest marker.""" yield # sleep(0) @pytest.mark.asyncio -def test_asyncio_marker_with_default_param(a_param=None): +def test_asyncio_marker_with_default_param(loop, a_param=None): """Test the asyncio pytest marker.""" yield # sleep(0) -@pytest.mark.asyncio_process_pool -def test_asyncio_process_pool_marker(event_loop): - """Test the asyncio pytest marker.""" - ret = yield from async_coro(event_loop) +@pytest.mark.asyncio +def test_asyncio_process_pool(loop_process_pool): + ret = yield from async_coro(loop_process_pool) assert ret == 'ok' @pytest.mark.asyncio -def test_unused_port_fixture(unused_tcp_port, event_loop): +def test_unused_port_fixture(unused_tcp_port, loop): """Test the unused TCP port fixture.""" @asyncio.coroutine @@ -62,19 +58,19 @@ def closer(_, writer): server1 = yield from asyncio.start_server(closer, host='localhost', port=unused_tcp_port, - loop=event_loop) + loop=loop) with pytest.raises(IOError): yield from asyncio.start_server(closer, host='localhost', port=unused_tcp_port, - loop=event_loop) + loop=loop) server1.close() yield from server1.wait_closed() @pytest.mark.asyncio -def test_unused_port_factory_fixture(unused_tcp_port_factory, event_loop): +def test_unused_port_factory_fixture(unused_tcp_port_factory, loop): """Test the unused TCP port factory fixture.""" @asyncio.coroutine @@ -86,19 +82,19 @@ def closer(_, writer): server1 = yield from asyncio.start_server(closer, host='localhost', port=port1, - loop=event_loop) + loop=loop) server2 = yield from asyncio.start_server(closer, host='localhost', port=port2, - loop=event_loop) + loop=loop) server3 = yield from asyncio.start_server(closer, host='localhost', port=port3, - loop=event_loop) + loop=loop) for port in port1, port2, port3: with pytest.raises(IOError): yield from asyncio.start_server(closer, host='localhost', port=port, - loop=event_loop) + loop=loop) server1.close() yield from server1.wait_closed() @@ -132,7 +128,7 @@ class Test: """Test that asyncio marked functions work in test methods.""" @pytest.mark.asyncio - def test_asyncio_marker_method(self, event_loop): + def test_asyncio_marker_method(self, loop): """Test the asyncio pytest marker in a Test class.""" - ret = yield from async_coro(event_loop) + ret = yield from async_coro(loop) assert ret == 'ok' diff --git a/tests/test_simple_35.py b/tests/test_simple_35.py index cfc9ec5e..2e7161d9 100644 --- a/tests/test_simple_35.py +++ b/tests/test_simple_35.py @@ -11,36 +11,36 @@ async def async_coro(loop): @pytest.mark.asyncio -async def test_asyncio_marker(): +async def test_asyncio_marker(loop): """Test the asyncio pytest marker.""" @pytest.mark.asyncio -async def test_asyncio_marker_with_default_param(a_param=None): +async def test_asyncio_marker_with_default_param(loop, a_param=None): """Test the asyncio pytest marker.""" -@pytest.mark.asyncio_process_pool -async def test_asyncio_process_pool_marker(event_loop): - ret = await async_coro(event_loop) +@pytest.mark.asyncio +async def test_asyncio_process_pool(loop_process_pool): + ret = await async_coro(loop_process_pool) assert ret == 'ok' @pytest.mark.asyncio -async def test_unused_port_fixture(unused_tcp_port, event_loop): +async def test_unused_port_fixture(unused_tcp_port, loop): """Test the unused TCP port fixture.""" async def closer(_, writer): writer.close() server1 = await asyncio.start_server(closer, host='localhost', port=unused_tcp_port, - loop=event_loop) + loop=loop) server1.close() await server1.wait_closed() -def test_unused_port_factory_fixture(unused_tcp_port_factory, event_loop): +def test_unused_port_factory_fixture(unused_tcp_port_factory, loop): """Test the unused TCP port factory fixture.""" async def closer(_, writer): @@ -52,19 +52,19 @@ async def closer(_, writer): async def run_test(): server1 = await asyncio.start_server(closer, host='localhost', port=port1, - loop=event_loop) + loop=loop) server2 = await asyncio.start_server(closer, host='localhost', port=port2, - loop=event_loop) + loop=loop) server3 = await asyncio.start_server(closer, host='localhost', port=port3, - loop=event_loop) + loop=loop) for port in port1, port2, port3: with pytest.raises(IOError): await asyncio.start_server(closer, host='localhost', port=port, - loop=event_loop) + loop=loop) server1.close() await server1.wait_closed() @@ -73,22 +73,22 @@ async def run_test(): server3.close() await server3.wait_closed() - event_loop.run_until_complete(run_test()) + loop.run_until_complete(run_test()) - event_loop.stop() - event_loop.close() + loop.stop() + loop.close() class Test: """Test that asyncio marked functions work in test methods.""" @pytest.mark.asyncio - async def test_asyncio_marker_method(self, event_loop): + async def test_asyncio_marker_method(self, loop): """Test the asyncio pytest marker in a Test class.""" - ret = await async_coro(event_loop) + ret = await async_coro(loop) assert ret == 'ok' -def test_async_close_loop(event_loop): - event_loop.close() +def test_async_close_loop(loop): + loop.close() return 'ok' From 81321e49fe8d697acd3e5b923990a091d9c8242f Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Wed, 25 May 2016 13:51:56 +0600 Subject: [PATCH 3/6] Fix base event loop cls --- README.rst | 2 +- pytest_asyncio/plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 93bf1cd3..dde6a1b9 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ tests and loops: you must explicitly create and request ``loop`` fixture, otherwise the plugin will raise a ``MissingLoopFixture`` exception. This fixture can be named anything, but requires to return an instance of - ``asyncio.BaseEventLoop``. There is one exception: if + ``asyncio.AbstractEventLoop``. There is one exception: if ``accept_global_loop`` is ``True`` AND a ``loop`` fixture is not requested, the plugin will use the global loop. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 5f374086..4a4b42aa 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -53,7 +53,7 @@ def pytest_pyfunc_call(pyfuncitem): event_loop = None for name, value in pyfuncitem.funcargs.items(): - if isinstance(value, asyncio.BaseEventLoop): + if isinstance(value, asyncio.AbstractEventLoop): event_loop = value break else: From 9ca2fceeb3891dd7cbe614a934c1d3d6421ec2be Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Wed, 25 May 2016 14:16:50 +0600 Subject: [PATCH 4/6] No much sense to use autouse=True loop fixtures --- tests/test_autouse.py | 12 ------------ tests/test_autouse_35.py | 18 ------------------ 2 files changed, 30 deletions(-) delete mode 100644 tests/test_autouse.py delete mode 100644 tests/test_autouse_35.py diff --git a/tests/test_autouse.py b/tests/test_autouse.py deleted file mode 100644 index 299fde4c..00000000 --- a/tests/test_autouse.py +++ /dev/null @@ -1,12 +0,0 @@ -import asyncio -import pytest - - -@pytest.fixture(autouse=True) -def loop(loop): - return loop - - -@pytest.mark.asyncio -def test_autoused_loop(): - yield # sleep(0) diff --git a/tests/test_autouse_35.py b/tests/test_autouse_35.py deleted file mode 100644 index 28751b92..00000000 --- a/tests/test_autouse_35.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import pytest - - -@pytest.fixture(autouse=True) -def loop(loop): - return loop - - -async def async_coro(): - await asyncio.sleep(0) - return 'ok' - - -@pytest.mark.asyncio -async def test_autoused_loop(): - ret = await async_coro() - assert ret == 'ok' From d939765824102426a5a0ed87a28f40d739c736fc Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Tue, 31 May 2016 00:40:09 +0600 Subject: [PATCH 5/6] Initial work on async fixtures --- README.rst | 13 +++++++++ pytest_asyncio/__init__.py | 2 ++ pytest_asyncio/exceptions.py | 3 +++ pytest_asyncio/fixture.py | 44 ++++++++++++++++++++++++++++++ pytest_asyncio/plugin.py | 49 +++++++--------------------------- pytest_asyncio/utils.py | 39 +++++++++++++++++++++++++++ tests/conftest.py | 2 +- tests/test_async_fixture.py | 44 ++++++++++++++++++++++++++++++ tests/test_async_fixture_35.py | 13 +++++++++ 9 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 pytest_asyncio/exceptions.py create mode 100644 pytest_asyncio/fixture.py create mode 100644 pytest_asyncio/utils.py create mode 100644 tests/test_async_fixture.py create mode 100644 tests/test_async_fixture_35.py diff --git a/README.rst b/README.rst index dde6a1b9..b5769dca 100644 --- a/README.rst +++ b/README.rst @@ -54,6 +54,19 @@ The advantages are: def loop(): ... +You can also create coroutine-based fixtures: + + .. code-block:: python + + @pytest_asyncio.async_fixture + async def async_fixture(loop): + await asyncio.sleep(1, loop=loop) + return 'something' + +By default, using a global asyncio loop is forbidden by default, similarly +to the test coroutine functions. To accept it, provide ``accept_global_loop=True`` +to the ``@pytest_asyncio.async_fixture`` decorator. + Examples compared to the original examples: .. code-block:: python diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index f0ca8cfe..c84aed23 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1 +1,3 @@ +from .fixture import async_fixture + __version__ = '0.4.0-dev' diff --git a/pytest_asyncio/exceptions.py b/pytest_asyncio/exceptions.py new file mode 100644 index 00000000..f2555fd3 --- /dev/null +++ b/pytest_asyncio/exceptions.py @@ -0,0 +1,3 @@ +class MissingLoopFixture(Exception): + """Raised if a test coroutine function does not request a loop fixture.""" + pass diff --git a/pytest_asyncio/fixture.py b/pytest_asyncio/fixture.py new file mode 100644 index 00000000..599945b6 --- /dev/null +++ b/pytest_asyncio/fixture.py @@ -0,0 +1,44 @@ +import asyncio +import itertools +import functools + +import pytest # noqa +import _pytest.python + +from .utils import find_loop, maybe_accept_global_loop + + +class AsyncFixtureFunctionMarker(_pytest.python.FixtureFunctionMarker): + + def __init__(self, *args, accept_global_loop, **kwargs): + super().__init__(*args, **kwargs) + self.accept_global_loop = accept_global_loop + + def __call__(self, coroutine): + if not asyncio.iscoroutinefunction(coroutine): + raise ValueError('Only coroutine functions supported') + + @functools.wraps(coroutine) + def inner(*args, **kwargs): + event_loop = find_loop(itertools.chain(args, kwargs.values())) + with maybe_accept_global_loop( + event_loop, self.accept_global_loop) as loop: + return loop.run_until_complete(coroutine(*args, **kwargs)) + + inner._pytestfixturefunction = self + return inner + + +def async_fixture(scope='function', params=None, autouse=False, ids=None, + accept_global_loop=False): + if callable(scope) and params is None and not autouse: + # direct invocation + marker = AsyncFixtureFunctionMarker( + 'function', params, autouse, accept_global_loop=accept_global_loop, + ) + return marker(scope) + if params is not None and not isinstance(params, (list, tuple)): + params = list(params) + return AsyncFixtureFunctionMarker( + scope, params, autouse, ids=ids, accept_global_loop=accept_global_loop, + ) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 4a4b42aa..a054c099 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,19 +1,11 @@ +import socket import asyncio -from concurrent.futures import ProcessPoolExecutor -from contextlib import closing import inspect -import socket -import pytest - - -class MissingLoopFixture(Exception): - """Raised if a test coroutine function does not request a loop fixture.""" - pass +import contextlib +import pytest -class ForbiddenEventLoopPolicy(asyncio.AbstractEventLoopPolicy): - """An event loop policy that raises errors on any operation.""" - pass +from .utils import find_loop, maybe_accept_global_loop def _is_coroutine(obj): @@ -51,43 +43,22 @@ def pytest_pyfunc_call(pyfuncitem): marker_kwargs = pyfuncitem.keywords['asyncio'].kwargs accept_global_loop = marker_kwargs.get('accept_global_loop', False) - event_loop = None - for name, value in pyfuncitem.funcargs.items(): - if isinstance(value, asyncio.AbstractEventLoop): - event_loop = value - break - else: - if not accept_global_loop: - raise MissingLoopFixture('A loop fixture must be provided ' - 'to run test coroutine functions') - - policy = asyncio.get_event_loop_policy() - current_event_loop = policy.get_event_loop() - if accept_global_loop and event_loop is None: - event_loop = current_event_loop - - if not accept_global_loop: - asyncio.set_event_loop_policy(ForbiddenEventLoopPolicy()) - else: - policy.set_event_loop(event_loop) - + event_loop = find_loop(pyfuncitem.funcargs.values()) funcargs = pyfuncitem.funcargs testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} - try: - event_loop.run_until_complete( - asyncio.async(pyfuncitem.obj(**testargs), loop=event_loop)) + with maybe_accept_global_loop( + event_loop, accept_global_loop) as loop: + loop.run_until_complete(asyncio.async(pyfuncitem.obj(**testargs), + loop=loop)) return True - finally: - if not accept_global_loop: - asyncio.set_event_loop_policy(policy) @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] diff --git a/pytest_asyncio/utils.py b/pytest_asyncio/utils.py new file mode 100644 index 00000000..b797df56 --- /dev/null +++ b/pytest_asyncio/utils.py @@ -0,0 +1,39 @@ +import asyncio +import contextlib + +from .exceptions import MissingLoopFixture + + +class ForbiddenEventLoopPolicy(asyncio.AbstractEventLoopPolicy): + """An event loop policy that raises errors on any operation.""" + pass + + +@contextlib.contextmanager +def maybe_accept_global_loop(event_loop, accept_global_loop): + if not accept_global_loop and event_loop is None: + raise MissingLoopFixture('A loop fixture must be provided' + ' when a global loop is forbidden') + + policy = asyncio.get_event_loop_policy() + try: + global_event_loop = policy.get_event_loop() + if accept_global_loop and event_loop is None: + event_loop = global_event_loop + + if not accept_global_loop: + asyncio.set_event_loop_policy(ForbiddenEventLoopPolicy()) + else: + policy.set_event_loop(event_loop) + + yield event_loop + + finally: + if not accept_global_loop: + asyncio.set_event_loop_policy(policy) + + +def find_loop(iterable): + for item in iterable: + if isinstance(item, asyncio.AbstractEventLoop): + return item diff --git a/tests/conftest.py b/tests/conftest.py index 96459c31..2c7a0901 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ collect_ignore = [] if sys.version_info[:2] < (3, 5): collect_ignore.append("test_simple_35.py") - collect_ignore.append("test_autouse_35.py") + collect_ignore.append("test_async_fixture_35.py") @pytest.yield_fixture diff --git a/tests/test_async_fixture.py b/tests/test_async_fixture.py new file mode 100644 index 00000000..ea06aee1 --- /dev/null +++ b/tests/test_async_fixture.py @@ -0,0 +1,44 @@ +import asyncio +import pytest +import pytest_asyncio + +pytest_plugins = "pytester" + + +@pytest_asyncio.async_fixture +@asyncio.coroutine +def hello(loop): + yield from asyncio.sleep(0, loop=loop) + return 'hello' + + +def test_async_fixture(hello): + assert hello == 'hello' + + +def test_forbidden_global_loop_raises(testdir): + testdir.makepyfile(""" + import asyncio + import pytest_asyncio + + @pytest_asyncio.async_fixture + @asyncio.coroutine + def should_raise(): + yield from asyncio.sleep(0) + + def test_should_raise(should_raise): + pass + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*pytest_asyncio.exceptions.MissingLoopFixture*"]) + + + +@pytest_asyncio.async_fixture(accept_global_loop=True) +async def using_global_loop(): + await asyncio.sleep(0) + return 'ok' + + +def test_accepted_global_loop(using_global_loop): + assert using_global_loop == 'ok' diff --git a/tests/test_async_fixture_35.py b/tests/test_async_fixture_35.py new file mode 100644 index 00000000..2d8d2f09 --- /dev/null +++ b/tests/test_async_fixture_35.py @@ -0,0 +1,13 @@ +import asyncio +import pytest +import pytest_asyncio + + +@pytest_asyncio.async_fixture +async def hello(loop): + await asyncio.sleep(0, loop=loop) + return 'hello' + + +def test_async_fixture(hello): + assert hello == 'hello' From c1591fd1e0d9376897526b11a3dc1060faabe658 Mon Sep 17 00:00:00 2001 From: Dmitry Malinovsky Date: Tue, 31 May 2016 18:05:43 +0600 Subject: [PATCH 6/6] Fix tests --- tests/test_async_fixture.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_async_fixture.py b/tests/test_async_fixture.py index ea06aee1..9606a12e 100644 --- a/tests/test_async_fixture.py +++ b/tests/test_async_fixture.py @@ -35,8 +35,9 @@ def test_should_raise(should_raise): @pytest_asyncio.async_fixture(accept_global_loop=True) -async def using_global_loop(): - await asyncio.sleep(0) +@asyncio.coroutine +def using_global_loop(): + yield from asyncio.sleep(0) return 'ok'