Skip to content

Add mechanism for explicit marking of fixtures which should be run with asyncio #125

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

82 changes: 79 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------
Expand All @@ -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
<https://docs.pytest.org/en/latest/reference/customize.html>`_:

.. 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
--------

Expand Down Expand Up @@ -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)

Expand All @@ -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
-------
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions pytest_asyncio/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
"""The main point for importing pytest-asyncio items."""
__version__ = "0.16.0"

from .plugin import fixture


__all__ = ("fixture",)
152 changes: 147 additions & 5 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,106 @@
"""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):
"""Check to see if an object is really an asyncio coroutine."""
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(
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"<fixture {func.__qualname__}, file={code.co_filename}, "
f"line={code.co_firstlineno}>"
)
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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ show_missing = true
[tool:pytest]
addopts = -rsx --tb=short
testpaths = tests
asyncio_mode = auto
filterwarnings = error

[metadata]
Expand Down
Loading