mirror of
https://git.deluge-torrent.org/deluge
synced 2025-04-19 19:14:55 +00:00
[Decorators] Add maybe_coroutine decorator
- Clean up callback hell by making more code inline - Use async/await syntax as it has more modern niceties than inlineCallbacks - Also gets us closer if we want to transition to asyncio in the future - `await` is usable in places that `yield` is not. e.g. `return await thething` `func(await thething, 'otherparam')` - IDEs know async semantics of async/await syntax to help more than with `inlineCallbacks` - `maybe_coroutine` decorator has nice property (over `ensureDeferred`) that when used in a chain of other coroutines, they won't be wrapped in deferreds on each level, and so traceback will show each `await` call leading to the error. - All async functions wrapped in `maybe_coroutine` are 100% backwards compatible with a regular Deferred returning function. Whether called from a coroutine or not. - Use Deferred type hints as strings since older versions of twisted (<21.7) don't support generic Deferred type hinting.
This commit is contained in:
parent
bd88f78af6
commit
b76f2c0f20
2 changed files with 267 additions and 0 deletions
|
@ -10,6 +10,9 @@ import inspect
|
|||
import re
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, TypeVar
|
||||
|
||||
from twisted.internet import defer
|
||||
|
||||
|
||||
def proxy(proxy_func):
|
||||
|
@ -159,3 +162,54 @@ def deprecated(func):
|
|||
return func(*args, **kwargs)
|
||||
|
||||
return depr_func
|
||||
|
||||
|
||||
class CoroutineDeferred(defer.Deferred):
|
||||
"""Wraps a coroutine in a Deferred.
|
||||
It will dynamically pass through the underlying coroutine without wrapping where apporpriate."""
|
||||
|
||||
def __init__(self, coro: Coroutine):
|
||||
# Delay this import to make sure a reactor was installed first
|
||||
from twisted.internet import reactor
|
||||
|
||||
super().__init__()
|
||||
self.coro = coro
|
||||
self.awaited = None
|
||||
self.activate_deferred = reactor.callLater(0, self.activate)
|
||||
|
||||
def __await__(self):
|
||||
if self.awaited in [None, True]:
|
||||
self.awaited = True
|
||||
return self.coro.__await__()
|
||||
# Already in deferred mode
|
||||
return super().__await__()
|
||||
|
||||
def activate(self):
|
||||
"""If the result wasn't awaited before the next context switch, we turn it into a deferred."""
|
||||
if self.awaited is None:
|
||||
self.awaited = False
|
||||
d = defer.Deferred.fromCoroutine(self.coro)
|
||||
d.chainDeferred(self)
|
||||
|
||||
def addCallbacks(self, *args, **kwargs): # noqa: N802
|
||||
assert not self.awaited, 'Cannot add callbacks to an already awaited coroutine.'
|
||||
self.activate()
|
||||
return super().addCallbacks(*args, **kwargs)
|
||||
|
||||
|
||||
_RetT = TypeVar('_RetT')
|
||||
|
||||
|
||||
def maybe_coroutine(
|
||||
f: Callable[..., Coroutine[Any, Any, _RetT]]
|
||||
) -> 'Callable[..., defer.Deferred[_RetT]]':
|
||||
"""Wraps a coroutine function to make it usable as a normal function that returns a Deferred."""
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Uncomment for quick testing to make sure CoroutineDeferred magic isn't at fault
|
||||
# from twisted.internet.defer import ensureDeferred
|
||||
# return ensureDeferred(f(*args, **kwargs))
|
||||
return CoroutineDeferred(f(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
|
213
deluge/tests/test_maybe_coroutine.py
Normal file
213
deluge/tests/test_maybe_coroutine.py
Normal file
|
@ -0,0 +1,213 @@
|
|||
#
|
||||
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
|
||||
# the additional special exception to link portions of this program with the OpenSSL library.
|
||||
# See LICENSE for more details.
|
||||
#
|
||||
import pytest
|
||||
import pytest_twisted
|
||||
import twisted.python.failure
|
||||
from twisted.internet import defer, reactor, task
|
||||
from twisted.internet.defer import maybeDeferred
|
||||
|
||||
from deluge.decorators import maybe_coroutine
|
||||
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def inline_func():
|
||||
result = yield task.deferLater(reactor, 0, lambda: 'function_result')
|
||||
return result
|
||||
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def inline_error():
|
||||
raise Exception('function_error')
|
||||
yield
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def coro_func():
|
||||
result = await task.deferLater(reactor, 0, lambda: 'function_result')
|
||||
return result
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def coro_error():
|
||||
raise Exception('function_error')
|
||||
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def coro_func_from_inline():
|
||||
result = yield coro_func()
|
||||
return result
|
||||
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def coro_error_from_inline():
|
||||
result = yield coro_error()
|
||||
return result
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def coro_func_from_coro():
|
||||
return await coro_func()
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def coro_error_from_coro():
|
||||
return await coro_error()
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def inline_func_from_coro():
|
||||
return await inline_func()
|
||||
|
||||
|
||||
@maybe_coroutine
|
||||
async def inline_error_from_coro():
|
||||
return await inline_error()
|
||||
|
||||
|
||||
@pytest_twisted.inlineCallbacks
|
||||
def test_standard_twisted():
|
||||
"""Sanity check that twisted tests work how we expect.
|
||||
|
||||
Not really testing deluge code at all.
|
||||
"""
|
||||
result = yield inline_func()
|
||||
assert result == 'function_result'
|
||||
|
||||
with pytest.raises(Exception, match='function_error'):
|
||||
yield inline_error()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'function',
|
||||
[
|
||||
inline_func,
|
||||
coro_func,
|
||||
coro_func_from_coro,
|
||||
coro_func_from_inline,
|
||||
inline_func_from_coro,
|
||||
],
|
||||
)
|
||||
@pytest_twisted.inlineCallbacks
|
||||
def test_from_inline(function):
|
||||
"""Test our coroutines wrapped with maybe_coroutine as if they returned plain twisted deferreds."""
|
||||
result = yield function()
|
||||
assert result == 'function_result'
|
||||
|
||||
def cb(result):
|
||||
assert result == 'function_result'
|
||||
|
||||
d = function()
|
||||
d.addCallback(cb)
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'function',
|
||||
[
|
||||
inline_error,
|
||||
coro_error,
|
||||
coro_error_from_coro,
|
||||
coro_error_from_inline,
|
||||
inline_error_from_coro,
|
||||
],
|
||||
)
|
||||
@pytest_twisted.inlineCallbacks
|
||||
def test_error_from_inline(function):
|
||||
"""Test our coroutines wrapped with maybe_coroutine as if they returned plain twisted deferreds that raise."""
|
||||
with pytest.raises(Exception, match='function_error'):
|
||||
yield function()
|
||||
|
||||
def eb(result):
|
||||
assert isinstance(result, twisted.python.failure.Failure)
|
||||
assert result.getErrorMessage() == 'function_error'
|
||||
|
||||
d = function()
|
||||
d.addErrback(eb)
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'function',
|
||||
[
|
||||
inline_func,
|
||||
coro_func,
|
||||
coro_func_from_coro,
|
||||
coro_func_from_inline,
|
||||
inline_func_from_coro,
|
||||
],
|
||||
)
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_from_coro(function):
|
||||
"""Test our coroutines wrapped with maybe_coroutine work from another coroutine."""
|
||||
result = await function()
|
||||
assert result == 'function_result'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'function',
|
||||
[
|
||||
inline_error,
|
||||
coro_error,
|
||||
coro_error_from_coro,
|
||||
coro_error_from_inline,
|
||||
inline_error_from_coro,
|
||||
],
|
||||
)
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_error_from_coro(function):
|
||||
"""Test our coroutines wrapped with maybe_coroutine work from another coroutine with errors."""
|
||||
with pytest.raises(Exception, match='function_error'):
|
||||
await function()
|
||||
|
||||
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_tracebacks_preserved():
|
||||
with pytest.raises(Exception) as exc:
|
||||
await coro_error_from_coro()
|
||||
traceback_lines = [
|
||||
'await coro_error_from_coro()',
|
||||
'return await coro_error()',
|
||||
"raise Exception('function_error')",
|
||||
]
|
||||
# If each coroutine got wrapped with ensureDeferred, the traceback will be mangled
|
||||
# verify the coroutines passed through by checking the traceback.
|
||||
for expected, actual in zip(traceback_lines, exc.traceback):
|
||||
assert expected in str(actual)
|
||||
|
||||
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_maybe_deferred_coroutine():
|
||||
result = await maybeDeferred(coro_func)
|
||||
assert result == 'function_result'
|
||||
|
||||
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_callback_before_await():
|
||||
def cb(res):
|
||||
assert res == 'function_result'
|
||||
return res
|
||||
|
||||
d = coro_func()
|
||||
d.addCallback(cb)
|
||||
result = await d
|
||||
assert result == 'function_result'
|
||||
|
||||
|
||||
@pytest_twisted.ensureDeferred
|
||||
async def test_callback_after_await():
|
||||
"""If it has already been used as a coroutine, can't be retroactively turned into a Deferred.
|
||||
This limitation could be fixed, but the extra complication doesn't feel worth it.
|
||||
"""
|
||||
|
||||
def cb(res):
|
||||
pass
|
||||
|
||||
d = coro_func()
|
||||
await d
|
||||
with pytest.raises(
|
||||
Exception, match='Cannot add callbacks to an already awaited coroutine'
|
||||
):
|
||||
d.addCallback(cb)
|
Loading…
Add table
Reference in a new issue