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

Resources

Documentation

Read the Docs

Documentation

Chat

Gitter

Support chatroom

Forum

Discourse

Support forum

Issues

GitHub

Issues

Repository

GitHub

Repository

Tests

GitHub Actions

Tests

Coverage

Codecov

Test coverage

Distribution

PyPI

Latest distribution version
Supported Python versions
Supported Python interpreters

Introduction

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. In this set of small examples we will allow the user to input their name then use that input to generate an output message. The user will be able to cancel the input to terminate the program early. In the first example we will do it in the form of a classic “hello” console program. Well, classic plus a bit of boilerplate to allow explicit testing without using special external tooling. Then second, the form of a general Qt program implementing this same activity. And finally, the QTrio way.

# A complete runnable source file with imports and helpers is available in
# either the documentation readme examples or in the repository under
# qtrio/examples/readme/console.py.

def main(
    input_file: typing.TextIO = sys.stdin, output_file: typing.TextIO = sys.stdout
) -> None:
    try:
        output_file.write("What is your name? ")
        output_file.flush()
        name = input_file.readline()[:-1]
        output_file.write(f"Hi {name}, welcome to the team!\n")
    except KeyboardInterrupt:
        pass

Nice and concise, including the cancellation via ctrl+c. This is because we can stay in one scope thus using both local variables and a try/except block. This kind of explodes when you shift into a classic Qt GUI setup.

# A complete runnable source file with imports and helpers is available in
# either the documentation readme examples or in the repository under
# qtrio/examples/readme/qt.py.

class Main:
    def __init__(
        self,
        application: QtWidgets.QApplication,
        input_dialog: typing.Optional[QtWidgets.QInputDialog] = None,
        output_dialog: typing.Optional[QtWidgets.QMessageBox] = None,
    ):
        self.application = application

        if input_dialog is None:  # pragma: no cover
            input_dialog = create_input()

        if output_dialog is None:  # pragma: no cover
            output_dialog = create_output()

        self.input_dialog = input_dialog
        self.output_dialog = output_dialog

    def setup(self) -> None:
        self.input_dialog.accepted.connect(self.input_accepted)
        self.input_dialog.rejected.connect(self.input_rejected)

        self.input_dialog.show()

    def input_accepted(self) -> None:
        name = self.input_dialog.textValue()

        self.output_dialog.setText(f"Hi {name}, welcome to the team!")

        self.output_dialog.finished.connect(self.output_finished)
        self.output_dialog.show()

    def input_rejected(self) -> None:
        self.application.quit()

    def output_finished(self) -> None:
        self.application.quit()

The third example, below, shows how using async and await allows us to return to the more concise and clear description of the sequenced activity. Most of the code is just setup for testability with only the last four lines really containing the activity.

# A complete runnable source file with imports and helpers is available in
# either the documentation readme examples or in the repository under
# qtrio/examples/readme/qtrio_example.py.

async def main(
    *,
    task_status: trio_typing.TaskStatus[Dialogs] = trio.TASK_STATUS_IGNORED,
) -> None:
    dialogs = Dialogs()
    task_status.started(dialogs)

    with contextlib.suppress(qtrio.UserCancelledError):
        name = await dialogs.input.wait()
        dialogs.output.text = f"Hi {name}, welcome to the team!"
        await dialogs.output.wait()

Getting Started

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.

Installation

While the general aspects of installation using pip belong elsewhere, it is recommended to work in a virtual environment such as you can create with the venv module (see also Python Virtual Environments in Five Minutes ).

Somewhat more specific to QTrio, several extras are available for installing optional dependencies or applying version constraints.

  • cli - For CLI usage, presently just examples.

  • examples - For running examples.

  • pyqt5 - For running with PyQt5, primarily to apply any version constraints.

  • pyside2 - For running with PySide2, primarily to apply any version constraints.

A normal installation might look like:

$ myenv/bin/pip install qtrio[pyside2]

Overview

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 attr
import qtrio
from qts import QtWidgets
import trio
import trio_typing


@attr.s(auto_attribs=True, eq=False)
class Widget:
    message: str
    change_delay: float = 0.5
    close_delay: float = 3
    label: QtWidgets.QLabel = attr.ib(factory=QtWidgets.QLabel)

    text_changed = qtrio.Signal(str)

    done_event: trio.Event = attr.ib(factory=trio.Event)

    def setup(self) -> None:
        self.label.setText(self.message)
        self.label.setText("")

    async def show(self) -> None:
        # TODO: maybe raise if already started?
        # start big enough to fit the whole message
        self.label.setText(self.message)
        self.label.show()
        self.label.setText("")

    def set_text(self, text: str) -> None:
        self.label.setText(text)
        self.text_changed.emit(text)

    async def serve(
        self,
        *,
        task_status: trio_typing.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
    ) -> None:
        await self.show()
        task_status.started()
        partial_message = ""
        for character in self.message:
            partial_message += character

            await trio.sleep(self.change_delay)
            self.set_text(partial_message)

        await trio.sleep(self.close_delay)
        self.done_event.set()


async def start_widget(
    message: str,
    change_delay: float = 0.5,
    close_delay: float = 3,
    hold_event: typing.Optional[trio.Event] = None,
    *,
    cls: typing.Type[Widget] = Widget,
    task_status: trio_typing.TaskStatus[Widget] = trio.TASK_STATUS_IGNORED,
) -> None:
    self = cls(message=message, change_delay=change_delay, close_delay=close_delay)
    self.setup()

    task_status.started(self)

    if hold_event is not None:
        await hold_event.wait()

    await self.serve()


if __name__ == "__main__":  # pragma: no cover
    qtrio.run(start_widget, "Hello world.")

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 attr
import qtrio
from qts import QtWidgets
import trio
import trio_typing


@attr.s(auto_attribs=True, eq=False)
class Widget:
    message: str
    widget: QtWidgets.QWidget = attr.ib(factory=QtWidgets.QWidget)
    layout: QtWidgets.QLayout = attr.ib(factory=QtWidgets.QVBoxLayout)
    button: QtWidgets.QPushButton = attr.ib(factory=QtWidgets.QPushButton)
    label: QtWidgets.QWidget = attr.ib(factory=QtWidgets.QLabel)

    text_changed = qtrio.Signal(str)

    def setup(self) -> None:
        self.button.setText("More")

        self.layout.addWidget(self.button)
        self.layout.addWidget(self.label)
        self.widget.setLayout(self.layout)

    async def show(self) -> None:
        # TODO: maybe raise if already started?
        # start big enough to fit the whole message
        self.label.setText(self.message)
        self.widget.show()
        self.label.setText("")

    def set_text(self, text: str) -> None:
        self.label.setText(text)
        self.text_changed.emit(text)

    async def serve(
        self,
        *,
        task_status: trio_typing.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
    ) -> None:
        async with qtrio.enter_emissions_channel(
            signals=[self.button.clicked]
        ) as emissions:
            i = 1
            await self.show()
            task_status.started()

            async for _ in emissions.channel:  # pragma: no branch
                self.set_text(self.message[:i])
                i += 1

                if i > len(self.message):
                    break

            # wait for another click to finish
            await emissions.channel.receive()


async def start_widget(
    message: str,
    hold_event: typing.Optional[trio.Event] = None,
    *,
    cls: typing.Type[Widget] = Widget,
    task_status: trio_typing.TaskStatus[Widget] = trio.TASK_STATUS_IGNORED,
) -> None:
    self = cls(message=message)
    self.setup()

    task_status.started(self)

    if hold_event is not None:
        await hold_event.wait()

    await self.serve()


if __name__ == "__main__":  # pragma: no cover
    qtrio.run(start_widget, "Hello world.")

Layer 3 - Best Friends

This space intentionally left blank.

(for now… sorry)

Core

Running

qtrio.run(async_fn, *args, done_callback=None, clock=None, instruments=())[source]

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

Parameters:
Return type:

object

Returns:

The object returned by async_fn.

class qtrio.Runner(application=_Nothing.NOTHING, quit_application=True, clock=None, instruments=(), reenter=_Nothing.NOTHING, done_callback=None)[source]

Bases: object

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

application: QtGui.QGuiApplication

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: bool

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

clock: Optional[Clock]

The clock to use for this run. This is primarily used to speed up tests that include timeouts. The value will be passed on to trio.lowlevel.start_guest_run().

instruments: Sequence[Instrument]

The instruments to use for this run. The value will be passed on to trio.lowlevel.start_guest_run().

reenter: qtrio.qt.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: Optional[Callable[[Outcomes], None]]

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: Outcomes

The outcomes from the Qt and Trio runs.

cancel_scope: CancelScope

An all encompassing cancellation scope for the Trio execution.

run(async_fn, *args, execute_application=True)[source]

Start the guest loop executing async_fn.

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

  • args (object) – 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, a qtrio.Outcomes containing outcomes from the Qt application and async_fn will be returned. Otherwise, an empty qtrio.Outcomes.

run_sync_soon_threadsafe(fn)[source]

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[[], object]) – A no parameter callable.

Return type:

None

await trio_main(async_fn, args)[source]

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

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

  • args (Tuple[object, ...]) – Positional arguments to be passed to async_fn

Return type:

object

Returns:

The result returned by async_fn.

trio_done(run_outcome)[source]

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 qtrio.Runner.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.

Parameters:

run_outcome (Outcome) – The outcome of the Trio guest run.

Return type:

None

class qtrio.Outcomes(qt=None, trio=None)[source]

Bases: object

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: Optional[Outcome]

The Qt application outcome.Outcome

trio: Optional[Outcome]

The Trio async function outcome.Outcome

unwrap()[source]

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.

Return type:

object

Returns:

Whatever captured value was selected.

Raises:

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)[source]

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 (Union[int, float]) – 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[Emissions, None]

Returns:

The emissions manager.

class qtrio.Emission(signal, args)[source]

Bases: object

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: Signal

An instance of the original signal.

args: Tuple[object, ...]

A tuple of the arguments emitted by the signal.

is_from(signal)[source]

Check if this emission came from signal.

Parameters:

signal (Signal) – The signal instance to check for being the source.

Return type:

bool

Returns:

Whether the passed signal was the source of this emission.

class qtrio.Emissions(channel, send_channel)[source]

Bases: object

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: MemoryReceiveChannel

A memory receive channel to be fed by signal emissions.

send_channel: MemorySendChannel

A memory send channel collecting signal emissions.

await aclose()[source]

Asynchronously close the send channel when signal emissions are no longer of interest.

Return type:

None

If you need a more Qt-like callback mechanism qtrio.open_emissions_nursery() offers that. Instead of tossing the callbacks behind the couch where they can leave their errors on the floor they will be run inside a nursery.

async with qtrio.open_emissions_nursery(until=None, wrapper=None)[source]

Open a nursery for handling callbacks triggered by signal emissions. This allows a ‘normal’ Qt callback structure while still executing the callbacks within a Trio nursery such that errors have a place to go. Both async and sync callbacks can be connected. Sync callbacks will be wrapped in an async call to allow execution in the nursery.

Parameters:
Return type:

AsyncGenerator[EmissionsNursery, None]

Returns:

The emissions manager.

class qtrio.EmissionsNursery(nursery, exit_stack, wrapper=None)[source]

Bases: object

Holds the nursery, exit stack, and wrapper needed to support connecting signals to both async and sync slots in the nursery.

nursery: Nursery

The Trio nursery that will handle execution of the slots.

exit_stack: ExitStack

The exit stack that will manage the connections so they get disconnected.

wrapper: Optional[Callable[[Callable[..., Awaitable[object]]], Awaitable[object]]]

The wrapper for handling the slots. This could, for example, handle exceptions and present a dialog to avoid cancelling the entire nursery.

connect(signal, slot)[source]

Connect an async signal to this emissions nursery so when called the slot will be run in the nursery.

Return type:

None

connect_sync(signal, slot)[source]

Connect to a sync slot to this emissions nursery so when called the slot will be run in the nursery.

Return type:

None

Helpers

class qtrio.Signal(*types, name=None)[source]

Bases: object

This is a (nearly) drop-in replacement for Signal. The useful difference is that it does not require inheriting from QObject. The not-quite part is that it will be a bit more complicated to change thread affinity of the relevant QObject. If you need this, maybe just inherit.

This signal gets around the normally required inheritance by creating QObject instances behind the scenes to host the real signals. Just as Signal uses the Python descriptor protocol to intercept the attribute access, so does this so it can ‘redirect’ to the signal on the other object.

object(instance)[source]

Get the QObject that hosts the real signal. This can be called such as type(instance).signal_name.object(instance). Yes this is non-obvious but you have to do something special to get around the descriptor protocol so you can get at this method instead of just having the underlying Signal.

Parameters:

instance (object) – The object on which this descriptor instance is hosted.

Return type:

QObject

Returns:

The signal-hosting QObject.

Reentry Events

Generally you should not need to use these functions. If you want to have control over when the Qt event type is registered, what value it gets, or handle any exceptions raised then you may like to call these directly.

qtrio.register_event_type()[source]

Register a Qt event type for use by Trio to reenter into the Qt event loop.

Raises:
Return type:

None

qtrio.register_requested_event_type(requested_value)[source]

Register the requested Qt event type for use by Trio to reenter into the Qt event loop.

Parameters:

requested_value (Union[int, Type]) – The value to ask Qt to use for the event type being registered.

Raises:
Return type:

None

qtrio.registered_event_type()[source]

Get the registered event type.

Return type:

Optional[Type]

Returns:

The type registered with Qt for the reenter event. None if no event type has been registered yet.

Lifetimes

In default usage, QTrio will automatically manage the lifetime of the Qt application. For the Trio guest mode to function, including during cleanup, the Qt application must be active and processing events. If a program changes this process it can cause parts of the system to not work correctly. Trio’s guest mode can’t call any of your code after the Qt application has quit. This includes cleanup code such as in finally blocks.

The most direct way to cause this is by calling quit. Enabling setQuitOnLastWindowClosed and closing all windows will cause early event loop termination as well. If manual termination of the application is truly needed this can be enabled by setting qtrio.Runner.quit_application to False.

QTrio makes an effort to emit a qtrio.ApplicationQuitWarning. The message includes a link to this page as a reminder.

.../qtrio/_core.py:751: ApplicationQuitWarning: The Qt application quit early.  See https://qtrio.readthedocs.io/en/stable/lifetimes.html

In some cases Trio will emit a warning.

.../trio/_core/_run.py:2221: RuntimeWarning: Trio guest run got abandoned without properly finishing... weird stuff might happen

Testing

pytest-trio provides for using an alternative runner specified via the trio_run configuration option. QTrio is enabled with a value of qtrio. The example pytest.ini below will be common for QTrio test suites.

# pytest.ini
[pytest]
trio_mode = true
trio_run = qtrio

Dialogs

Usage Pattern

Creation Functions

qtrio.dialogs.create_integer_dialog(parent=None)[source]

Create an integer input dialog.

Parameters:

parent (Optional[QWidget]) – See qtrio.dialogs.IntegerDialog.parent.

Return type:

IntegerDialog

Returns:

The dialog manager.

qtrio.dialogs.create_text_input_dialog(title=None, label=None, parent=None)[source]

Create a text input dialog.

Parameters:
Return type:

TextInputDialog

Returns:

The dialog manager.

qtrio.dialogs.create_file_open_dialog(parent=None, default_directory=None, default_file=None, options=PySide2.QtWidgets.QFileDialog.Option(0))[source]

Create a file open dialog.

Parameters:
Return type:

FileDialog

qtrio.dialogs.create_file_save_dialog(parent=None, default_directory=None, default_file=None, options=PySide2.QtWidgets.QFileDialog.Option(0))[source]

Create a file save dialog.

Parameters:
Return type:

FileDialog

qtrio.dialogs.create_message_box(title='', text='', icon=PySide2.QtWidgets.QMessageBox.Icon.Information, buttons=PySide2.QtWidgets.QMessageBox.StandardButton.Ok, parent=None)[source]

Create a message box.

Parameters:
Return type:

MessageBox

qtrio.dialogs.create_progress_dialog(title='', text='', cancel_button_text=None, minimum=0, maximum=0, parent=None)[source]

Create a progress dialog.

Parameters:
Return type:

ProgressDialog

Classes

class qtrio.dialogs.IntegerDialog(parent=None, dialog=None, edit_widget=None, accept_button=None, reject_button=None, result=None)[source]

Bases: object

Manage a dialog for inputting an integer from the user. Generally instances should be built via qtrio.dialogs.create_integer_dialog().

parent: Optional[QWidget]

The parent widget for the dialog.

dialog: Optional[QInputDialog]

The actual dialog widget instance.

edit_widget: Optional[QLineEdit]

The line edit that the user will enter the input into.

accept_button: Optional[QAbstractButton]

The entry confirmation button.

reject_button: Optional[QAbstractButton]

The input cancellation button.

result: Optional[int]

The result of parsing the user input.

shown

See qtrio.dialogs.DialogProtocol.shown.

finished

See qtrio.dialogs.BasicDialogProtocol.finished.

setup()[source]

See qtrio.dialogs.BasicDialogProtocol.setup().

Return type:

None

teardown()[source]

See qtrio.dialogs.BasicDialogProtocol.teardown().

Return type:

None

await wait()[source]

See qtrio.dialogs.DialogProtocol.wait().

Return type:

int

class qtrio.dialogs.TextInputDialog(title=None, label=None, parent=None, dialog=None, accept_button=None, reject_button=None, line_edit=None, result=None, finished_event=_Nothing.NOTHING)[source]

Bases: object

Manage a dialog for inputting an integer from the user. Generally instances should be built via qtrio.dialogs.create_text_input_dialog().

title: Optional[str]

The title of the dialog.

label: Optional[str]

The label for the input widget.

parent: Optional[QWidget]

The parent widget for the dialog.

dialog: Optional[QInputDialog]

The actual dialog widget instance.

accept_button: Optional[QAbstractButton]

The entry confirmation button.

reject_button: Optional[QAbstractButton]

The input cancellation button.

line_edit: Optional[QLineEdit]

The line edit that the user will enter the input into.

result: Optional[str]

The result of parsing the user input.

shown

See qtrio.dialogs.DialogProtocol.shown.

finished

See qtrio.dialogs.BasicDialogProtocol.finished.

finished_event: Event
setup()[source]

See qtrio.dialogs.BasicDialogProtocol.setup().

Return type:

None

teardown()[source]

See qtrio.dialogs.BasicDialogProtocol.teardown().

Return type:

None

await wait(shown_event=<trio.Event object>)[source]

See qtrio.dialogs.DialogProtocol.wait().

Return type:

str

class qtrio.dialogs.FileDialog(file_mode, accept_mode, default_directory=None, default_file=None, options=PySide2.QtWidgets.QFileDialog.Option(0), parent=None, dialog=None, accept_button=None, reject_button=None, file_name_line_edit=None, result=None)[source]

Bases: object

Manage a dialog for allowing the user to select a file or directory. Generally instances should be built via qtrio.dialogs.create_file_save_dialog().

file_mode: FileMode

Controls whether the dialog is for picking an existing vs. new file or directory, etc.

accept_mode: AcceptMode

Specify an open vs. a save dialog.

default_directory: Optional[Path]

The directory to be initially presented in the dialog.

default_file: Optional[Path]

The file to be initially selected in the dialog.

options: Option

Miscellaneous options. See the Qt documentation.

parent: Optional[QWidget]

The parent widget for the dialog.

dialog: Optional[QFileDialog]

The actual dialog widget instance.

accept_button: Optional[QAbstractButton]

The confirmation button.

reject_button: Optional[QAbstractButton]

The cancellation button.

file_name_line_edit: Optional[QLineEdit]

The file name line edit widget.

result: Optional[Path]

The path selected by the user.

shown

See qtrio.dialogs.DialogProtocol.shown.

finished

See qtrio.dialogs.BasicDialogProtocol.finished.

await set_path(path)[source]

Set the directory and enter the file name in the text box. Note that this does not select the file in the file list.

Parameters:

path (Path) – The full path to the file to be set.

Return type:

None

setup()[source]

See qtrio.dialogs.BasicDialogProtocol.setup().

Return type:

None

teardown()[source]

See qtrio.dialogs.BasicDialogProtocol.teardown().

Return type:

None

await wait(shown_event=<trio.Event object>)[source]

See qtrio.dialogs.DialogProtocol.wait().

Return type:

Path

class qtrio.dialogs.MessageBox(title, text, icon, buttons, parent=None, dialog=None, accept_button=None, result=None)[source]

Bases: object

Manage a message box for notifying the user. Generally instances should be built via qtrio.dialogs.create_message_box().

title: str

The message box title.

text: str

The message text shown inside the dialog.

icon: Icon

The icon shown inside the dialog.

buttons: Union[StandardButton, StandardButtons]

The buttons to be shown in the dialog.

parent: Optional[QWidget]

The parent widget for the dialog.

dialog: Optional[QMessageBox]

The actual dialog widget instance.

accept_button: Optional[QAbstractButton]

The button to accept the dialog.

result: Optional[Path]

Not generally relevant for a message box.

shown

See qtrio.dialogs.DialogProtocol.shown.

finished

See qtrio.dialogs.BasicDialogProtocol.finished.

setup()[source]

See qtrio.dialogs.BasicDialogProtocol.setup().

Return type:

None

teardown()[source]

See qtrio.dialogs.BasicDialogProtocol.teardown().

Return type:

None

await wait(shown_event=<trio.Event object>)[source]

See qtrio.dialogs.DialogProtocol.wait().

Return type:

None

class qtrio.dialogs.ProgressDialog(title, text, cancel_button_text, minimum, maximum, parent=None, dialog=None, cancel_button=None)[source]

Bases: object

Manage a progress dialog for updating the user. Generally instances should be built via qtrio.dialogs.create_progress_dialog().

title: str

The message box title.

text: str

The message text shown as the progress bar label.

cancel_button_text: Optional[str]

The cancel button text.

minimum: int

The progress value corresponding to no progress.

maximum: int

The progress value corresponding to completion.

parent: Optional[QWidget]

The parent widget for the dialog.

dialog: Optional[QProgressDialog]

The actual dialog widget instance.

cancel_button: Optional[QPushButton]

The cancellation button.

shown

See qtrio.dialogs.DialogProtocol.shown.

finished

See qtrio.dialogs.BasicDialogProtocol.finished.

setup()[source]

See qtrio.dialogs.BasicDialogProtocol.setup().

Return type:

None

teardown()[source]

See qtrio.dialogs.BasicDialogProtocol.teardown().

Return type:

None

async with manage()[source]

A context manager to setup the progress dialog, cancel the managed context and teardown the dialog when done.

Return type:

AsyncIterator[None]

Protocols

class qtrio.dialogs.BasicDialogProtocol(*args, **kwargs)[source]

Bases: Protocol

The minimal common interface used for working with QTrio dialogs. To check that a class implements this protocol see qtrio.dialogs.check_basic_dialog_protocol().

finished: Signal

The signal to be emitted when the dialog is finished.

setup()[source]

Setup and show the dialog. Emit qtrio.dialogs.DialogProtocol.shown when done.

Return type:

None

teardown()[source]

Hide and teardown the dialog.

Return type:

None

class qtrio.dialogs.DialogProtocol(*args, **kwargs)[source]

Bases: BasicDialogProtocol, Protocol

The common interface used for working with QTrio dialogs. To check that a class implements this protocol see qtrio.dialogs.check_dialog_protocol().

shown: Signal

The signal to be emitted when the dialog is shown.

await wait()[source]

Show the dialog, wait for the user interaction, and return the result.

Raises:
Return type:

object

Protocol Checkers

These callables can be used if you want to verify that your own classes properly implement the associated protocols. They are simple pass through decorators at runtime but when checking type hints they will result in a failure if the class does not implement the protocol.

qtrio.dialogs.check_basic_dialog_protocol = <qtrio._util.ProtocolChecker object>

Assert proper implementation of qtrio.dialogs.BasicDialogProtocol when type hint checking.

Parameters:

cls – The class to verify.

Returns:

The same class, unmodified, that was passed in.

qtrio.dialogs.check_dialog_protocol = <qtrio._util.ProtocolChecker object>

Assert proper implementation of qtrio.dialogs.DialogProtocol when type hint checking.

Parameters:

cls – The class to verify.

Returns:

The same class, unmodified, that was passed in.

Exceptions and Warnings

Exceptions

class qtrio.QTrioException[source]

Bases: Exception

Base exception for all QTrio exceptions.

class qtrio.NoOutcomesError[source]

Bases: QTrioException

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

class qtrio.EventTypeRegistrationError[source]

Bases: QTrioException

Base class for various event type registration exceptions to inherit from.

class qtrio.EventTypeRegistrationFailedError[source]

Bases: EventTypeRegistrationError

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

class qtrio.RequestedEventTypeUnavailableError(requested_type, returned_type)[source]

Bases: EventTypeRegistrationError

Raised if the requested event type is unavailable.

class qtrio.EventTypeAlreadyRegisteredError[source]

Bases: EventTypeRegistrationError

Raised when a request is made to register an event type but a type has already been registered previously.

class qtrio.ReturnCodeError[source]

Bases: QTrioException

Wraps a QApplication return code as an exception.

class qtrio.InternalError[source]

Bases: QTrioException

Raised when an internal state is inconsistent.

class qtrio.UserCancelledError[source]

Bases: QTrioException

Raised when a user requested cancellation of an operation.

class qtrio.InvalidInputError[source]

Bases: QTrioException

Raised when invalid input is provided such as via a dialog.

Warnings

class qtrio.QTrioWarning[source]

Bases: UserWarning

Base warning for all QTrio warnings.

class qtrio.ApplicationQuitWarning[source]

Bases: QTrioWarning

Emitted when the Qt application quits but QTrio is expecting to manage the application lifetime. See the documentation on the application lifetime for more information.

Examples

Download

"""Thanks to njsmith for developing Trio guest mode and sharing the first example
integration with Qt.
"""
import contextlib
import functools
import math
import os
import time
import typing

import attr
import httpcore._async.http11
import httpx
import hyperlink
import trio
import trio_typing

import qtrio
import qtrio.dialogs


# Default is 4096
httpcore._async.http11.AsyncHTTP11Connection.READ_NUM_BYTES = 100_000

default_fps: int = 60


def create_title(specific: str) -> str:
    return f"QTrio Download Example - {specific}"


@attr.s(auto_attribs=True, frozen=True)
class Progress:
    downloaded: int
    first: bool
    total: typing.Optional[int] = None


@attr.s(auto_attribs=True, eq=False)
class Downloader:
    text_input_dialog: typing.Optional[qtrio.dialogs.TextInputDialog] = None
    file_dialog: typing.Optional[qtrio.dialogs.FileDialog] = None
    get_dialog: typing.Optional["GetDialog"] = None

    text_input_shown_event: trio.Event = attr.ib(factory=trio.Event)
    file_dialog_shown_event: trio.Event = attr.ib(factory=trio.Event)
    get_dialog_created_event: trio.Event = attr.ib(factory=trio.Event)

    async def serve(
        self,
        url: typing.Optional[typing.Union[str, hyperlink.URL]] = None,
        destination: typing.Optional[typing.Union[str, os.PathLike]] = None,
        fps: float = default_fps,
        http_application: typing.Optional[typing.Callable[..., typing.Any]] = None,
        *,
        task_status: trio_typing.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
    ) -> None:
        task_status.started()

        converted_url: hyperlink.URL
        converted_destination: trio.Path

        with contextlib.suppress(qtrio.UserCancelledError):
            if url is None:
                # async with trio.open_nursery() as nursery:
                #     start = functools.partial(
                #         qtrio.dialogs.TextInputDialog.serve,
                #         title=create_title("Enter URL"),
                #         label="URL to download:",
                #     )
                #     self.text_input_dialog = await nursery.start(start)

                self.text_input_dialog = qtrio.dialogs.create_text_input_dialog()

                self.text_input_dialog.title = create_title("Enter URL")
                self.text_input_dialog.label = "URL to download:"

                url = await self.text_input_dialog.wait(
                    shown_event=self.text_input_shown_event,
                )
                self.text_input_dialog = None

            if isinstance(url, str):
                converted_url = hyperlink.URL.from_text(url)
            else:
                converted_url = url

            if destination is None:
                self.file_dialog = qtrio.dialogs.create_file_save_dialog()

                default_file: str = ""
                if len(converted_url.path) > 0:
                    default_file = converted_url.path[-1]

                self.file_dialog.default_file = trio.Path(default_file)

                destination = await self.file_dialog.wait(
                    shown_event=self.file_dialog_shown_event,
                )
                self.file_dialog = None

            converted_destination = trio.Path(destination)

            async with trio.open_nursery() as nursery:
                start = functools.partial(
                    start_get_dialog,
                    url=converted_url,
                    destination=converted_destination,
                    fps=fps,
                    http_application=http_application,
                )
                self.get_dialog = await nursery.start(start)
                self.get_dialog_created_event.set()


async def start_downloader(
    url: typing.Optional[hyperlink.URL] = None,
    destination: typing.Optional[trio.Path] = None,
    fps: float = default_fps,
    http_application: typing.Optional[typing.Callable[..., typing.Any]] = None,
    hold_event: typing.Optional[trio.Event] = None,
    *,
    cls: typing.Type[Downloader] = Downloader,
    task_status: trio_typing.TaskStatus[Downloader] = trio.TASK_STATUS_IGNORED,
) -> None:
    self = cls()

    task_status.started(self)

    if hold_event is not None:
        await hold_event.wait()

    await self.serve(
        url=url, destination=destination, fps=fps, http_application=http_application
    )


@attr.s(auto_attribs=True, eq=False)
class GetDialog:
    fps: float = default_fps
    clock: typing.Callable[[], float] = time.monotonic
    http_application: typing.Optional[typing.Callable[..., typing.Any]] = None

    progress_dialog: typing.Optional[qtrio.dialogs.ProgressDialog] = None
    message_box: typing.Optional[qtrio.dialogs.MessageBox] = None

    progress_dialog_shown_event: trio.Event = attr.ib(factory=trio.Event)
    message_box_shown_event: trio.Event = attr.ib(factory=trio.Event)

    async def serve(
        self,
        url: hyperlink.URL,
        destination: trio.Path,
    ) -> None:
        self.progress_dialog = qtrio.dialogs.create_progress_dialog()

        self.progress_dialog.title = create_title("Fetching")
        self.progress_dialog.text = f"Fetching {url}..."

        async with self.progress_dialog.manage():
            if self.progress_dialog.dialog is None:  # pragma: no cover
                raise qtrio.InternalError(
                    "Dialog not assigned while it is being managed."
                )

            # Always show the dialog
            self.progress_dialog.dialog.setMinimumDuration(0)
            self.progress_dialog_shown_event.set()

            start = self.clock()

            async for progress in get(
                url=url,
                destination=destination,
                update_period=1 / self.fps,
                clock=self.clock,
                http_application=self.http_application,
            ):
                if progress.first:
                    if progress.total is None:
                        maximum = 0
                    else:
                        maximum = progress.total

                    self.progress_dialog.dialog.setMaximum(maximum)
                    self.progress_dialog.dialog.setValue(0)

                if progress.total is not None:
                    self.progress_dialog.dialog.setValue(progress.downloaded)

            end = self.clock()

        self.progress_dialog = None

        duration = end - start
        if duration == 0:
            # define this seems to happen when testing on Windows with an x86 Python
            if progress.downloaded > 0:
                bytes_per_second = math.inf
            else:  # pragma: no cover
                bytes_per_second = 0
        else:
            bytes_per_second = progress.downloaded / duration

        summary = "\n\n".join(
            [
                url.asText(),
                os.fspath(destination),
                f"Downloaded {progress.downloaded} bytes in {duration:.2f} seconds",
                f"{bytes_per_second:.2f} bytes/second",
            ]
        )

        self.message_box = qtrio.dialogs.create_message_box()

        self.message_box.title = create_title("Download Summary")
        self.message_box.text = summary

        await self.message_box.wait(shown_event=self.message_box_shown_event)

        self.message_box = None


async def start_get_dialog(
    url: hyperlink.URL,
    destination: trio.Path,
    fps: float = default_fps,
    http_application: typing.Optional[typing.Callable[..., typing.Any]] = None,
    hold_event: typing.Optional[trio.Event] = None,
    *,
    cls: typing.Type[GetDialog] = GetDialog,
    task_status: trio_typing.TaskStatus[GetDialog] = trio.TASK_STATUS_IGNORED,
) -> None:
    self = cls(fps=fps, http_application=http_application)

    task_status.started(self)

    if hold_event is not None:
        await hold_event.wait()

    await self.serve(url=url, destination=destination)


async def get(
    url: hyperlink.URL,
    destination: trio.Path,
    update_period: float = 0.2,
    clock: typing.Callable[[], float] = time.monotonic,
    http_application: typing.Optional[typing.Callable[..., typing.Any]] = None,
) -> typing.AsyncIterable[Progress]:
    async with httpx.AsyncClient(app=http_application) as client:
        async with client.stream("GET", url.asText()) as response:
            raw_content_length = response.headers.get("content-length")
            if raw_content_length is None:
                content_length = None
            else:
                content_length = int(raw_content_length)

            progress = Progress(
                downloaded=0,
                total=content_length,
                first=True,
            )

            yield progress
            last_update = clock()

            progress = attr.evolve(progress, first=False)

            downloaded = 0

            async with (await destination.open("wb")) as file:
                async for chunk in response.aiter_raw():
                    downloaded += len(chunk)
                    await file.write(chunk)

                    if clock() - last_update > update_period:
                        progress = attr.evolve(progress, downloaded=downloaded)
                        yield progress
                        last_update = clock()

            if progress.downloaded != downloaded:
                progress = attr.evolve(progress, downloaded=downloaded)
                yield progress


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

Emissions

import typing

import attr
import qts
from qts import QtCore
from qts import QtGui
from qts import QtWidgets
import qtrio
import trio
import trio_typing


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: QtGui.QCloseEvent) -> None:
        """Detect close events and emit the ``closed`` signal."""

        super().closeEvent(event)
        if event.isAccepted():
            # TODO: https://bugreports.qt.io/browse/PYSIDE-1318
            if qts.is_pyqt_5_wrapper:
                self.closed.emit()
            elif qts.is_pyside_5_wrapper:
                signal = typing.cast(QtCore.SignalInstance, self.closed)
                signal.emit()
            else:  # pragma: no cover
                raise qtrio.InternalError(
                    "You should not be here but you are running neither PyQt5 nor PySide2.",
                )
        else:  # pragma: no cover
            pass

    def showEvent(self, event: QtGui.QShowEvent) -> None:
        """Detect show events and emit the ``shown`` signal."""

        super().showEvent(event)
        if event.isAccepted():
            # TODO: https://bugreports.qt.io/browse/PYSIDE-1318
            if qts.is_pyqt_5_wrapper:
                self.shown.emit()
            elif qts.is_pyside_5_wrapper:
                signal = typing.cast(QtCore.SignalInstance, self.shown)
                signal.emit()
            else:  # pragma: no cover
                raise qtrio.InternalError(
                    "You should not be here but you are running neither PyQt5 nor PySide2.",
                )
        else:  # pragma: no cover
            pass


@attr.s(auto_attribs=True)
class Widget:
    """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 = attr.ib(factory=QSignaledWidget)
    increment: QtWidgets.QPushButton = attr.ib(factory=QtWidgets.QPushButton)
    decrement: QtWidgets.QPushButton = attr.ib(factory=QtWidgets.QPushButton)
    label: QtWidgets.QLabel = attr.ib(factory=QtWidgets.QLabel)
    layout: QtWidgets.QHBoxLayout = attr.ib(factory=QtWidgets.QHBoxLayout)
    count: int = 0
    serving_event: trio.Event = attr.ib(factory=trio.Event)

    def setup(self, title: str, parent: typing.Optional[QtWidgets.QWidget]) -> None:
        self.widget.setParent(parent)

        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)

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

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

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

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

    async def show(self) -> None:
        """Show the primary widget for this window."""

        self.widget.show()

    async def serve(
        self,
        *,
        task_status: trio_typing.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
    ) -> None:
        signals = [
            self.decrement.clicked,
            self.increment.clicked,
            self.widget.closed,
        ]

        async with qtrio.enter_emissions_channel(signals=signals) as emissions:
            await self.show()
            task_status.started()
            self.serving_event.set()

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


async def start_widget(
    title: str = "QTrio Emissions Example",
    parent: typing.Optional[QtWidgets.QWidget] = None,
    hold_event: typing.Optional[trio.Event] = None,
    *,
    cls: typing.Type[Widget] = Widget,
    task_status: trio_typing.TaskStatus[Widget] = trio.TASK_STATUS_IGNORED,
) -> None:
    self = cls()
    self.setup(title=title, parent=parent)

    task_status.started(self)

    if hold_event is not None:
        await hold_event.wait()

    await self.serve()

README

Console
import sys
import typing


def main(
    input_file: typing.TextIO = sys.stdin, output_file: typing.TextIO = sys.stdout
) -> None:
    try:
        output_file.write("What is your name? ")
        output_file.flush()
        name = input_file.readline()[:-1]
        output_file.write(f"Hi {name}, welcome to the team!\n")
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":  # pragma: no cover
    main()
Qt
import typing

from qts import QtWidgets


def create_input() -> QtWidgets.QInputDialog:
    dialog = QtWidgets.QInputDialog()
    dialog.setWindowTitle("Hello")
    dialog.setLabelText("Enter your name:")

    return dialog


def create_output() -> QtWidgets.QMessageBox:
    return QtWidgets.QMessageBox(
        QtWidgets.QMessageBox.Icon.Question,
        "Hello",
        "",
        QtWidgets.QMessageBox.Ok,
    )


class Main:
    def __init__(
        self,
        application: QtWidgets.QApplication,
        input_dialog: typing.Optional[QtWidgets.QInputDialog] = None,
        output_dialog: typing.Optional[QtWidgets.QMessageBox] = None,
    ):
        self.application = application

        if input_dialog is None:  # pragma: no cover
            input_dialog = create_input()

        if output_dialog is None:  # pragma: no cover
            output_dialog = create_output()

        self.input_dialog = input_dialog
        self.output_dialog = output_dialog

    def setup(self) -> None:
        self.input_dialog.accepted.connect(self.input_accepted)
        self.input_dialog.rejected.connect(self.input_rejected)

        self.input_dialog.show()

    def input_accepted(self) -> None:
        name = self.input_dialog.textValue()

        self.output_dialog.setText(f"Hi {name}, welcome to the team!")

        self.output_dialog.finished.connect(self.output_finished)
        self.output_dialog.show()

    def input_rejected(self) -> None:
        self.application.quit()

    def output_finished(self) -> None:
        self.application.quit()


def main() -> None:  # pragma: no cover
    application = QtWidgets.QApplication([])
    application.setQuitOnLastWindowClosed(False)
    main_object = Main(application=application)
    main_object.setup()
    application.exec_()


if __name__ == "__main__":  # pragma: no cover
    main()
QTrio
import contextlib

import attr
from qts import QtWidgets
import trio
import trio_typing

import qtrio
import qtrio.dialogs


def create_input() -> qtrio.dialogs.TextInputDialog:
    return qtrio.dialogs.create_text_input_dialog(
        title="Hello",
        label="Enter your name:",
    )


def create_output() -> qtrio.dialogs.MessageBox:
    return qtrio.dialogs.create_message_box(
        title="Hello",
        text="",
        icon=QtWidgets.QMessageBox.Icon.Question,
        buttons=QtWidgets.QMessageBox.Ok,
    )


@attr.s
class Dialogs:
    input: qtrio.dialogs.TextInputDialog = attr.ib(factory=create_input)
    output: qtrio.dialogs.MessageBox = attr.ib(factory=create_output)


async def main(
    *,
    task_status: trio_typing.TaskStatus[Dialogs] = trio.TASK_STATUS_IGNORED,
) -> None:
    dialogs = Dialogs()
    task_status.started(dialogs)

    with contextlib.suppress(qtrio.UserCancelledError):
        name = await dialogs.input.wait()
        dialogs.output.text = f"Hi {name}, welcome to the team!"
        await dialogs.output.wait()


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

Tip

Development should generally occur against the latest commit on the main branch. Please be sure that you are using the development documentation from there as well instead of reading the latest released documentation. One place to find these is the latest version on Read the Docs.

Developing

Tip

Development should generally occur against the latest commit on the main branch. Please be sure that you are using the development documentation from there as well instead of reading the latest released documentation. One place to find these is the latest version on Read the Docs.

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.

Tip

Development should generally occur against the latest commit on the main branch. Please be sure that you are using the development documentation from there as well instead of reading the latest released documentation. One place to find these is the latest version on Read the Docs.

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/python -m pip install --upgrade pip setuptools wheel
testvenv/bin/python -m pip install --editable .[pyside2,p_checks,p_docs,p_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). Note that the ci.sh script builds the package into dist/ and then installs whatever is in that directory. As such you will generally want to delete the contents of dist/ prior to running ci.sh.

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

Automatic code reformatting is handled by black.

python -m venv testvenv
testvenv/bin/python -m pip install --upgrade pip setuptools wheel
testvenv/bin/python -m pip install black
testvenv/bin/black .

Linting is handled by flake8.

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

The documentation can be built with sphinx.

python -m venv testvenv
testvenv/bin/python -m pip install --upgrade pip setuptools wheel
testvenv/bin/python -m pip install --editable .[pyside2,p_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.

Tip

Development should generally occur against the latest commit on the main branch. Please be sure that you are using the development documentation from there as well instead of reading the latest released documentation. One place to find these is the latest version on Read the Docs.

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
    • If this is a change relevant to users, there should be a newsfragment file as follows

    • 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, if present

  • Branch is up to date with main

Tip

Development should generally occur against the latest commit on the main branch. Please be sure that you are using the development documentation from there as well instead of reading the latest released documentation. One place to find these is the latest version on Read the Docs.

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

    • select new version number

    • checkout a new branch release-vX.Y.Z

    • bump version number in qtrio/_version.py

      • remove +dev tag from version number

    • run towncrier

  • push to the primary repository

    • a tag will be created on the branch and the tag should be in the primary repository

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

  • verify that all checks succeeded

  • get a review

  • tag with vX.Y.Z, push tag

  • download wheel and sdist from build artifacts and unpack

  • push to PyPI:

    twine upload dist/*
    
  • replace the +dev version tag in the same pull request as vX.Y.Z+dev

  • merge the release pull request

Release history

QTrio 0.7.0 (2023-06-07)

For contributors
  • Updated to support trio-typing 0.8.0. (#283)

QTrio 0.6.0 (2022-11-21)

Headline features
  • Added support for CPython 3.10 and 3.11. Note that PySide2 only supports up to 3.10 and there are unresolved issues with that combination so it is excluded from testing. (#275)

Features
  • Updated several dependencies. (#275)

Deprecations and removals
  • Removed testing of and support for EOL CPython 3.6. (#275)

QTrio 0.5.1 (2022-10-13)

Features
Improved documentation

QTrio 0.5.0 (2021-07-22)

Breaking changes
  • qts has replaced QtPy as the Qt wrapper compatibility layer. This allows progress on support for Qt 6 and more complete type hints. (#251)

For contributors
  • CI checks with mypy using both PySide2 and PyQt5. (#83)

  • Use -m towncrier in the Read the Docs build. (#123)

QTrio 0.4.2 (2021-02-04)

No significant changes. Released for documentation improvements.

QTrio 0.4.1 (2021-01-06)

Features
  • Constrained all dependencies for better long term compatibility and reduced pip resolution time. (#211)

QTrio 0.4.0 (2021-01-04)

Features
Bugfixes
  • Include the missing py.typed file that is used to state there are type hints to process. (#206)

Improved documentation

QTrio 0.3.0 (2020-10-16)

Headline features
  • Integrate with pytest-trio for testing. (#9)

  • Python 3.9 supported. (#113)

Breaking changes
  • Removed qtrio.host() in favor of pytest-trio for testing. (#9)

  • qtrio.run() returns the passed async function’s result instead of a combined Trio and Qt outcome. (#9)

QTrio 0.2.0 (2020-09-19)

Headline features
Features
Bugfixes
Improved documentation
  • Badges now in new resources section of readme and main doc page. (#103)

  • Classifiers for 3.6, 3.7, and 3.8 are included. (#104)

  • Link to issues included in resources section. (#106)

  • List all resource URLs in PyPI project URLs. (#107)

  • Use stable for outcome intersphinx links. (#109)

  • Add section about Installation, mostly to describe extras. (#155)

  • Show [sources] links in documentation linked to included code. (#168)

  • Update the layer 2 example to use async for _ in emissions.channel:. (#173)

For contributors
  • Shift to a single qtrio._tests package rather than distributing with one _tests per code package. (#139)

  • pytest type hints are no longer ignored. Version 6 or later required. (#153)

  • black config updated, use black . to format. (#174)

QTrio 0.1.0 (2020-07-10)

  • Initial release