Join chatroom Join forum Documentation Latest PyPi version Repository Test coverage

QTrio - a library bringing Qt GUIs together with async and await via Trio

Note:

This library is in early development. It works. It has tests. It has documentation. Expect breaking changes as we explore a clean API. By paying this price you get the privilege to provide feedback via GitHub issues to help shape our future. :]

The QTrio project’s goal is to bring the friendly concurrency of Trio using Python’s async and await syntax together with the GUI features of Qt to enable more correct code and a more pleasant developer experience. QTrio is permissively licensed to avoid introducing restrictions beyond those of the underlying Python Qt library you choose. Both PySide2 and PyQt5 are supported.

By enabling use of async and await it is possible in some cases to write related code more concisely and clearly than you would get with the signal and slot mechanisms of Qt concurrency.

class TwoStep:
    def __init__(self, a_signal, some_path):
        self.signal = a_signal
        self.file = None
        self.some_path = some_path

    def before(self):
        self.file = open(some_path, 'w')
        self.signal.connect(self.after)
        self.file.write('before')

    def after(self, value):
        self.signal.disconnect(self.after)
        self.file.write(f'after {value!r}')
        self.file.close()
async def together(a_signal):
    with open(self.some_path, 'w') as file:
        async with qtrio.enter_emissions_channel(signals=[a_signal]) as emissions:
            file.write('before')
            emission = await emissions.channel.receive()
            [value] = emission.args
            file.write(f'after {value!r}')

Note how by using async and await we are not only able to more clearly and concisely describe the sequenced activity, we also get to use with to manage the context of the open file to be sure it gets closed.

Tutorial

This tutorial introduces usage of QTrio to enable integration of Qt into a Trio async application. For help with relevant async concepts and usage of Trio itself see the Trio tutorial.

I know, I know… we are supposed to do one thing well. But QTrio presently targets three distinct development tools. In time perhaps pieces will be spun off but for now they provide increasing layers you can use or not as they interest you.

The first layer allows you to run Trio tasks in the same thread as the Qt event loop. This is valuable as it let’s the tasks safely interact directly with the Qt GUI objects. It is a small wrapper around Trio’s guest mode. This layer is exposed directly under the qtrio package.

Now that Qt and Trio are friends we can focus on making the relationship smoother. This second layer of QTrio is also available directly in the qtrio package and allows for awaiting signals and iterating over the emissions of signals. This avoids the normal callback design of GUI systems in favor of Trio’s structured concurrency allowing GUI responses to be handled where you want within the task tree.

Not everything Qt provides will be easily integrated into this structure. The rest of QTrio will grow to contain helpers and wrappers to address these cases.

In addition to the above three layers there is also adjacent support for testing.

Layer 1 - Crossing Paths

With one extra character you can turn trio.run() into qtrio.run(). This gets you the Trio guest mode hosted by a Qt QApplication. Note how there is only one function and you are able to asynchronously sleep in it to avoid blocking the GUI. By default, when you leave your main function the Qt application will be exited.

import typing

import qtrio
from qtpy import QtWidgets
import trio


async def main(
    label: typing.Optional[QtWidgets.QWidget] = None,
    message: str = "Hello world.",
    change_delay=0.5,
    close_delay=3,
):
    if label is None:  # pragma: no cover
        label = QtWidgets.QLabel()
    # start big enough to fit the whole message
    label.setText(message)
    label.show()
    label.setText("")

    for i in range(len(message)):
        await trio.sleep(change_delay)
        label.setText(message[: i + 1])

    await trio.sleep(close_delay)


if __name__ == "__main__":  # pragma: no cover
    qtrio.run(main)

Layer 2 - Building Respect

A good relationship goes both ways. Above, Trio did all the talking and Qt just listened. Now let’s have Trio listen to Qt. Emissions from Qt signals can be made available in a trio.MemoryReceiveChannel. You can either trio.MemoryReceiveChannel.receive() them one at a time or asynchronously iterate over them for longer lasting activities. The received object is a qtrio.Emission and contains both the originating signal and the arguments.

import typing

import qtrio
from qtpy import QtWidgets


async def main(button: typing.Optional[QtWidgets.QPushButton] = None):
    if button is None:  # pragma: no cover
        button = QtWidgets.QPushButton()

    button.setText("Exit")

    async with qtrio.enter_emissions_channel(signals=[button.clicked]) as emissions:
        button.show()

        await emissions.channel.receive()


if __name__ == "__main__":  # pragma: no cover
    qtrio.run(main)

Layer 3 - Best Friends

This space intentionally left blank.

(for now… sorry)

Core

Running

qtrio.run(async_fn, *args, done_callback=None)

Run a Trio-flavored async function in guest mode on a Qt host application, and return the outcomes.

Parameters
Return type

Outcomes

Returns

The qtrio.Outcomes with both the Trio and Qt outcomes.

class qtrio.Runner(application=NOTHING, quit_application=True, timeout=None, reenter=NOTHING, done_callback=None)

This class helps run Trio in guest mode on a Qt host application.

application

The Qt application object to run as the host. If not set before calling run() the application will be created as QtWidgets.QApplication(sys.argv[1:]) and .setQuitOnLastWindowClosed(False) will be called on it to allow the application to continue throughout the lifetime of the async function passed to qtrio.Runner.run().

quit_application

When true, the done_callback() method will quit the application when the async function passed to qtrio.Runner.run() has completed.

timeout

If not :py:object`None`, use trio.move_on_after() to cancel after timeout seconds and raise.

reenter

The QObject instance which will receive the events requesting execution of the needed Trio and user code in the host’s event loop and thread.

done_callback

The builtin done_callback() will be passed to trio.lowlevel.start_guest_run() but will call the callback passed here before (maybe) quitting the application. The outcome.Outcome from the completion of the async function passed to run() will be passed to this callback.

outcomes

The outcomes from the Qt and Trio runs.

cancel_scope

An all encompassing cancellation scope for the Trio execution.

run(async_fn, *args, execute_application=True)

Start the guest loop executing async_fn.

Parameters
  • async_fn (Callable[[], Awaitable[None]]) – The async function to be run in the Qt host loop by the Trio guest.

  • args – Arguments to pass when calling async_fn.

  • execute_application (bool) – If True, the Qt application will be executed and this call will block until it finishes.

Return type

Outcomes

Returns

If execute_application is true, an Outcomes containing outcomes from the Qt application and async_fn will be returned. Otherwise, an empty Outcomes.

run_sync_soon_threadsafe(fn)

Helper for the Trio guest to execute a sync function in the Qt host thread when called from the Trio guest thread. This call will not block waiting for completion of fn nor will it return the result of calling fn.

Parameters

fn (Callable[[], Any]) – A no parameter callable.

Return type

None

await trio_main(async_fn, args)

Will be run as the main async function by the Trio guest. It creates a cancellation scope to be cancelled when QtGui.QGuiApplication.lastWindowClosed is emitted. Within this scope the application’s async_fn will be run and passed args.

Parameters
  • async_fn (Callable[…, Awaitable[None]]) – The application’s main async function to be run by Trio in the Qt host’s thread.

  • args (Tuple[Any, …]) – Positional arguments to be passed to async_fn

Return type

None

trio_done(run_outcome)

Will be called after the Trio guest run has finished. This allows collection of the outcome.Outcome and execution of any application provided done callback. Finally, if quit_application was set when creating the instance then the Qt application will be requested to quit().

Actions such as outputting error information or unwrapping the outcomes need to be further considered.

Return type

None

class qtrio.Outcomes(qt=None, trio=None)

This class holds an outcome.Outcome from each of the Trio and the Qt application execution. Do not construct instances directly. Instead, an instance will be returned from qtrio.run() or available on instances of qtrio.Runner.outcomes.

qt

The Qt application outcome.Outcome

trio

The Trio async function outcome.Outcome

unwrap()

Unwrap either the Trio or Qt outcome. First, errors are given priority over success values. Second, the Trio outcome gets priority over the Qt outcome. If both are still None a qtrio.NoOutcomesError is raised.

Emissions

The basics of handling Qt GUIs is to respond to the widgets’ signals being emitted. qtrio.enter_emissions_channel() is the primary tool for handling this. It allows for connection of signals prior to showing a window and subsequent iteration of the emissions. See the emissions example for an example usage.

async with qtrio.enter_emissions_channel(signals, max_buffer_size=inf)

Create a memory channel fed by the emissions of the signals and enter both the send and receive channels’ context managers.

Parameters
  • signals (Collection[Signal]) – A collection of signals which will be monitored for emissions.

  • max_buffer_size – When the number of unhandled emissions in the channel reaches this limit then additional emissions will be silently thrown out the window.

Return type

AsyncGenerator[MemoryReceiveChannel, None]

class qtrio.Emission(signal, args)

Stores the emission of a signal including the emitted arguments. Can be compared against a signal instance to check the source. Do not construct this class directly. Instead, instances will be received through a channel created by qtrio.enter_emissions_channel().

Note

Each time you access a signal such as a_qobject.some_signal you get a different signal instance object so the signal attribute generally will not be the same object. A signal instance is a QtCore.SignalInstance in PySide2 or QtCore.pyqtBoundSignal in PyQt5.

signal

An instance of the original signal.

args

A tuple of the arguments emitted by the signal.

class qtrio.Emissions(channel, send_channel)

Hold elements useful for the application to work with emissions from signals. Do not construct this class directly. Instead, use qtrio.enter_emissions_channel().

channel

A memory receive channel to be fed by signal emissions.

send_channel

A memory send channel collecting signal emissions.

Testing

qtrio.host(test_function)

Decorate your tests that you want run with a Trio guest and a Qt Host.

Note

Presently the test is required to specify the request fixture so this decorator can intercept and use it.

Warning

The interface for specifying tests to run in this way will likely change a lot. Try to keep up. :|

Parameters

test_function (Callable[…, Awaitable[None]]) – The pytest function to be tested.

Exceptions

class qtrio.QTrioException

Base exception for all QTrio exceptions.

class qtrio.NoOutcomesError

Raised if you try to unwrap a qtrio.Outcomes which has no outcomes.

class qtrio.RegisterEventTypeError

Raised if the attempt to register a new event type fails.

class qtrio.ReturnCodeError

Wraps a QApplication return code as an exception.

Examples

Emissions

import attr
from qtpy import QtCore
from qtpy import QtWidgets

import qtrio


class QSignaledWidget(QtWidgets.QWidget):
    """A :class:`QtWidgets.QWidget` with extra signals for events of interest.

    Attributes:
        closed: A signal that will be emitted after a close event.
    """

    closed = QtCore.Signal()
    shown = QtCore.Signal()

    def closeEvent(self, event):
        """Detect close events and emit the `closed` signal."""

        super().closeEvent(event)
        if event.isAccepted():
            self.closed.emit()
        else:  # pragma: no cover
            pass

    def showEvent(self, event):
        """Detect show events and emit the `shown` signal."""

        super().showEvent(event)
        if event.isAccepted():
            self.shown.emit()
        else:  # pragma: no cover
            pass


@attr.s(auto_attribs=True)
class Window:
    """A manager for a simple window with increment and decrement buttons to change a
    counter which is displayed via a widget in the center.
    """

    widget: QSignaledWidget
    increment: QtWidgets.QPushButton
    decrement: QtWidgets.QPushButton
    label: QtWidgets.QLabel
    layout: QtWidgets.QHBoxLayout
    count: int = 0

    @classmethod
    def build(cls, title="QTrio Emissions Example", parent=None):
        """Build and lay out the widgets that make up this window."""

        self = cls(
            widget=QSignaledWidget(parent),
            layout=QtWidgets.QHBoxLayout(),
            increment=QtWidgets.QPushButton(),
            decrement=QtWidgets.QPushButton(),
            label=QtWidgets.QLabel(),
        )

        self.widget.setWindowTitle(title)
        self.widget.setLayout(self.layout)

        self.increment.setText("+")
        self.decrement.setText("-")

        self.label.setText(str(self.count))

        self.layout.addWidget(self.decrement)
        self.layout.addWidget(self.label)
        self.layout.addWidget(self.increment)

        return self

    def increment_count(self):
        """Increment the counter and update the label."""

        self.count += 1
        self.label.setText(str(self.count))

    def decrement_count(self):
        """Decrement the counter and update the label."""

        self.count -= 1
        self.label.setText(str(self.count))

    def show(self):
        """Show the primary widget for this window."""

        self.widget.show()


async def main(window=None):
    """Show the example window and iterate over the relevant signal emissions to respond
    to user interactions with the GUI.
    """
    if window is None:  # pragma: no cover
        window = Window.build()

    signals = [
        window.decrement.clicked,
        window.increment.clicked,
        window.widget.closed,
    ]

    async with qtrio.enter_emissions_channel(signals=signals) as emissions:
        window.show()

        async for emission in emissions.channel:
            if emission.is_from(window.decrement.clicked):
                window.decrement_count()
            elif emission.is_from(window.increment.clicked):
                window.increment_count()
            elif emission.is_from(window.widget.closed):
                break
            else:  # pragma: no cover
                raise qtrio.QTrioException(f"Unexpected emission: {emission}")

Developing

Contributing

Welcome to the team! By so much as reading the documentation or using the library you are providing a valuable testing service to QTrio. If you find anything amiss please do submit a GitHub issue to let us know. If you are comfortable providing a pull request to fix the issue, all the better.

Since QTrio is early in its life, it is both more prone to problems and more flexible about responding to them. Community involvement is one of the key pieces to evolving a powerful and usable library. We each bring different perspectives and needs that together expose the shortcomings and strengths of what we have made.

Testing

While developing it is important to make certain that the existing tests continue to pass and that any changes you make also have passing tests that exercise them. This will be done in it’s entirety for you when you submit PR. These runs will cover multiple operating systems, Python versions, and Qt libraries. They will also check formatting, the documentation build, and more.

Still, sometimes you would rather run the tests locally for potentially quicker feedback, the opportunity to debug, and less public observation of your every commit. You can run pytest, black, and sphinx directly from your own installation.

python -m venv testvenv
testvenv/bin/pip install --upgrade pip setuptools wheel
testvenv/bin/pip install --editable .[pyside2,checks,docs,tests]
testvenv/bin/pytest --pyargs qtrio

The CI test script, ci.sh, in the project root will run pytest with coverage (and fail to upload the coverage results, which is ok).

python -m venv testvenv
source testvenv/bin/activate
./ci.sh

Automatic code reformatting is handled by black.

python -m venv testvenv
testvenv/bin/pip install --upgrade pip setuptools wheel
testvenv/bin/pip install black
testvenv/bin/black setup.py docs/ qtrio/

Linting is handled by flake8.

python -m venv testvenv
testvenv/bin/pip install --upgrade pip setuptools wheel
testvenv/bin/pip install flake8
testvenv/bin/flake8 setup.py docs/ qtrio/

The documentation can be built with sphinx.

python -m venv testvenv
testvenv/bin/pip install --upgrade pip setuptools wheel
testvenv/bin/pip install --editable .[pyside2,docs]
source testenv/bin/activate
cd docs/
make html --always-make

I don’t like to write the hazardous command that does it, but it is good to remove the entire docs/build/ directory prior to each build of the documentation. After building the documentation it can be loaded in your browser at file:///path/to/qtrio/docs/build/html/index.html.

Reviewing

Manual checks

  • The change should happen
    • The bug is a bug

    • The feature is an improvement

    • The code belongs in QTrio, not another package
      • Unless it is a workaround in QTrio temporarily or because the proper library has declined to resolve the issue.

  • Relevant bugs or features are being tested
    • The line coverage provided by automatic coverage checks are valuable but you are the only one that can decide if the proper functionality is being tested

  • Documentation updates
    • Docstrings are present and accurate for all modules, classes, methods, and functions including private ones and tests.

    • For bug fixes consider if the docs should be updated to clarify proper behavior

    • For feature additions consider if prose in the docs should be updated in addition to the docstrings.

  • The change is described for the user
    • Newsfragment file name has the proper issue or PR number and change type

    • The contents describe the change well for users

    • Proper Sphinx references are used where appropriate

Automatic checks

  • Full test suite passes across:
    • operating systems

    • Python versions

    • Qt libraries

  • All code and tests lines are fully covered when running tests

  • Code is formatted per Black

  • Code passes flake8 checks

  • Docs build successfully including newsfragment

  • Branch is up to date with master

  • A newsfragment file is present

Preparing a release

Things to do for releasing:

  • check for open issues / pull requests that really should be in the release

    • come back when these are done

    • … or ignore them and do another release next week

  • check for deprecations “long enough ago” (two months or two releases, whichever is longer)

    • remove affected code

  • do the actual release changeset

    • bump version number

    • run towncrier

      • review history change

      • git rm changes

    • fixup docs/source/history.rst

      • correct QTrio capitalization

      • remove empty misc changelog entries from the history

    • commit

  • push to your personal repository

  • create pull request to altendky/qtrio’s “master” branch

  • verify that all checks succeeded

  • tag with vX.Y.Z, push tag

  • download wheel and sdist from build artifacts and unpack

  • push to PyPI:

    twine upload dist/*
    
  • update version number in the same pull request

    • add +dev tag to the end

  • merge the release pull request

Release history

QTrio 0.1.0 (2020-07-10)

  • Initial release