diff --git a/README.rst b/README.rst index 7330e1a9..b70b3200 100644 --- a/README.rst +++ b/README.rst @@ -39,6 +39,9 @@ Features - pytest markers for treating tests as asyncio coroutines - easy testing with non-default event loops - support for `async def` fixtures and async generator fixtures +- support *auto* mode to handle all async fixtures and tests automatically by asyncio; + provide *strict* mode if a test suite should work with different async frameworks + simultaneously, e.g. ``asyncio`` and ``trio``. Installation ------------ @@ -51,6 +54,70 @@ To install pytest-asyncio, simply: This is enough for pytest to pick up pytest-asyncio. +Modes +----- + +Starting from ``pytest-asyncio>=0.17``, three modes are provided: *auto*, *strict* and +*legacy* (default). + +The mode can be set by ``asyncio_mode`` configuration option in `configuration file +`_: + +.. code-block:: ini + + # pytest.ini + [pytest] + asyncio_mode = auto + +The value can be overriden by command-line option for ``pytest`` invocation: + +.. code-block:: bash + + $ pytest tests --asyncio-mode=strict + +Auto mode +~~~~~~~~~ + +When the mode is auto, all discovered *async* tests are considered *asyncio-driven* even +if they have no ``@pytest.mark.asyncio`` marker. + +All async fixtures are considered *asyncio-driven* as well, even if they are decorated +with a regular ``@pytest.fixture`` decorator instead of dedicated +``@pytest_asyncio.fixture`` counterpart. + +*asyncio-driven* means that tests and fixtures are executed by ``pytest-asyncio`` +plugin. + +This mode requires the simplest tests and fixtures configuration and is +recommended for default usage *unless* the same project and its test suite should +execute tests from different async frameworks, e.g. ``asyncio`` and ``trio``. In this +case, auto-handling can break tests designed for other framework; plase use *strict* +mode instead. + +Strict mode +~~~~~~~~~~~ + +Strict mode enforces ``@pytest.mark.asyncio`` and ``@pytest_asyncio.fixture`` usage. +Without these markers, tests and fixtures are not considered as *asyncio-driven*, other +pytest plugin can handle them. + +Please use this mode if multiple async frameworks should be combined in the same test +suite. + + +Legacy mode +~~~~~~~~~~~ + +This mode follows rules used by ``pytest-asyncio<0.17``: tests are not auto-marked but +fixtures are. + +This mode is used by default for the sake of backward compatibility, deprecation +warnings are emitted with suggestion to either switching to ``auto`` mode or using +``strict`` mode with ``@pytest_asyncio.fixture`` decorators. + +In future, the default will be changed. + + Fixtures -------- @@ -116,16 +183,18 @@ Work just like their TCP counterparts but return unused UDP ports. Async fixtures ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be coroutines or asynchronous generators. +Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``. .. code-block:: python3 - @pytest.fixture + import pytest_asyncio + + @pytest_asyncio.fixture async def async_gen_fixture(): await asyncio.sleep(0.1) yield 'a value' - @pytest.fixture(scope='module') + @pytest_asyncio.fixture(scope='module') async def async_fixture(): return await asyncio.sleep(0.1) @@ -134,6 +203,9 @@ to redefine the ``event_loop`` fixture to have the same or broader scope. Async fixtures need the event loop, and so must have the same or narrower scope than the ``event_loop`` fixture. +*auto* and *legacy* mode automatically converts async fixtures declared with the +standard ``@pytest.fixture`` decorator to *asyncio-driven* versions. + Markers ------- @@ -164,6 +236,10 @@ Only test coroutines will be affected (by default, coroutines prefixed by """No marker!""" await asyncio.sleep(0, loop=event_loop) +In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added +automatically to *async* test functions. + + .. |pytestmark| replace:: ``pytestmark`` .. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules diff --git a/pytest_asyncio/__init__.py b/pytest_asyncio/__init__.py index b16159e7..0da62156 100644 --- a/pytest_asyncio/__init__.py +++ b/pytest_asyncio/__init__.py @@ -1,2 +1,7 @@ """The main point for importing pytest-asyncio items.""" __version__ = "0.16.0" + +from .plugin import fixture + + +__all__ = ("fixture",) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index dcaf429b..44165602 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1,13 +1,88 @@ """pytest-asyncio implementation.""" import asyncio import contextlib +import enum import functools import inspect import socket +import sys +import warnings import pytest -from inspect import isasyncgenfunction + +class Mode(str, enum.Enum): + AUTO = "auto" + STRICT = "strict" + LEGACY = "legacy" + + +LEGACY_MODE = pytest.PytestDeprecationWarning( + "The 'asyncio_mode' default value will change to 'strict' in future, " + "please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' " + "in pytest configuration file." +) + +LEGACY_ASYNCIO_FIXTURE = ( + "'@pytest.fixture' is applied to {name} " + "in 'legacy' mode, " + "please replace it with '@pytest_asyncio.fixture' as a preparation " + "for switching to 'strict' mode (or use 'auto' mode to seamlessly handle " + "all these fixtures as asyncio-driven)." +) + + +ASYNCIO_MODE_HELP = """\ +'auto' - for automatically handling all async functions by the plugin +'strict' - for autoprocessing disabling (useful if different async frameworks \ +should be tested together, e.g. \ +both pytest-asyncio and pytest-trio are used in the same project) +'legacy' - for keeping compatibility with pytest-asyncio<0.17: \ +auto-handling is disabled but pytest_asyncio.fixture usage is not enforced +""" + + +def pytest_addoption(parser, pluginmanager): + group = parser.getgroup("asyncio") + group.addoption( + "--asyncio-mode", + dest="asyncio_mode", + default=None, + metavar="MODE", + help=ASYNCIO_MODE_HELP, + ) + parser.addini( + "asyncio_mode", + help="default value for --asyncio-mode", + type="string", + default="legacy", + ) + + +def fixture(fixture_function=None, **kwargs): + if fixture_function is not None: + _set_explicit_asyncio_mark(fixture_function) + return pytest.fixture(fixture_function, **kwargs) + + else: + + @functools.wraps(fixture) + def inner(fixture_function): + return fixture(fixture_function, **kwargs) + + return inner + + +def _has_explicit_asyncio_mark(obj): + obj = getattr(obj, "__func__", obj) # instance method maybe? + return getattr(obj, "_force_asyncio_fixture", False) + + +def _set_explicit_asyncio_mark(obj): + if hasattr(obj, "__func__"): + # instance method, check the function object + obj = obj.__func__ + obj._force_asyncio_fixture = True def _is_coroutine(obj): @@ -15,6 +90,17 @@ def _is_coroutine(obj): return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj) +def _is_coroutine_or_asyncgen(obj): + return _is_coroutine(obj) or inspect.isasyncgenfunction(obj) + + +def _get_asyncio_mode(config): + val = config.getoption("asyncio_mode") + if val is None: + val = config.getini("asyncio_mode") + return Mode(val) + + def pytest_configure(config): """Inject documentation.""" config.addinivalue_line( @@ -23,6 +109,22 @@ def pytest_configure(config): "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) + if _get_asyncio_mode(config) == Mode.LEGACY: + _issue_warning_captured(LEGACY_MODE, config.hook, stacklevel=1) + + +def _issue_warning_captured(warning, hook, *, stacklevel=1): + # copy-paste of pytest internal _pytest.warnings._issue_warning_captured function + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always", type(warning)) + warnings.warn(LEGACY_MODE, stacklevel=stacklevel) + frame = sys._getframe(stacklevel - 1) + location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name + hook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=records[0], when="config", nodeid="", location=location + ) + ) @pytest.mark.tryfirst @@ -32,6 +134,13 @@ def pytest_pycollect_makeitem(collector, name, obj): item = pytest.Function.from_parent(collector, name=name) if "asyncio" in item.keywords: return list(collector._genfunctions(name, obj)) + else: + if _get_asyncio_mode(item.config) == Mode.AUTO: + # implicitly add asyncio marker if asyncio mode is on + ret = list(collector._genfunctions(name, obj)) + for elem in ret: + elem.add_marker("asyncio") + return ret class FixtureStripper: @@ -88,9 +197,42 @@ def pytest_fixture_setup(fixturedef, request): policy.set_event_loop(loop) return - if isasyncgenfunction(fixturedef.func): + func = fixturedef.func + if not _is_coroutine_or_asyncgen(func): + # Nothing to do with a regular fixture function + yield + return + + config = request.node.config + asyncio_mode = _get_asyncio_mode(config) + + if not _has_explicit_asyncio_mark(func): + if asyncio_mode == Mode.AUTO: + # Enforce asyncio mode if 'auto' + _set_explicit_asyncio_mark(func) + elif asyncio_mode == Mode.LEGACY: + _set_explicit_asyncio_mark(func) + try: + code = func.__code__ + except AttributeError: + code = func.__func__.__code__ + name = ( + f"" + ) + warnings.warn( + LEGACY_ASYNCIO_FIXTURE.format(name=name), + pytest.PytestDeprecationWarning, + ) + else: + # asyncio_mode is STRICT, + # don't handle fixtures that are not explicitly marked + yield + return + + if inspect.isasyncgenfunction(func): # This is an async generator function. Wrap it accordingly. - generator = fixturedef.func + generator = func fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) @@ -129,8 +271,8 @@ async def async_finalizer(): return loop.run_until_complete(setup()) fixturedef.func = wrapper - elif inspect.iscoroutinefunction(fixturedef.func): - coro = fixturedef.func + elif inspect.iscoroutinefunction(func): + coro = func fixture_stripper = FixtureStripper(fixturedef) fixture_stripper.add(FixtureStripper.EVENT_LOOP) diff --git a/setup.cfg b/setup.cfg index 01610865..fc18e3d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,6 +7,7 @@ show_missing = true [tool:pytest] addopts = -rsx --tb=short testpaths = tests +asyncio_mode = auto filterwarnings = error [metadata] diff --git a/tests/modes/test_auto_mode.py b/tests/modes/test_auto_mode.py new file mode 100644 index 00000000..980b0b04 --- /dev/null +++ b/tests/modes/test_auto_mode.py @@ -0,0 +1,91 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +def test_auto_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = auto\n") + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_auto_mode_async_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture + async def fixture_a(): + await asyncio.sleep(0) + return 1 + + async def test_a(fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_auto_mode_method_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + + class TestA: + + @pytest.fixture + async def fixture_a(self): + await asyncio.sleep(0) + return 1 + + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/modes/test_legacy_mode.py b/tests/modes/test_legacy_mode.py new file mode 100644 index 00000000..df9c2cb6 --- /dev/null +++ b/tests/modes/test_legacy_mode.py @@ -0,0 +1,115 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +LEGACY_MODE = ( + "The 'asyncio_mode' default value will change to 'strict' in future, " + "please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' " + "in pytest configuration file." +) + +LEGACY_ASYNCIO_FIXTURE = ( + "'@pytest.fixture' is applied to {name} " + "in 'legacy' mode, " + "please replace it with '@pytest_asyncio.fixture' as a preparation " + "for switching to 'strict' mode (or use 'auto' mode to seamlessly handle " + "all these fixtures as asyncio-driven)." +).format(name="*") + + +def test_warning_for_legacy_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 1 + result.stdout.fnmatch_lines(["*" + LEGACY_MODE + "*"]) + + +def test_warning_for_legacy_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = legacy\n") + result = pytester.runpytest() + assert result.parseoutcomes()["warnings"] == 1 + result.stdout.fnmatch_lines(["*" + LEGACY_MODE + "*"]) + result.stdout.no_fnmatch_line("*" + LEGACY_ASYNCIO_FIXTURE + "*") + + +def test_warning_for_legacy_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.fixture + async def fixture_a(): + await asyncio.sleep(0) + return 1 + + @pytest.mark.asyncio + async def test_a(fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 2 + result.stdout.fnmatch_lines(["*" + LEGACY_ASYNCIO_FIXTURE + "*"]) + + +def test_warning_for_legacy_method_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + + class TestA: + + @pytest.fixture + async def fixture_a(self): + await asyncio.sleep(0) + return 1 + + @pytest.mark.asyncio + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=legacy") + assert result.parseoutcomes()["warnings"] == 2 + result.stdout.fnmatch_lines(["*" + LEGACY_ASYNCIO_FIXTURE + "*"]) diff --git a/tests/modes/test_strict_mode.py b/tests/modes/test_strict_mode.py new file mode 100644 index 00000000..7b574012 --- /dev/null +++ b/tests/modes/test_strict_mode.py @@ -0,0 +1,70 @@ +from textwrap import dedent + +pytest_plugins = "pytester" + + +def test_strict_mode_cmdline(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_strict_mode_cfg(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_a(): + await asyncio.sleep(0) + """ + ) + ) + pytester.makefile(".ini", pytest="[pytest]\nasyncio_mode = strict\n") + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_strict_mode_method_fixture(pytester): + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = 'pytest_asyncio' + + class TestA: + + @pytest_asyncio.fixture + async def fixture_a(self): + await asyncio.sleep(0) + return 1 + + @pytest.mark.asyncio + async def test_a(self, fixture_a): + await asyncio.sleep(0) + assert fixture_a == 1 + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) diff --git a/tests/test_asyncio_fixture.py b/tests/test_asyncio_fixture.py new file mode 100644 index 00000000..824956d8 --- /dev/null +++ b/tests/test_asyncio_fixture.py @@ -0,0 +1,39 @@ +import asyncio +import pytest_asyncio +import pytest + + +@pytest_asyncio.fixture +async def fixture_bare(): + await asyncio.sleep(0) + return 1 + + +@pytest.mark.asyncio +async def test_bare_fixture(fixture_bare): + await asyncio.sleep(0) + assert fixture_bare == 1 + + +@pytest_asyncio.fixture(name="new_fixture_name") +async def fixture_with_name(request): + await asyncio.sleep(0) + return request.fixturename + + +@pytest.mark.asyncio +async def test_fixture_with_name(new_fixture_name): + await asyncio.sleep(0) + assert new_fixture_name == "new_fixture_name" + + +@pytest_asyncio.fixture(params=[2, 4]) +async def fixture_with_params(request): + await asyncio.sleep(0) + return request.param + + +@pytest.mark.asyncio +async def test_fixture_with_params(fixture_with_params): + await asyncio.sleep(0) + assert fixture_with_params % 2 == 0