diff --git a/.travis.yml b/.travis.yml index fe902344..b93377ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: python + matrix: include: - python: 3.5 @@ -7,10 +8,8 @@ matrix: env: TOX_ENV=py36 - python: 3.7 env: TOX_ENV=py37 - # TODO: the dist and sudo keys are currently needed to use Python 3.7. - # They should be removed once Travis-CI supports 3.7 on the default image. - dist: xenial - sudo: true + - python: 3.8 + env: TOX_ENV=py38 install: pip install tox-travis coveralls diff --git a/README.rst b/README.rst index 1933fcc5..92ae8f40 100644 --- a/README.rst +++ b/README.rst @@ -81,7 +81,7 @@ event loop. This will take effect even if you're using the .. code-block:: python - @pytest.yield_fixture() + @pytest.fixture def event_loop(): loop = MyCustomLoop() yield loop @@ -176,7 +176,21 @@ Only test coroutines will be affected (by default, coroutines prefixed by Changelog --------- -0.10.0. (UNRELEASED) +0.12.0 (2020-05-04) +~~~~~~~~~~~~~~~~~~~ +- Run the event loop fixture as soon as possible. This helps with fixtures that have an implicit dependency on the event loop. + `#156` + +0.11.0 (2020-04-20) +~~~~~~~~~~~~~~~~~~~ +- Test on 3.8, drop 3.3 and 3.4. Stick to 0.10 for these versions. + `#152` +- Use the new Pytest 5.4.0 Function API. We therefore depend on pytest >= 5.4.0. + `#142` +- Better ``pytest.skip`` support. + `#126` + +0.10.0 (2019-01-08) ~~~~~~~~~~~~~~~~~~~~ - ``pytest-asyncio`` integrates with `Hypothesis `_ to support ``@given`` on async test functions using ``asyncio``. diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index 79725620..d0b72ecf 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,2 +1,2 @@ """The main point for importing pytest-asyncio items.""" -__version__ = "0.10.0" +__version__ = "0.12.0" diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 9f4e10d7..c0b65da2 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -36,13 +36,13 @@ def pytest_configure(config): def pytest_pycollect_makeitem(collector, name, obj): """A pytest hook to collect asyncio coroutines.""" if collector.funcnamefilter(name) and _is_coroutine(obj): - item = pytest.Function(name, parent=collector) + item = pytest.Function.from_parent(collector, name=name) # Due to how pytest test collection works, module-level pytestmarks # are applied after the collection step. Since this is the collection # step, we look ourselves. transfer_markers(obj, item.cls, item.module) - item = pytest.Function(name, parent=collector) # To reload keywords. + item = pytest.Function.from_parent(collector, name=name) # To reload keywords. if 'asyncio' in item.keywords: return list(collector._genfunctions(name, obj)) @@ -51,28 +51,35 @@ def pytest_pycollect_makeitem(collector, name, obj): @pytest.hookimpl(hookwrapper=True) def pytest_fixture_setup(fixturedef, request): """Adjust the event loop policy when an event loop is produced.""" + if fixturedef.argname == "event_loop" and 'asyncio' in request.keywords: + outcome = yield + loop = outcome.get_result() + policy = asyncio.get_event_loop_policy() + try: + old_loop = policy.get_event_loop() + except RuntimeError as exc: + if 'no current event loop' not in str(exc): + raise + old_loop = None + policy.set_event_loop(loop) + fixturedef.addfinalizer(lambda: policy.set_event_loop(old_loop)) + return + if isasyncgenfunction(fixturedef.func): # This is an async generator function. Wrap it accordingly. - f = fixturedef.func + generator = fixturedef.func - strip_event_loop = False - if 'event_loop' not in fixturedef.argnames: - fixturedef.argnames += ('event_loop', ) - strip_event_loop = True strip_request = False if 'request' not in fixturedef.argnames: fixturedef.argnames += ('request', ) strip_request = True def wrapper(*args, **kwargs): - loop = kwargs['event_loop'] request = kwargs['request'] - if strip_event_loop: - del kwargs['event_loop'] if strip_request: del kwargs['request'] - gen_obj = f(*args, **kwargs) + gen_obj = generator(*args, **kwargs) async def setup(): res = await gen_obj.__anext__() @@ -89,119 +96,89 @@ async def async_finalizer(): msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) - - loop.run_until_complete(async_finalizer()) + asyncio.get_event_loop().run_until_complete(async_finalizer()) request.addfinalizer(finalizer) - - return loop.run_until_complete(setup()) + return asyncio.get_event_loop().run_until_complete(setup()) fixturedef.func = wrapper - elif inspect.iscoroutinefunction(fixturedef.func): - # Just a coroutine, not an async generator. - f = fixturedef.func - - strip_event_loop = False - if 'event_loop' not in fixturedef.argnames: - fixturedef.argnames += ('event_loop', ) - strip_event_loop = True + coro = fixturedef.func def wrapper(*args, **kwargs): - loop = kwargs['event_loop'] - if strip_event_loop: - del kwargs['event_loop'] - async def setup(): - res = await f(*args, **kwargs) + res = await coro(*args, **kwargs) return res - return loop.run_until_complete(setup()) + return asyncio.get_event_loop().run_until_complete(setup()) fixturedef.func = wrapper - - outcome = yield - - if fixturedef.argname == "event_loop" and 'asyncio' in request.keywords: - loop = outcome.get_result() - for kw in _markers_2_fixtures.keys(): - if kw not in request.keywords: - continue - policy = asyncio.get_event_loop_policy() - try: - old_loop = policy.get_event_loop() - except RuntimeError as exc: - if 'no current event loop' not in str(exc): - raise - old_loop = None - policy.set_event_loop(loop) - fixturedef.addfinalizer(lambda: policy.set_event_loop(old_loop)) + yield -@pytest.mark.tryfirst +@pytest.hookimpl(tryfirst=True, hookwrapper=True) 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 \ - and not getattr(pyfuncitem.obj, 'is_hypothesis_test', False): - event_loop = pyfuncitem.funcargs[fixture_name] - - funcargs = pyfuncitem.funcargs - testargs = {arg: funcargs[arg] - for arg in pyfuncitem._fixtureinfo.argnames} - - event_loop.run_until_complete( - asyncio.ensure_future( - pyfuncitem.obj(**testargs), loop=event_loop)) - return True + 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, + _loop=pyfuncitem.funcargs['event_loop'] + ) + else: + pyfuncitem.obj = wrap_in_sync( + pyfuncitem.obj, + _loop=pyfuncitem.funcargs['event_loop'] + ) + yield -def wrap_in_sync(func): - """Return a sync wrapper around an async function.""" +def wrap_in_sync(func, _loop): + """Return a sync wrapper around an async function executing it in the + current event loop.""" @functools.wraps(func) def inner(**kwargs): - loop = asyncio.get_event_loop_policy().new_event_loop() - try: - coro = func(**kwargs) - if coro is not None: - future = asyncio.ensure_future(coro, loop=loop) - loop.run_until_complete(future) - finally: - loop.close() - + coro = func(**kwargs) + if coro is not None: + try: + 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 + # exceptions to prevent asyncio's warning from logging. + if task.done() and not task.cancelled(): + task.exception() + raise return inner 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) - if item.get_closest_marker("asyncio") is not None: - if hasattr(item.obj, 'hypothesis'): - # If it's a Hypothesis test, we insert the wrap_in_sync decorator - item.obj.hypothesis.inner_test = wrap_in_sync( - item.obj.hypothesis.inner_test - ) - elif getattr(item.obj, 'is_hypothesis_test', False): + if 'asyncio' in item.keywords: + # inject an event loop fixture for all async tests + if 'event_loop' in item.fixturenames: + item.fixturenames.remove('event_loop') + item.fixturenames.insert(0, 'event_loop') + if item.get_closest_marker("asyncio") is not None \ + and not getattr(item.obj, 'hypothesis', False) \ + and getattr(item.obj, 'is_hypothesis_test', False): pytest.fail( 'test function `%r` is using Hypothesis, but pytest-asyncio ' 'only works with Hypothesis 3.64.0 or later.' % item ) -# maps marker to the name of the event loop fixture that will be available -# to marked test functions -_markers_2_fixtures = { - 'asyncio': 'event_loop', -} - - -@pytest.yield_fixture +@pytest.fixture def event_loop(request): """Create an instance of the default event loop for each test case.""" loop = asyncio.get_event_loop_policy().new_event_loop() diff --git a/setup.py b/setup.py index 97ef4603..61757113 100644 --- a/setup.py +++ b/setup.py @@ -36,17 +36,18 @@ def find_version(): "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Testing", "Framework :: Pytest", ], python_requires=">= 3.5", - install_requires=["pytest >= 3.0.6"], + install_requires=["pytest >= 5.4.0"], extras_require={ ':python_version == "3.5"': "async_generator >= 1.3", "testing": [ "coverage", "async_generator >= 1.3", - "hypothesis >= 3.64", + "hypothesis >= 5.7.1", ], }, entry_points={"pytest11": ["asyncio = pytest_asyncio.plugin"]}, diff --git a/tests/conftest.py b/tests/conftest.py index a2debdf3..cc2ec163 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ collect_ignore.append("async_fixtures/test_nested_36.py") -@pytest.yield_fixture() +@pytest.fixture def dependent_fixture(event_loop): """A fixture dependent on the event_loop fixture, doing some cleanup.""" counter = 0 @@ -17,7 +17,7 @@ def dependent_fixture(event_loop): async def just_a_sleep(): """Just sleep a little while.""" nonlocal event_loop - await asyncio.sleep(0.1, loop=event_loop) + await asyncio.sleep(0.1) nonlocal counter counter += 1 diff --git a/tests/multiloop/conftest.py b/tests/multiloop/conftest.py index 50d70a94..1b62a1d0 100644 --- a/tests/multiloop/conftest.py +++ b/tests/multiloop/conftest.py @@ -8,7 +8,7 @@ class CustomSelectorLoop(asyncio.SelectorEventLoop): pass -@pytest.yield_fixture() +@pytest.fixture def event_loop(): """Create an instance of the default event loop for each test case.""" loop = CustomSelectorLoop() diff --git a/tests/test_hypothesis_integration.py b/tests/test_hypothesis_integration.py index 562f4772..9c97e06c 100644 --- a/tests/test_hypothesis_integration.py +++ b/tests/test_hypothesis_integration.py @@ -1,12 +1,19 @@ """Tests for the Hypothesis integration, which wraps async functions in a sync shim for Hypothesis. """ +import asyncio import pytest from hypothesis import given, strategies as st +@pytest.fixture(scope="module") +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + + @given(st.integers()) @pytest.mark.asyncio async def test_mark_inner(n): @@ -25,3 +32,11 @@ async def test_mark_outer(n): async def test_mark_and_parametrize(x, y): assert x is None assert y in (1, 2) + + +@given(st.integers()) +@pytest.mark.asyncio +async def test_can_use_fixture_provided_event_loop(event_loop, n): + semaphore = asyncio.Semaphore(value=0) + event_loop.call_soon(semaphore.release) + await semaphore.acquire() diff --git a/tests/test_simple.py b/tests/test_simple.py index f9e3992f..c8dccaf8 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,28 +1,26 @@ """Quick'n'dirty unit tests for provided fixtures and markers.""" import asyncio -import os import pytest import pytest_asyncio.plugin -async def async_coro(loop=None): - """A very simple coroutine.""" - await asyncio.sleep(0, loop=loop) +async def async_coro(): + await asyncio.sleep(0) 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)) + ret = event_loop.run_until_complete(async_coro()) assert ret == 'ok' @pytest.mark.asyncio -def test_asyncio_marker(): +async def test_asyncio_marker(): """Test the asyncio pytest marker.""" - yield # sleep(0) + await asyncio.sleep(0) @pytest.mark.xfail(reason='need a failure', strict=True) @@ -45,13 +43,11 @@ async def closer(_, writer): writer.close() server1 = await asyncio.start_server(closer, host='localhost', - port=unused_tcp_port, - loop=event_loop) + port=unused_tcp_port) with pytest.raises(IOError): await asyncio.start_server(closer, host='localhost', - port=unused_tcp_port, - loop=event_loop) + port=unused_tcp_port) server1.close() await server1.wait_closed() @@ -68,20 +64,16 @@ async def closer(_, writer): unused_tcp_port_factory()) server1 = await asyncio.start_server(closer, host='localhost', - port=port1, - loop=event_loop) + port=port1) server2 = await asyncio.start_server(closer, host='localhost', - port=port2, - loop=event_loop) + port=port2) server3 = await asyncio.start_server(closer, host='localhost', - port=port3, - loop=event_loop) + port=port3) for port in port1, port2, port3: with pytest.raises(IOError): await asyncio.start_server(closer, host='localhost', - port=port, - loop=event_loop) + port=port) server1.close() await server1.wait_closed() @@ -117,7 +109,7 @@ class Test: @pytest.mark.asyncio async def test_asyncio_marker_method(self, event_loop): """Test the asyncio pytest marker in a Test class.""" - ret = await async_coro(event_loop) + ret = await async_coro() assert ret == 'ok' @@ -134,3 +126,31 @@ 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' + + +class TestEventLoopStartedBeforeFixtures: + @pytest.fixture + async def loop(self): + return asyncio.get_event_loop() + + @staticmethod + def foo(): + return 1 + + @pytest.mark.asyncio + async def test_no_event_loop(self, loop): + assert await loop.run_in_executor(None, self.foo) == 1 + + @pytest.mark.asyncio + async def test_event_loop_after_fixture(self, loop, event_loop): + assert await loop.run_in_executor(None, self.foo) == 1 + + @pytest.mark.asyncio + async def test_event_loop_before_fixture(self, event_loop, loop): + assert await loop.run_in_executor(None, self.foo) == 1 + + + +@pytest.mark.asyncio +async def test_no_warning_on_skip(): + pytest.skip("Test a skip error inside asyncio") diff --git a/tests/test_simple_35.py b/tests/test_simple_35.py index 1e4d697c..4141fb0b 100644 --- a/tests/test_simple_35.py +++ b/tests/test_simple_35.py @@ -6,7 +6,7 @@ @pytest.mark.asyncio async def async_coro(loop): - await asyncio.sleep(0, loop=loop) + await asyncio.sleep(0) return 'ok' @@ -27,8 +27,7 @@ async def closer(_, writer): writer.close() server1 = await asyncio.start_server(closer, host='localhost', - port=unused_tcp_port, - loop=event_loop) + port=unused_tcp_port) server1.close() await server1.wait_closed() @@ -45,20 +44,16 @@ async def closer(_, writer): async def run_test(): server1 = await asyncio.start_server(closer, host='localhost', - port=port1, - loop=event_loop) + port=port1) server2 = await asyncio.start_server(closer, host='localhost', - port=port2, - loop=event_loop) + port=port2) server3 = await asyncio.start_server(closer, host='localhost', - port=port3, - loop=event_loop) + port=port3) for port in port1, port2, port3: with pytest.raises(IOError): await asyncio.start_server(closer, host='localhost', - port=port, - loop=event_loop) + port=port) server1.close() await server1.wait_closed() diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index 3f3fa090..069c6c22 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -6,12 +6,22 @@ import pytest +if sys.platform == 'win32': + # The default asyncio event loop implementation on Windows does not + # support subprocesses. Subprocesses are available for Windows if a + # ProactorEventLoop is used. + @pytest.yield_fixture() + def event_loop(): + loop = asyncio.ProactorEventLoop() + yield loop + loop.close() + + @pytest.mark.asyncio(forbid_global_loop=False) async def test_subprocess(event_loop): """Starting a subprocess should be possible.""" proc = await asyncio.subprocess.create_subprocess_exec( - sys.executable, '--version', stdout=asyncio.subprocess.PIPE, - loop=event_loop) + sys.executable, '--version', stdout=asyncio.subprocess.PIPE) await proc.communicate() @@ -19,6 +29,5 @@ async def test_subprocess(event_loop): async def test_subprocess_forbid(event_loop): """Starting a subprocess should be possible.""" proc = await asyncio.subprocess.create_subprocess_exec( - sys.executable, '--version', stdout=asyncio.subprocess.PIPE, - loop=event_loop) + sys.executable, '--version', stdout=asyncio.subprocess.PIPE) await proc.communicate() diff --git a/tox.ini b/tox.ini index 13d5155a..eed6fb67 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] -envlist = py35, py36, py37 -minversion = 2.5.0 +minversion = 3.14.0 +envlist = py35, py36, py37, py38 +skip_missing_interpreters = true [testenv] extras = testing