quamash
quamash copied to clipboard
Support awaiting on qt signals
I did some experimenting with quamash, and I think it's fairly easy to make an awaitable wrapper for Qt signals. While it's not part of a regular asyncio event loop, it would make working with pure PyQt code (like QNetworkReply or QDialog) a bit easier.
I made an experimental branch at https://github.com/Wesmania/quamash/tree/non-compliant-stuff that cuts quamash down to a small, non-compliant event loop with support for awaiting on signals. There's still some stuff to be worked out (cleaning up references to awaited-on signals at cancel, wrapping slots in call_soon to avoid growing the stack too much, custom future with "done" signal), but it can serve as a proof-of-concept.
I think it would be beneficial to get some convenience functionality for bringing Qt signals to the async domain. IMHO this isn't as trivial as one might expect, since a there are two pitfalls in this:
-
Futures must clean up Qt signal connections after resolve or cancellation to avoid memory "leaks". This is important if the sender isn't discarded (like
QNetworkReply) after use. Example: Awaiting theclickedsignal of a persistentQPushButton. -
Futures should be able (if required) to handle premature destruction of the sender object. Example: Destroying a
QNetworkAccessManagerdestroys pendingQNetworkReplyobjects without ever emittingfinishedorerror, which shouldn't leave an awaitable in an undefined state.
If those two points are adressed, we can use asyncio.wait for timing out, racing or gathering multiple signal awaitables as @harvimt suggested. Thanks to the the built-in cancellation option in asyncio.Future this should work without leaking Qt connections as well.
Example usage:
# Await signal (forever)
await future_from_signal(bn.clicked)
# Await signal for 1s, automatically raise/cancel/cleanup on timeout
await asyncio.wait_for(future_from_signal(bn.clicked), timeout=1.0)
# Await the first of two signals
sig1, sig2 = future_from_signal(bn1.clicked), future_from_signal(bn2.clicked)
await asyncio.wait({sig1, sig2}, return_when=asyncio.FIRST_COMPLETED)
# ... check which one fired, cancel() the other ...
# Await signal from potentially volatile object
try:
await future_from_signal(obj.signal, obj)
except SenderDestroyed:
# ... sender destroyed before signal was emitted ...
I think this should work (corrections/suggestions?):
class SenderDestroyed(Exception):
"""
Exception raised on signal source destruction.
"""
def __init__(self):
super(SenderDestroyed, self).__init__("Sender object was destroyed")
class _SignalFutureAdapter:
__slots__ = ("_signal", "_future", "_sender", "__weakref__")
def __init__(self,
signal: QtCore.pyqtBoundSignal,
sender: QtCore.QObject):
self._signal = signal
self._future = asyncio.get_event_loop().create_future()
self._sender = sender
signal.connect(self._on_signal)
sender.destroyed.connect(self._on_destroyed)
self._future.add_done_callback(self._done_callback)
def _on_signal(self, *args):
if not self._future.done():
self._future.set_result(None if len(args) is 0 else args[0] if len(args) is 1 else args)
def _on_destroyed(self):
self._future.remove_done_callback(self._done_callback)
self._future.set_exception(SenderDestroyed())
def _done_callback(self, _):
self._signal.disconnect(self._on_signal)
self._sender.destroyed.disconnect(self._on_destroyed)
def future(self) -> asyncio.Future:
return self._future
def _on_signal(future: asyncio.Future, *args):
if not future.done():
future.set_result(None if len(args) is 0 else args[0] if len(args) is 1 else args)
def future_from_signal(signal: QtCore.pyqtBoundSignal,
sender: QtCore.QObject = None) -> asyncio.Future:
"""
Create Future for awaiting a Qt signal.
Providing a sender object as second argument guards the Future
against premature destruction of sender, in which case a
`SenderDestroyed` exception is raised.
:param signal: Qt signal.
:param sender: Sender object for observing destroyed signal (optional).
:return: Future for awaiting signal.
"""
if sender is None:
future = asyncio.get_event_loop().create_future()
on_signal = functools.partial(_on_signal, future)
signal.connect(on_signal)
future.add_done_callback(lambda f: signal.disconnect(on_signal))
return future
adapter = _SignalFutureAdapter(signal, sender)
return adapter.future()
If there is interest in having this in quamash, I could prepare a PR.