Skip to content

Commit c4691e5

Browse files
authored
Merge pull request #37 from altendky/35-altendky-tidy_async_await_fixtures
[35] Support async/await fixtures
2 parents 34473e6 + 3e4d008 commit c4691e5

File tree

3 files changed

+283
-2
lines changed

3 files changed

+283
-2
lines changed

README.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ Waiting for deferreds in fixtures
8383
return pytest_twisted.blockon(d)
8484

8585

86+
async/await fixtures
87+
====================
88+
``async``/``await`` fixtures can be used along with ``yield`` for normal
89+
pytest fixture semantics of setup, value, and teardown. At present only
90+
function scope is supported::
91+
92+
@pytest_twisted.async_fixture
93+
async def foo():
94+
d1, d2 = defer.Deferred(), defer.Deferred()
95+
reactor.callLater(0.01, d1.callback, 42)
96+
reactor.callLater(0.02, d2.callback, 37)
97+
value = await d1
98+
yield value
99+
await d2
100+
101+
86102
The twisted greenlet
87103
====================
88104
Some libraries (e.g. corotwine) need to know the greenlet, which is

pytest_twisted.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@ class WrongReactorAlreadyInstalledError(Exception):
1515
pass
1616

1717

18+
class UnrecognizedCoroutineMarkError(Exception):
19+
@classmethod
20+
def from_mark(cls, mark):
21+
return cls(
22+
'Coroutine wrapper mark not recognized: {}'.format(repr(mark)),
23+
)
24+
25+
26+
class AsyncGeneratorFixtureDidNotStopError(Exception):
27+
@classmethod
28+
def from_generator(cls, generator):
29+
return cls(
30+
'async fixture did not stop: {}'.format(generator),
31+
)
32+
33+
34+
class AsyncFixtureUnsupportedScopeError(Exception):
35+
@classmethod
36+
def from_scope(cls, scope):
37+
return cls(
38+
'Unsupported scope used for async fixture: {}'.format(scope)
39+
)
40+
41+
1842
class _config:
1943
external_reactor = False
2044

@@ -105,16 +129,95 @@ def stop_twisted_greenlet():
105129
_instances.gr_twisted.switch()
106130

107131

132+
class _CoroutineWrapper:
133+
def __init__(self, coroutine, mark):
134+
self.coroutine = coroutine
135+
self.mark = mark
136+
137+
138+
def _marked_async_fixture(mark):
139+
@functools.wraps(pytest.fixture)
140+
def fixture(*args, **kwargs):
141+
try:
142+
scope = args[0]
143+
except IndexError:
144+
scope = kwargs.get('scope', 'function')
145+
146+
if scope != 'function':
147+
raise AsyncFixtureUnsupportedScopeError.from_scope(scope=scope)
148+
149+
def marker(f):
150+
@functools.wraps(f)
151+
def w(*args, **kwargs):
152+
return _CoroutineWrapper(
153+
coroutine=f(*args, **kwargs),
154+
mark=mark,
155+
)
156+
157+
return w
158+
159+
def decorator(f):
160+
result = pytest.fixture(*args, **kwargs)(marker(f))
161+
162+
return result
163+
164+
return decorator
165+
166+
return fixture
167+
168+
169+
async_fixture = _marked_async_fixture('async_fixture')
170+
async_yield_fixture = _marked_async_fixture('async_yield_fixture')
171+
172+
173+
@defer.inlineCallbacks
108174
def _pytest_pyfunc_call(pyfuncitem):
109175
testfunction = pyfuncitem.obj
176+
async_generators = []
110177
funcargs = pyfuncitem.funcargs
111178
if hasattr(pyfuncitem, "_fixtureinfo"):
112179
testargs = {}
113180
for arg in pyfuncitem._fixtureinfo.argnames:
114-
testargs[arg] = funcargs[arg]
181+
if isinstance(funcargs[arg], _CoroutineWrapper):
182+
wrapper = funcargs[arg]
183+
184+
if wrapper.mark == 'async_fixture':
185+
arg_value = yield defer.ensureDeferred(
186+
wrapper.coroutine
187+
)
188+
elif wrapper.mark == 'async_yield_fixture':
189+
async_generators.append((arg, wrapper))
190+
arg_value = yield defer.ensureDeferred(
191+
wrapper.coroutine.__anext__(),
192+
)
193+
else:
194+
raise UnrecognizedCoroutineMarkError.from_mark(
195+
mark=wrapper.mark,
196+
)
197+
else:
198+
arg_value = funcargs[arg]
199+
200+
testargs[arg] = arg_value
115201
else:
116202
testargs = funcargs
117-
return testfunction(**testargs)
203+
result = yield testfunction(**testargs)
204+
205+
async_generator_deferreds = [
206+
(arg, defer.ensureDeferred(g.coroutine.__anext__()))
207+
for arg, g in reversed(async_generators)
208+
]
209+
210+
for arg, d in async_generator_deferreds:
211+
try:
212+
yield d
213+
except StopAsyncIteration:
214+
continue
215+
else:
216+
raise AsyncGeneratorFixtureDidNotStopError.from_generator(
217+
generator=arg,
218+
)
219+
220+
defer.returnValue(result)
118221

119222

120223
def pytest_pyfunc_call(pyfuncitem):

testing/test_basic.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
import pytest
55

66

7+
# https://docs.python.org/3/whatsnew/3.5.html#pep-492-coroutines-with-async-and-await-syntax
78
ASYNC_AWAIT = sys.version_info >= (3, 5)
89

10+
# https://docs.python.org/3/whatsnew/3.6.html#pep-525-asynchronous-generators
11+
ASYNC_GENERATORS = sys.version_info >= (3, 6)
12+
913

1014
def assert_outcomes(run_result, outcomes):
1115
formatted_output = format_run_result_output_for_assert(run_result)
@@ -47,6 +51,13 @@ def skip_if_no_async_await():
4751
)
4852

4953

54+
def skip_if_no_async_generators():
55+
return pytest.mark.skipif(
56+
not ASYNC_GENERATORS,
57+
reason="async generators not support on Python <3.6",
58+
)
59+
60+
5061
@pytest.fixture
5162
def cmd_opts(request):
5263
reactor = request.config.getoption("reactor", "default")
@@ -303,6 +314,157 @@ async def test_succeed(foo):
303314
assert_outcomes(rr, {"passed": 2, "failed": 1})
304315

305316

317+
@skip_if_no_async_await()
318+
def test_async_fixture(testdir, cmd_opts):
319+
test_file = """
320+
from twisted.internet import reactor, defer
321+
import pytest
322+
import pytest_twisted
323+
324+
@pytest_twisted.async_fixture(scope="function", params=["fs", "imap", "web"])
325+
@pytest.mark.redgreenblue
326+
async def foo(request):
327+
d1, d2 = defer.Deferred(), defer.Deferred()
328+
reactor.callLater(0.01, d1.callback, 1)
329+
reactor.callLater(0.02, d2.callback, request.param)
330+
await d1
331+
return d2,
332+
333+
@pytest_twisted.inlineCallbacks
334+
def test_succeed_blue(foo):
335+
x = yield foo[0]
336+
if x == "web":
337+
raise RuntimeError("baz")
338+
"""
339+
testdir.makepyfile(test_file)
340+
rr = testdir.run(sys.executable, "-m", "pytest", "-v", *cmd_opts)
341+
assert_outcomes(rr, {"passed": 2, "failed": 1})
342+
343+
344+
@skip_if_no_async_generators()
345+
def test_async_yield_fixture_concurrent_teardown(testdir, cmd_opts):
346+
test_file = """
347+
from twisted.internet import reactor, defer
348+
import pytest
349+
import pytest_twisted
350+
351+
352+
here = defer.Deferred()
353+
there = defer.Deferred()
354+
355+
@pytest_twisted.async_yield_fixture()
356+
async def this():
357+
yield 42
358+
359+
there.callback(None)
360+
reactor.callLater(5, here.cancel)
361+
await here
362+
363+
@pytest_twisted.async_yield_fixture()
364+
async def that():
365+
yield 37
366+
367+
here.callback(None)
368+
reactor.callLater(5, there.cancel)
369+
await there
370+
371+
def test_succeed(this, that):
372+
pass
373+
"""
374+
testdir.makepyfile(test_file)
375+
# TODO: add a timeout, failure just hangs indefinitely for now
376+
# https://github.com/pytest-dev/pytest/issues/4073
377+
rr = testdir.run(sys.executable, "-m", "pytest", "-v", *cmd_opts)
378+
assert_outcomes(rr, {"passed": 1})
379+
380+
381+
@skip_if_no_async_generators()
382+
def test_async_yield_fixture(testdir, cmd_opts):
383+
test_file = """
384+
from twisted.internet import reactor, defer
385+
import pytest
386+
import pytest_twisted
387+
388+
@pytest_twisted.async_yield_fixture(
389+
scope="function",
390+
params=["fs", "imap", "web", "gopher", "archie"],
391+
)
392+
async def foo(request):
393+
d1, d2 = defer.Deferred(), defer.Deferred()
394+
reactor.callLater(0.01, d1.callback, 1)
395+
reactor.callLater(0.02, d2.callback, request.param)
396+
await d1
397+
398+
# Twisted doesn't allow calling back with a Deferred as a value.
399+
# This deferred is being wrapped up in a tuple to sneak through.
400+
# https://github.com/twisted/twisted/blob/c0f1394c7bfb04d97c725a353a1f678fa6a1c602/src/twisted/internet/defer.py#L459
401+
yield d2,
402+
403+
if request.param == "gopher":
404+
raise RuntimeError("gaz")
405+
406+
if request.param == "archie":
407+
yield 42
408+
409+
@pytest_twisted.inlineCallbacks
410+
def test_succeed(foo):
411+
x = yield foo[0]
412+
if x == "web":
413+
raise RuntimeError("baz")
414+
"""
415+
testdir.makepyfile(test_file)
416+
rr = testdir.run(sys.executable, "-m", "pytest", "-v", *cmd_opts)
417+
assert_outcomes(rr, {"passed": 2, "failed": 3})
418+
419+
420+
@skip_if_no_async_generators()
421+
def test_async_yield_fixture_function_scope(testdir, cmd_opts):
422+
test_file = """
423+
from twisted.internet import reactor, defer
424+
import pytest
425+
import pytest_twisted
426+
427+
check_me = 0
428+
429+
@pytest_twisted.async_yield_fixture(scope="function")
430+
async def foo():
431+
global check_me
432+
433+
if check_me != 0:
434+
raise Exception('check_me already modified before fixture run')
435+
436+
check_me = 1
437+
438+
yield 42
439+
440+
if check_me != 2:
441+
raise Exception(
442+
'check_me not updated properly: {}'.format(check_me),
443+
)
444+
445+
check_me = 0
446+
447+
def test_first(foo):
448+
global check_me
449+
450+
assert check_me == 1
451+
assert foo == 42
452+
453+
check_me = 2
454+
455+
def test_second(foo):
456+
global check_me
457+
458+
assert check_me == 1
459+
assert foo == 42
460+
461+
check_me = 2
462+
"""
463+
testdir.makepyfile(test_file)
464+
rr = testdir.run(sys.executable, "-m", "pytest", "-v", *cmd_opts)
465+
assert_outcomes(rr, {"passed": 2})
466+
467+
306468
def test_blockon_in_hook(testdir, cmd_opts, request):
307469
skip_if_reactor_not(request, "default")
308470
conftest_file = """

0 commit comments

Comments
 (0)