Skip to content

Commit a487615

Browse files
authored
Merge pull request #91 from altendky/ayfif
Restructure pytest plugin hooks
2 parents c665542 + fbc1ce8 commit a487615

File tree

3 files changed

+398
-77
lines changed

3 files changed

+398
-77
lines changed

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ async/await fixtures
162162
====================
163163
``async``/``await`` fixtures can be used along with ``yield`` for normal
164164
pytest fixture semantics of setup, value, and teardown. At present only
165-
function scope is supported.
165+
function and module scope are supported.
166166

167167
Note: You must *call* ``pytest_twisted.async_fixture()`` and
168168
``pytest_twisted.async_yield_fixture()``.

pytest_twisted.py

Lines changed: 194 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ class _instances:
4949
reactor = None
5050

5151

52+
class _tracking:
53+
async_yield_fixture_cache = {}
54+
to_be_torn_down = []
55+
56+
5257
def _deprecate(deprecated, recommended):
5358
def decorator(f):
5459
@functools.wraps(f)
@@ -102,14 +107,44 @@ def block_from_thread(d):
102107
return blockingCallFromThread(_instances.reactor, lambda x: x, d)
103108

104109

105-
@decorator.decorator
106-
def inlineCallbacks(fun, *args, **kw):
107-
return defer.inlineCallbacks(fun)(*args, **kw)
110+
def decorator_apply(dec, func):
111+
"""
112+
Decorate a function by preserving the signature even if dec
113+
is not a signature-preserving decorator.
114+
115+
https://github.com/micheles/decorator/blob/55a68b5ef1951614c5c37a6d201b1f3b804dbce6/docs/documentation.md#dealing-with-third-party-decorators
116+
"""
117+
return decorator.FunctionMaker.create(
118+
func, 'return decfunc(%(signature)s)',
119+
dict(decfunc=dec(func)), __wrapped__=func)
120+
121+
122+
def inlineCallbacks(f):
123+
"""
124+
Mark as inline callbacks test for pytest-twisted processing and apply
125+
@inlineCallbacks.
126+
127+
Unlike @ensureDeferred, @inlineCallbacks can be applied here because it
128+
does not call nor schedule the test function. Further, @inlineCallbacks
129+
must be applied here otherwise pytest identifies the test as a 'yield test'
130+
for which they dropped support in 4.0 and now they skip.
131+
"""
132+
decorated = decorator_apply(defer.inlineCallbacks, f)
133+
_set_mark(o=decorated, mark='inline_callbacks_test')
134+
135+
return decorated
108136

109137

110-
@decorator.decorator
111-
def ensureDeferred(fun, *args, **kw):
112-
return defer.ensureDeferred(fun(*args, **kw))
138+
def ensureDeferred(f):
139+
"""
140+
Mark as async test for pytest-twisted processing.
141+
142+
Unlike @inlineCallbacks, @ensureDeferred must not be applied here since it
143+
would call and schedule the test function.
144+
"""
145+
_set_mark(o=f, mark='async_test')
146+
147+
return f
113148

114149

115150
def init_twisted_greenlet():
@@ -130,10 +165,14 @@ def stop_twisted_greenlet():
130165
_instances.gr_twisted.switch()
131166

132167

133-
class _CoroutineWrapper:
134-
def __init__(self, coroutine, mark):
135-
self.coroutine = coroutine
136-
self.mark = mark
168+
def _get_mark(o, default=None):
169+
"""Get the pytest-twisted test or fixture mark."""
170+
return getattr(o, _mark_attribute_name, default)
171+
172+
173+
def _set_mark(o, mark):
174+
"""Set the pytest-twisted test or fixture mark."""
175+
setattr(o, _mark_attribute_name, mark)
137176

138177

139178
def _marked_async_fixture(mark):
@@ -144,21 +183,23 @@ def fixture(*args, **kwargs):
144183
except IndexError:
145184
scope = kwargs.get('scope', 'function')
146185

147-
if scope != 'function':
186+
if scope not in ['function', 'module']:
187+
# TODO: handle...
188+
# - class
189+
# - package
190+
# - session
191+
# - dynamic
192+
#
193+
# https://docs.pytest.org/en/latest/reference.html#pytest-fixture-api
194+
# then remove this and update docs, or maybe keep it around
195+
# in case new options come in without support?
196+
#
197+
# https://github.com/pytest-dev/pytest-twisted/issues/56
148198
raise AsyncFixtureUnsupportedScopeError.from_scope(scope=scope)
149199

150-
def marker(f):
151-
@functools.wraps(f)
152-
def w(*args, **kwargs):
153-
return _CoroutineWrapper(
154-
coroutine=f(*args, **kwargs),
155-
mark=mark,
156-
)
157-
158-
return w
159-
160200
def decorator(f):
161-
result = pytest.fixture(*args, **kwargs)(marker(f))
201+
_set_mark(f, mark)
202+
result = pytest.fixture(*args, **kwargs)(f)
162203

163204
return result
164205

@@ -167,61 +208,86 @@ def decorator(f):
167208
return fixture
168209

169210

211+
_mark_attribute_name = '_pytest_twisted_mark'
170212
async_fixture = _marked_async_fixture('async_fixture')
171213
async_yield_fixture = _marked_async_fixture('async_yield_fixture')
172214

173215

216+
def pytest_fixture_setup(fixturedef, request):
217+
"""Interface pytest to async for async and async yield fixtures."""
218+
# TODO: what about _adding_ inlineCallbacks fixture support?
219+
maybe_mark = _get_mark(fixturedef.func)
220+
if maybe_mark is None:
221+
return None
222+
223+
mark = maybe_mark
224+
225+
_run_inline_callbacks(
226+
_async_pytest_fixture_setup,
227+
fixturedef,
228+
request,
229+
mark,
230+
)
231+
232+
return not None
233+
234+
174235
@defer.inlineCallbacks
175-
def _pytest_pyfunc_call(pyfuncitem):
176-
testfunction = pyfuncitem.obj
177-
async_generators = []
178-
funcargs = pyfuncitem.funcargs
179-
if hasattr(pyfuncitem, "_fixtureinfo"):
180-
testargs = {}
181-
for arg in pyfuncitem._fixtureinfo.argnames:
182-
if isinstance(funcargs[arg], _CoroutineWrapper):
183-
wrapper = funcargs[arg]
184-
185-
if wrapper.mark == 'async_fixture':
186-
arg_value = yield defer.ensureDeferred(
187-
wrapper.coroutine
188-
)
189-
elif wrapper.mark == 'async_yield_fixture':
190-
async_generators.append((arg, wrapper))
191-
arg_value = yield defer.ensureDeferred(
192-
wrapper.coroutine.__anext__(),
193-
)
194-
else:
195-
raise UnrecognizedCoroutineMarkError.from_mark(
196-
mark=wrapper.mark,
197-
)
198-
else:
199-
arg_value = funcargs[arg]
200-
201-
testargs[arg] = arg_value
236+
def _async_pytest_fixture_setup(fixturedef, request, mark):
237+
"""Setup an async or async yield fixture."""
238+
fixture_function = fixturedef.func
239+
240+
kwargs = {
241+
name: request.getfixturevalue(name)
242+
for name in fixturedef.argnames
243+
}
244+
245+
if mark == 'async_fixture':
246+
arg_value = yield defer.ensureDeferred(
247+
fixture_function(**kwargs)
248+
)
249+
elif mark == 'async_yield_fixture':
250+
coroutine = fixture_function(**kwargs)
251+
252+
finalizer = functools.partial(
253+
_tracking.to_be_torn_down.append,
254+
coroutine,
255+
)
256+
request.addfinalizer(finalizer)
257+
258+
arg_value = yield defer.ensureDeferred(
259+
coroutine.__anext__(),
260+
)
202261
else:
203-
testargs = funcargs
204-
result = yield testfunction(**testargs)
262+
raise UnrecognizedCoroutineMarkError.from_mark(mark=mark)
205263

206-
async_generator_deferreds = [
207-
(arg, defer.ensureDeferred(g.coroutine.__anext__()))
208-
for arg, g in reversed(async_generators)
209-
]
264+
fixturedef.cached_result = (arg_value, request.param_index, None)
210265

211-
for arg, d in async_generator_deferreds:
212-
try:
213-
yield d
214-
except StopAsyncIteration:
215-
continue
216-
else:
217-
raise AsyncGeneratorFixtureDidNotStopError.from_generator(
218-
generator=arg,
219-
)
266+
defer.returnValue(arg_value)
220267

221-
defer.returnValue(result)
222268

269+
@defer.inlineCallbacks
270+
def tear_it_down(deferred):
271+
"""Tear down a specific async yield fixture."""
272+
try:
273+
yield deferred
274+
except StopAsyncIteration:
275+
return
276+
except Exception: # as e:
277+
pass
278+
# e = e
279+
else:
280+
pass
281+
# e = None
223282

224-
def pytest_pyfunc_call(pyfuncitem):
283+
# TODO: six.raise_from()
284+
raise AsyncGeneratorFixtureDidNotStopError.from_generator(
285+
generator=deferred,
286+
)
287+
288+
289+
def _run_inline_callbacks(f, *args):
290+
"""Interface into Twisted greenlet to run and wait for a deferred."""
225291
if _instances.gr_twisted is not None:
226292
if _instances.gr_twisted.dead:
227293
raise RuntimeError("twisted reactor has stopped")
@@ -230,26 +296,68 @@ def in_reactor(d, f, *args):
230296
return defer.maybeDeferred(f, *args).chainDeferred(d)
231297

232298
d = defer.Deferred()
233-
_instances.reactor.callLater(
234-
0.0, in_reactor, d, _pytest_pyfunc_call, pyfuncitem
235-
)
299+
_instances.reactor.callLater(0.0, in_reactor, d, f, *args)
236300
blockon_default(d)
237301
else:
238302
if not _instances.reactor.running:
239303
raise RuntimeError("twisted reactor is not running")
240-
blockingCallFromThread(
241-
_instances.reactor, _pytest_pyfunc_call, pyfuncitem
242-
)
243-
return True
304+
blockingCallFromThread(_instances.reactor, f, *args)
305+
306+
307+
@pytest.hookimpl(hookwrapper=True)
308+
def pytest_runtest_teardown(item):
309+
"""Tear down collected async yield fixtures."""
310+
yield
311+
312+
deferreds = []
313+
while len(_tracking.to_be_torn_down) > 0:
314+
coroutine = _tracking.to_be_torn_down.pop(0)
315+
deferred = defer.ensureDeferred(coroutine.__anext__())
316+
317+
deferreds.append(deferred)
318+
319+
for deferred in deferreds:
320+
_run_inline_callbacks(tear_it_down, deferred)
321+
322+
323+
def pytest_pyfunc_call(pyfuncitem):
324+
"""Interface to async test call handler."""
325+
# TODO: only handle 'our' tests? what is the point of handling others?
326+
# well, because our interface allowed people to return deferreds
327+
# from arbitrary tests so we kinda have to keep this up for now
328+
_run_inline_callbacks(_async_pytest_pyfunc_call, pyfuncitem)
329+
return not None
330+
331+
332+
@defer.inlineCallbacks
333+
def _async_pytest_pyfunc_call(pyfuncitem):
334+
"""Run test function."""
335+
kwargs = {
336+
name: value
337+
for name, value in pyfuncitem.funcargs.items()
338+
if name in pyfuncitem._fixtureinfo.argnames
339+
}
340+
341+
maybe_mark = _get_mark(pyfuncitem.obj)
342+
if maybe_mark == 'async_test':
343+
result = yield defer.ensureDeferred(pyfuncitem.obj(**kwargs))
344+
elif maybe_mark == 'inline_callbacks_test':
345+
result = yield pyfuncitem.obj(**kwargs)
346+
else:
347+
# TODO: maybe deprecate this
348+
result = yield pyfuncitem.obj(**kwargs)
349+
350+
defer.returnValue(result)
244351

245352

246353
@pytest.fixture(scope="session", autouse=True)
247-
def twisted_greenlet(request):
248-
request.addfinalizer(stop_twisted_greenlet)
354+
def twisted_greenlet():
355+
"""Provide the twisted greenlet in fixture form."""
249356
return _instances.gr_twisted
250357

251358

252359
def init_default_reactor():
360+
"""Install the default Twisted reactor."""
253361
import twisted.internet.default
254362

255363
module = inspect.getmodule(twisted.internet.default.install)
@@ -265,6 +373,7 @@ def init_default_reactor():
265373

266374

267375
def init_qt5_reactor():
376+
"""Install the qt5reactor... reactor."""
268377
import qt5reactor
269378

270379
_install_reactor(
@@ -273,6 +382,7 @@ def init_qt5_reactor():
273382

274383

275384
def init_asyncio_reactor():
385+
"""Install the Twisted reactor for asyncio."""
276386
from twisted.internet import asyncioreactor
277387

278388
_install_reactor(
@@ -289,6 +399,7 @@ def init_asyncio_reactor():
289399

290400

291401
def _install_reactor(reactor_installer, reactor_type):
402+
"""Install the specified reactor and create the greenlet."""
292403
try:
293404
reactor_installer()
294405
except error.ReactorAlreadyInstalledError:
@@ -308,6 +419,7 @@ def _install_reactor(reactor_installer, reactor_type):
308419

309420

310421
def pytest_addoption(parser):
422+
"""Add options into the pytest CLI."""
311423
group = parser.getgroup("twisted")
312424
group.addoption(
313425
"--reactor",
@@ -317,6 +429,7 @@ def pytest_addoption(parser):
317429

318430

319431
def pytest_configure(config):
432+
"""Identify and install chosen reactor."""
320433
pytest.inlineCallbacks = _deprecate(
321434
deprecated='pytest.inlineCallbacks',
322435
recommended='pytest_twisted.inlineCallbacks',
@@ -329,7 +442,13 @@ def pytest_configure(config):
329442
reactor_installers[config.getoption("reactor")]()
330443

331444

445+
def pytest_unconfigure(config):
446+
"""Stop the reactor greenlet."""
447+
stop_twisted_greenlet()
448+
449+
332450
def _use_asyncio_selector_if_required(config):
451+
"""Set asyncio selector event loop policy if needed."""
333452
# https://twistedmatrix.com/trac/ticket/9766
334453
# https://github.com/pytest-dev/pytest-twisted/issues/80
335454

0 commit comments

Comments
 (0)