Skip to content

Commit f86d900

Browse files
authored
Add mechanism for explicit marking of fixtures which should be run with asyncio (#125)
1 parent 8226167 commit f86d900

File tree

8 files changed

+547
-8
lines changed

8 files changed

+547
-8
lines changed

README.rst

+79-3
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ Features
3939
- pytest markers for treating tests as asyncio coroutines
4040
- easy testing with non-default event loops
4141
- support for `async def` fixtures and async generator fixtures
42+
- support *auto* mode to handle all async fixtures and tests automatically by asyncio;
43+
provide *strict* mode if a test suite should work with different async frameworks
44+
simultaneously, e.g. ``asyncio`` and ``trio``.
4245

4346
Installation
4447
------------
@@ -51,6 +54,70 @@ To install pytest-asyncio, simply:
5154
5255
This is enough for pytest to pick up pytest-asyncio.
5356

57+
Modes
58+
-----
59+
60+
Starting from ``pytest-asyncio>=0.17``, three modes are provided: *auto*, *strict* and
61+
*legacy* (default).
62+
63+
The mode can be set by ``asyncio_mode`` configuration option in `configuration file
64+
<https://docs.pytest.org/en/latest/reference/customize.html>`_:
65+
66+
.. code-block:: ini
67+
68+
# pytest.ini
69+
[pytest]
70+
asyncio_mode = auto
71+
72+
The value can be overriden by command-line option for ``pytest`` invocation:
73+
74+
.. code-block:: bash
75+
76+
$ pytest tests --asyncio-mode=strict
77+
78+
Auto mode
79+
~~~~~~~~~
80+
81+
When the mode is auto, all discovered *async* tests are considered *asyncio-driven* even
82+
if they have no ``@pytest.mark.asyncio`` marker.
83+
84+
All async fixtures are considered *asyncio-driven* as well, even if they are decorated
85+
with a regular ``@pytest.fixture`` decorator instead of dedicated
86+
``@pytest_asyncio.fixture`` counterpart.
87+
88+
*asyncio-driven* means that tests and fixtures are executed by ``pytest-asyncio``
89+
plugin.
90+
91+
This mode requires the simplest tests and fixtures configuration and is
92+
recommended for default usage *unless* the same project and its test suite should
93+
execute tests from different async frameworks, e.g. ``asyncio`` and ``trio``. In this
94+
case, auto-handling can break tests designed for other framework; plase use *strict*
95+
mode instead.
96+
97+
Strict mode
98+
~~~~~~~~~~~
99+
100+
Strict mode enforces ``@pytest.mark.asyncio`` and ``@pytest_asyncio.fixture`` usage.
101+
Without these markers, tests and fixtures are not considered as *asyncio-driven*, other
102+
pytest plugin can handle them.
103+
104+
Please use this mode if multiple async frameworks should be combined in the same test
105+
suite.
106+
107+
108+
Legacy mode
109+
~~~~~~~~~~~
110+
111+
This mode follows rules used by ``pytest-asyncio<0.17``: tests are not auto-marked but
112+
fixtures are.
113+
114+
This mode is used by default for the sake of backward compatibility, deprecation
115+
warnings are emitted with suggestion to either switching to ``auto`` mode or using
116+
``strict`` mode with ``@pytest_asyncio.fixture`` decorators.
117+
118+
In future, the default will be changed.
119+
120+
54121
Fixtures
55122
--------
56123

@@ -116,16 +183,18 @@ Work just like their TCP counterparts but return unused UDP ports.
116183

117184
Async fixtures
118185
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
119-
Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be coroutines or asynchronous generators.
186+
Asynchronous fixtures are defined just like ordinary pytest fixtures, except they should be decorated with ``@pytest_asyncio.fixture``.
120187

121188
.. code-block:: python3
122189
123-
@pytest.fixture
190+
import pytest_asyncio
191+
192+
@pytest_asyncio.fixture
124193
async def async_gen_fixture():
125194
await asyncio.sleep(0.1)
126195
yield 'a value'
127196
128-
@pytest.fixture(scope='module')
197+
@pytest_asyncio.fixture(scope='module')
129198
async def async_fixture():
130199
return await asyncio.sleep(0.1)
131200
@@ -134,6 +203,9 @@ to redefine the ``event_loop`` fixture to have the same or broader scope.
134203
Async fixtures need the event loop, and so must have the same or narrower scope
135204
than the ``event_loop`` fixture.
136205

206+
*auto* and *legacy* mode automatically converts async fixtures declared with the
207+
standard ``@pytest.fixture`` decorator to *asyncio-driven* versions.
208+
137209

138210
Markers
139211
-------
@@ -164,6 +236,10 @@ Only test coroutines will be affected (by default, coroutines prefixed by
164236
"""No marker!"""
165237
await asyncio.sleep(0, loop=event_loop)
166238
239+
In *auto* mode, the ``pytest.mark.asyncio`` marker can be omitted, the marker is added
240+
automatically to *async* test functions.
241+
242+
167243
.. |pytestmark| replace:: ``pytestmark``
168244
.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules
169245

pytest_asyncio/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
"""The main point for importing pytest-asyncio items."""
22
__version__ = "0.16.0"
3+
4+
from .plugin import fixture
5+
6+
7+
__all__ = ("fixture",)

pytest_asyncio/plugin.py

+147-5
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,106 @@
11
"""pytest-asyncio implementation."""
22
import asyncio
33
import contextlib
4+
import enum
45
import functools
56
import inspect
67
import socket
8+
import sys
9+
import warnings
710

811
import pytest
912

10-
from inspect import isasyncgenfunction
13+
14+
class Mode(str, enum.Enum):
15+
AUTO = "auto"
16+
STRICT = "strict"
17+
LEGACY = "legacy"
18+
19+
20+
LEGACY_MODE = pytest.PytestDeprecationWarning(
21+
"The 'asyncio_mode' default value will change to 'strict' in future, "
22+
"please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' "
23+
"in pytest configuration file."
24+
)
25+
26+
LEGACY_ASYNCIO_FIXTURE = (
27+
"'@pytest.fixture' is applied to {name} "
28+
"in 'legacy' mode, "
29+
"please replace it with '@pytest_asyncio.fixture' as a preparation "
30+
"for switching to 'strict' mode (or use 'auto' mode to seamlessly handle "
31+
"all these fixtures as asyncio-driven)."
32+
)
33+
34+
35+
ASYNCIO_MODE_HELP = """\
36+
'auto' - for automatically handling all async functions by the plugin
37+
'strict' - for autoprocessing disabling (useful if different async frameworks \
38+
should be tested together, e.g. \
39+
both pytest-asyncio and pytest-trio are used in the same project)
40+
'legacy' - for keeping compatibility with pytest-asyncio<0.17: \
41+
auto-handling is disabled but pytest_asyncio.fixture usage is not enforced
42+
"""
43+
44+
45+
def pytest_addoption(parser, pluginmanager):
46+
group = parser.getgroup("asyncio")
47+
group.addoption(
48+
"--asyncio-mode",
49+
dest="asyncio_mode",
50+
default=None,
51+
metavar="MODE",
52+
help=ASYNCIO_MODE_HELP,
53+
)
54+
parser.addini(
55+
"asyncio_mode",
56+
help="default value for --asyncio-mode",
57+
type="string",
58+
default="legacy",
59+
)
60+
61+
62+
def fixture(fixture_function=None, **kwargs):
63+
if fixture_function is not None:
64+
_set_explicit_asyncio_mark(fixture_function)
65+
return pytest.fixture(fixture_function, **kwargs)
66+
67+
else:
68+
69+
@functools.wraps(fixture)
70+
def inner(fixture_function):
71+
return fixture(fixture_function, **kwargs)
72+
73+
return inner
74+
75+
76+
def _has_explicit_asyncio_mark(obj):
77+
obj = getattr(obj, "__func__", obj) # instance method maybe?
78+
return getattr(obj, "_force_asyncio_fixture", False)
79+
80+
81+
def _set_explicit_asyncio_mark(obj):
82+
if hasattr(obj, "__func__"):
83+
# instance method, check the function object
84+
obj = obj.__func__
85+
obj._force_asyncio_fixture = True
1186

1287

1388
def _is_coroutine(obj):
1489
"""Check to see if an object is really an asyncio coroutine."""
1590
return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj)
1691

1792

93+
def _is_coroutine_or_asyncgen(obj):
94+
return _is_coroutine(obj) or inspect.isasyncgenfunction(obj)
95+
96+
97+
def _get_asyncio_mode(config):
98+
val = config.getoption("asyncio_mode")
99+
if val is None:
100+
val = config.getini("asyncio_mode")
101+
return Mode(val)
102+
103+
18104
def pytest_configure(config):
19105
"""Inject documentation."""
20106
config.addinivalue_line(
@@ -23,6 +109,22 @@ def pytest_configure(config):
23109
"mark the test as a coroutine, it will be "
24110
"run using an asyncio event loop",
25111
)
112+
if _get_asyncio_mode(config) == Mode.LEGACY:
113+
_issue_warning_captured(LEGACY_MODE, config.hook, stacklevel=1)
114+
115+
116+
def _issue_warning_captured(warning, hook, *, stacklevel=1):
117+
# copy-paste of pytest internal _pytest.warnings._issue_warning_captured function
118+
with warnings.catch_warnings(record=True) as records:
119+
warnings.simplefilter("always", type(warning))
120+
warnings.warn(LEGACY_MODE, stacklevel=stacklevel)
121+
frame = sys._getframe(stacklevel - 1)
122+
location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name
123+
hook.pytest_warning_recorded.call_historic(
124+
kwargs=dict(
125+
warning_message=records[0], when="config", nodeid="", location=location
126+
)
127+
)
26128

27129

28130
@pytest.mark.tryfirst
@@ -32,6 +134,13 @@ def pytest_pycollect_makeitem(collector, name, obj):
32134
item = pytest.Function.from_parent(collector, name=name)
33135
if "asyncio" in item.keywords:
34136
return list(collector._genfunctions(name, obj))
137+
else:
138+
if _get_asyncio_mode(item.config) == Mode.AUTO:
139+
# implicitly add asyncio marker if asyncio mode is on
140+
ret = list(collector._genfunctions(name, obj))
141+
for elem in ret:
142+
elem.add_marker("asyncio")
143+
return ret
35144

36145

37146
class FixtureStripper:
@@ -88,9 +197,42 @@ def pytest_fixture_setup(fixturedef, request):
88197
policy.set_event_loop(loop)
89198
return
90199

91-
if isasyncgenfunction(fixturedef.func):
200+
func = fixturedef.func
201+
if not _is_coroutine_or_asyncgen(func):
202+
# Nothing to do with a regular fixture function
203+
yield
204+
return
205+
206+
config = request.node.config
207+
asyncio_mode = _get_asyncio_mode(config)
208+
209+
if not _has_explicit_asyncio_mark(func):
210+
if asyncio_mode == Mode.AUTO:
211+
# Enforce asyncio mode if 'auto'
212+
_set_explicit_asyncio_mark(func)
213+
elif asyncio_mode == Mode.LEGACY:
214+
_set_explicit_asyncio_mark(func)
215+
try:
216+
code = func.__code__
217+
except AttributeError:
218+
code = func.__func__.__code__
219+
name = (
220+
f"<fixture {func.__qualname__}, file={code.co_filename}, "
221+
f"line={code.co_firstlineno}>"
222+
)
223+
warnings.warn(
224+
LEGACY_ASYNCIO_FIXTURE.format(name=name),
225+
pytest.PytestDeprecationWarning,
226+
)
227+
else:
228+
# asyncio_mode is STRICT,
229+
# don't handle fixtures that are not explicitly marked
230+
yield
231+
return
232+
233+
if inspect.isasyncgenfunction(func):
92234
# This is an async generator function. Wrap it accordingly.
93-
generator = fixturedef.func
235+
generator = func
94236

95237
fixture_stripper = FixtureStripper(fixturedef)
96238
fixture_stripper.add(FixtureStripper.EVENT_LOOP)
@@ -129,8 +271,8 @@ async def async_finalizer():
129271
return loop.run_until_complete(setup())
130272

131273
fixturedef.func = wrapper
132-
elif inspect.iscoroutinefunction(fixturedef.func):
133-
coro = fixturedef.func
274+
elif inspect.iscoroutinefunction(func):
275+
coro = func
134276

135277
fixture_stripper = FixtureStripper(fixturedef)
136278
fixture_stripper.add(FixtureStripper.EVENT_LOOP)

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ show_missing = true
77
[tool:pytest]
88
addopts = -rsx --tb=short
99
testpaths = tests
10+
asyncio_mode = auto
1011
filterwarnings = error
1112

1213
[metadata]

0 commit comments

Comments
 (0)