Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support awaiting on qt signals #90

Open
Wesmania opened this issue Apr 3, 2018 · 1 comment
Open

Support awaiting on qt signals #90

Wesmania opened this issue Apr 3, 2018 · 1 comment

Comments

@Wesmania
Copy link

Wesmania commented Apr 3, 2018

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.

@pwuertz
Copy link

pwuertz commented Oct 24, 2018

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 the clicked signal of a persistent QPushButton.

  • Futures should be able (if required) to handle premature destruction of the sender object. Example: Destroying a QNetworkAccessManager destroys pending QNetworkReply objects without ever emitting finished or error, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants