From 80a8022b0e9676738d028a4e7a971f9be8ba199d Mon Sep 17 00:00:00 2001 From: Adrian Herrmann Date: Fri, 23 Sep 2022 21:23:01 +0200 Subject: examples: Add async examples Add two examples that demonstrate how to use Qt together with an async package (Trio). Task-number: PYSIDE-769 Change-Id: I1514eecc0a2eb65c6bb493857d901cf8817b7b52 Reviewed-by: Cristian Maureira-Fredes (cherry picked from commit 493afb7bef94a8c1115a200b64f1df2d3f502591) Reviewed-by: Christian Tismer --- examples/async/eratosthenes/doc/eratosthenes.rst | 42 ++++ examples/async/eratosthenes/doc/eratosthenes.svg | 1 + examples/async/eratosthenes/eratosthenes.py | 239 +++++++++++++++++++++ examples/async/eratosthenes/eratosthenes.pyproject | 3 + examples/async/eratosthenes/requirements.txt | 2 + examples/async/minimal/doc/minimal.png | Bin 0 -> 8135 bytes examples/async/minimal/doc/minimal.rst | 41 ++++ examples/async/minimal/minimal.py | 152 +++++++++++++ examples/async/minimal/minimal.pyproject | 3 + examples/async/minimal/requirements.txt | 1 + 10 files changed, 484 insertions(+) create mode 100644 examples/async/eratosthenes/doc/eratosthenes.rst create mode 100644 examples/async/eratosthenes/doc/eratosthenes.svg create mode 100644 examples/async/eratosthenes/eratosthenes.py create mode 100644 examples/async/eratosthenes/eratosthenes.pyproject create mode 100644 examples/async/eratosthenes/requirements.txt create mode 100644 examples/async/minimal/doc/minimal.png create mode 100644 examples/async/minimal/doc/minimal.rst create mode 100644 examples/async/minimal/minimal.py create mode 100644 examples/async/minimal/minimal.pyproject create mode 100644 examples/async/minimal/requirements.txt diff --git a/examples/async/eratosthenes/doc/eratosthenes.rst b/examples/async/eratosthenes/doc/eratosthenes.rst new file mode 100644 index 000000000..37758e99f --- /dev/null +++ b/examples/async/eratosthenes/doc/eratosthenes.rst @@ -0,0 +1,42 @@ +Async examples +============== + +The Python language provides keywords for asynchronous operations, i.e., +"async" to define coroutines or "await" to schedule asynchronous calls in the +event loop (see `PEP 492 `_). It is up to +packages to implement an event loop, support for these keywords, and more. + +One such package is `trio`. Since both an async package and Qt itself work with +event loops, special care must be taken to ensure that both event loops work +with each other. trio offers a dedicated `low-level API +`_ for more +complicated use cases such as this. Specifically, there exists a function +`start_guest_run` that enables running the Trio event loop as a "guest" inside +another event loop - Qt's in our case. + +Based on this functionality, two examples for async usage with Qt have been +implemented: `eratosthenes` and `minimal`: + +.. image:: eratosthenes.svg + :alt: Async example: Eratosthenes + :width: 400 + +* `eratosthenes` is a more extensive example that visualizes the Sieve of +Eratosthenes algorithm. This algorithm per se is not one that is particularly +suitable for asynchronous operations as it's not I/O-heavy, but synchronizing +coroutines to a configurable tick allows for a good visualization. +* `minimal` is a minimal example featuring a button that triggers an +asynchronous coroutine with a sleep. It is designed to highlight which +boilerplate code is essential for an async program with Qt and offers a +starting point for more complex programs. + +Both examples feature: + +1. A window class. +2. An `AsyncHelper` class containing `start_guest_run` plus helpers and +callbacks necessary for its invocation. The entry point for the Trio guest run +is provided as an argument from outside, which can be any async function. + +While `eratosthenes` offloads the asynchronous logic that will run in trio's +event loop into a separate class, `minimal` demonstrates that async functions +can be integrated into any class, including subclasses of Qt classes. diff --git a/examples/async/eratosthenes/doc/eratosthenes.svg b/examples/async/eratosthenes/doc/eratosthenes.svg new file mode 100644 index 000000000..eaf53da50 --- /dev/null +++ b/examples/async/eratosthenes/doc/eratosthenes.svg @@ -0,0 +1 @@ +🥳 Congratulations! You found all the prime numbers and solved mathematics. 🥳123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625 diff --git a/examples/async/eratosthenes/eratosthenes.py b/examples/async/eratosthenes/eratosthenes.py new file mode 100644 index 000000000..2b7581206 --- /dev/null +++ b/examples/async/eratosthenes/eratosthenes.py @@ -0,0 +1,239 @@ +############################################################################# +## +## Copyright (C) 2022 The Qt Company Ltd. +## Contact: https://www.qt.io/licensing/ +## +## This file is part of the Qt for Python examples of the Qt Toolkit. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of The Qt Company Ltd nor the names of its +## contributors may be used to endorse or promote products derived +## from this software without specific prior written permission. +## +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## +## $QT_END_LICENSE$ +## +############################################################################# + +from PySide6.QtCore import (Qt, QEvent, QObject, QTimer, Signal, Slot) +from PySide6.QtGui import (QColor, QFont, QPalette) +from PySide6.QtWidgets import (QApplication, QGridLayout, QLabel, QMainWindow, QVBoxLayout, QWidget) + +import outcome +import signal +import sys +import traceback +import trio +from random import randint + + +class MainWindow(QMainWindow): + + set_num = Signal(int, QColor) + + def __init__(self, rows, cols): + super().__init__() + + self.rows = rows + self.cols = cols + + widget_central = QWidget() + self.setCentralWidget(widget_central) + + layout_outer = QVBoxLayout(widget_central) + + self.widget_outer_text = QLabel() + font = QFont() + font.setPointSize(14) + self.widget_outer_text.setFont(font) + layout_outer.addWidget(self.widget_outer_text, alignment=Qt.AlignmentFlag.AlignCenter) + + widget_inner_grid = QWidget() + layout_outer.addWidget(widget_inner_grid, alignment=Qt.AlignmentFlag.AlignCenter) + + self.layout_inner_grid = QGridLayout(widget_inner_grid) + k = 1 + for i in range(self.rows): + for j in range(self.cols): + box = QLabel(f"{k}") + self.layout_inner_grid.addWidget(box, i, j, Qt.AlignmentFlag.AlignCenter) + k += 1 + + self.set_num.connect(self.set_num_handler) + + @Slot(int, QColor) + def set_num_handler(self, i, color): + row = int((i - 1) / self.cols) + col = (i - 1) - (row * self.cols) + widget = self.layout_inner_grid.itemAtPosition(row, col).widget() + + font = QFont() + font.setWeight(QFont.Bold) + palette = QPalette() + palette.setColor(QPalette.WindowText, color) + widget.setFont(font) + widget.setPalette(palette) + + +class Eratosthenes(): + + """ This Sieve of Eratosthenes runs on a configurable tick (default + 0.1 seconds). At each tick, a new subroutine will be created + that will check multiples of the next prime number. Each of + these subroutines also operates on the same second tick. The + tick is coordinated through the trio event loop's internal clock. """ + + def __init__(self, num, window, tick=0.1): + self.num = num + self.sieve = [True] * self.num + self.base = 0 + self.window = window + self.tick = tick + self.coroutines = [] + self.done = False + self.nursery = None + + def get_tick(self): + return trio.lowlevel.current_clock().current_time() + self.tick + + async def start(self): + async with trio.open_nursery() as self.nursery: + self.nursery.start_soon(self.update_text) + while self.base <= self.num / 2: + await trio.sleep_until(self.get_tick()) + for i in range(self.base + 1, self.num): + if self.sieve[i]: + self.base = i + break + self.nursery.start_soon(self.mark_number, self.base + 1) + while sum(self.coroutines) > 0: + await trio.sleep_until(self.get_tick()) + self.done = True + + async def mark_number(self, base): + id = len(self.coroutines) + self.coroutines.append(1) + color = QColor(randint(64, 192), randint(64, 192), randint(64, 192)) + for i in range(2 * base, self.num + 1, base): + if self.sieve[i - 1]: + self.sieve[i - 1] = False + self.window.set_num.emit(i, color) + await trio.sleep_until(self.get_tick()) + self.coroutines[id] = 0 + + async def update_text(self): + while not self.done: + tick = self.get_tick() + await trio.sleep_until(tick) + if int(tick) % 2: + text = "⚙️ ...Calculating prime numbers... ⚙️" + else: + text = "👩‍💻 ...Hacking the universe... 👩‍💻" + self.window.widget_outer_text.setText(text) + + self.window.widget_outer_text.setText( + "🥳 Congratulations! You found all the prime numbers and solved mathematics. 🥳" + ) + + +class AsyncHelper(QObject): + + trigger_signal = Signal() + + class ReenterQtObject(QObject): + """ This is a QObject to which an event will be posted, allowing + Trio to resume when the event is handled. event.fn() is the + next entry point of the Trio event loop. """ + def event(self, event): + if event.type() == QEvent.User + 1: + event.fn() + return True + return False + + class ReenterQtEvent(QEvent): + """ This is the QEvent that will be handled by the ReenterQtObject. + self.fn is the next entry point of the Trio event loop. """ + def __init__(self, fn): + super().__init__(QEvent.Type(QEvent.User + 1)) + self.fn = fn + + def __init__(self, entry=None): + super().__init__() + self.reenter_qt = self.ReenterQtObject() + self.entry = entry + + def set_entry(self, entry): + self.entry = entry + + @Slot() + def launch_guest_run(self): + """ To use Trio and Qt together, one must run the Trio event + loop as a "guest" inside the Qt "host" event loop. """ + if not self.entry: + raise Exception("No entry point for the Trio guest run was set.") + trio.lowlevel.start_guest_run( + self.entry, + run_sync_soon_threadsafe=self.next_guest_run_schedule, + done_callback=self.trio_done_callback, + ) + + def next_guest_run_schedule(self, fn): + """ This function serves to re-schedule the guest (Trio) event + loop inside the host (Qt) event loop. It is called by Trio + at the end of an event loop run in order to relinquish back + to Qt's event loop. By posting an event on the Qt event loop + that contains Trio's next entry point, it ensures that Trio's + event loop will be scheduled again by Qt. """ + QApplication.postEvent(self.reenter_qt, self.ReenterQtEvent(fn)) + + def trio_done_callback(self, outcome_): + """ This function is called by Trio when its event loop has + finished. """ + if isinstance(outcome_, outcome.Error): + error = outcome_.error + traceback.print_exception(type(error), error, error.__traceback__) + + +if __name__ == "__main__": + rows = 40 + cols = 40 + num = rows * cols + + app = QApplication(sys.argv) + main_window = MainWindow(rows, cols) + eratosthenes = Eratosthenes(num, main_window) + async_helper = AsyncHelper(entry=eratosthenes.start) + + # This establishes the entry point for the Trio guest run. It varies + # depending on how and when its event loop is to be triggered, e.g., + # from the beginning (as here) or rather at a specific moment like + # a button press. + QTimer.singleShot(0, async_helper.launch_guest_run) + + main_window.show() + + signal.signal(signal.SIGINT, signal.SIG_DFL) + app.exec() diff --git a/examples/async/eratosthenes/eratosthenes.pyproject b/examples/async/eratosthenes/eratosthenes.pyproject new file mode 100644 index 000000000..8ea189b3c --- /dev/null +++ b/examples/async/eratosthenes/eratosthenes.pyproject @@ -0,0 +1,3 @@ +{ + "files": ["eratosthenes.py"] +} diff --git a/examples/async/eratosthenes/requirements.txt b/examples/async/eratosthenes/requirements.txt new file mode 100644 index 000000000..e2cc10204 --- /dev/null +++ b/examples/async/eratosthenes/requirements.txt @@ -0,0 +1,2 @@ +trio +outcome diff --git a/examples/async/minimal/doc/minimal.png b/examples/async/minimal/doc/minimal.png new file mode 100644 index 000000000..b8a18963f Binary files /dev/null and b/examples/async/minimal/doc/minimal.png differ diff --git a/examples/async/minimal/doc/minimal.rst b/examples/async/minimal/doc/minimal.rst new file mode 100644 index 000000000..a72e8a73c --- /dev/null +++ b/examples/async/minimal/doc/minimal.rst @@ -0,0 +1,41 @@ +Async examples +============== + +The Python language provides keywords for asynchronous operations, i.e., +"async" to define coroutines or "await" to schedule asynchronous calls in the +event loop (see `PEP 492 `_). It is up to +packages to implement an event loop, support for these keywords, and more. + +One such package is `trio`. Since both an async package and Qt itself work with +event loops, special care must be taken to ensure that both event loops work +with each other. trio offers a dedicated `low-level API +`_ for more +complicated use cases such as this. Specifically, there exists a function +`start_guest_run` that enables running the Trio event loop as a "guest" inside +another event loop - Qt's in our case. + +Based on this functionality, two examples for async usage with Qt have been +implemented: `eratosthenes` and `minimal`: + +.. image:: minimal.png + :alt: Async example: Minimal + +* `eratosthenes` is a more extensive example that visualizes the Sieve of +Eratosthenes algorithm. This algorithm per se is not one that is particularly +suitable for asynchronous operations as it's not I/O-heavy, but synchronizing +coroutines to a configurable tick allows for a good visualization. +* `minimal` is a minimal example featuring a button that triggers an +asynchronous coroutine with a sleep. It is designed to highlight which +boilerplate code is essential for an async program with Qt and offers a +starting point for more complex programs. + +Both examples feature: + +1. A window class. +2. An `AsyncHelper` class containing `start_guest_run` plus helpers and +callbacks necessary for its invocation. The entry point for the Trio guest run +is provided as an argument from outside, which can be any async function. + +While `eratosthenes` offloads the asynchronous logic that will run in trio's +event loop into a separate class, `minimal` demonstrates that async functions +can be integrated into any class, including subclasses of Qt classes. diff --git a/examples/async/minimal/minimal.py b/examples/async/minimal/minimal.py new file mode 100644 index 000000000..b816f5725 --- /dev/null +++ b/examples/async/minimal/minimal.py @@ -0,0 +1,152 @@ +############################################################################# +## +## Copyright (C) 2022 The Qt Company Ltd. +## Contact: https://www.qt.io/licensing/ +## +## This file is part of the Qt for Python examples of the Qt Toolkit. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of The Qt Company Ltd nor the names of its +## contributors may be used to endorse or promote products derived +## from this software without specific prior written permission. +## +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## +## $QT_END_LICENSE$ +## +############################################################################# + +from PySide6.QtCore import (Qt, QEvent, QObject, Signal, Slot) +from PySide6.QtWidgets import (QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget) + +import outcome +import signal +import sys +import traceback +import trio + + +class MainWindow(QMainWindow): + + def __init__(self, async_signal): + super().__init__() + + self.async_signal = async_signal + + widget = QWidget() + self.setCentralWidget(widget) + + layout = QVBoxLayout(widget) + + self.text = QLabel("The answer is 42.") + layout.addWidget(self.text, alignment=Qt.AlignmentFlag.AlignCenter) + + async_trigger = QPushButton(text="What is the question?") + async_trigger.clicked.connect(self.async_start) + layout.addWidget(async_trigger, alignment=Qt.AlignmentFlag.AlignCenter) + + @Slot() + def async_start(self): + self.async_signal.emit() + + async def set_text(self): + await trio.sleep(1) + self.text.setText("What do you get if you multiply six by nine?") + + +class AsyncHelper(QObject): + + trigger_signal = Signal() + + class ReenterQtObject(QObject): + """ This is a QObject to which an event will be posted, allowing + Trio to resume when the event is handled. event.fn() is the + next entry point of the Trio event loop. """ + def event(self, event): + if event.type() == QEvent.User + 1: + event.fn() + return True + return False + + class ReenterQtEvent(QEvent): + """ This is the QEvent that will be handled by the ReenterQtObject. + self.fn is the next entry point of the Trio event loop. """ + def __init__(self, fn): + super().__init__(QEvent.Type(QEvent.User + 1)) + self.fn = fn + + def __init__(self, entry=None): + super().__init__() + self.reenter_qt = self.ReenterQtObject() + self.entry = entry + + def set_entry(self, entry): + self.entry = entry + + @Slot() + def launch_guest_run(self): + """ To use Trio and Qt together, one must run the Trio event + loop as a "guest" inside the Qt "host" event loop. """ + if not self.entry: + raise Exception("No entry point for the Trio guest run was set.") + trio.lowlevel.start_guest_run( + self.entry, + run_sync_soon_threadsafe=self.next_guest_run_schedule, + done_callback=self.trio_done_callback, + ) + + def next_guest_run_schedule(self, fn): + """ This function serves to re-schedule the guest (Trio) event + loop inside the host (Qt) event loop. It is called by Trio + at the end of an event loop run in order to relinquish back + to Qt's event loop. By posting an event on the Qt event loop + that contains Trio's next entry point, it ensures that Trio's + event loop will be scheduled again by Qt. """ + QApplication.postEvent(self.reenter_qt, self.ReenterQtEvent(fn)) + + def trio_done_callback(self, outcome_): + """ This function is called by Trio when its event loop has + finished. """ + if isinstance(outcome_, outcome.Error): + error = outcome_.error + traceback.print_exception(type(error), error, error.__traceback__) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + async_helper = AsyncHelper() + main_window = MainWindow(async_helper.trigger_signal) + async_helper.set_entry(main_window.set_text) + + # This establishes the entry point for the Trio guest run. It varies + # depending on how and when its event loop is to be triggered, e.g., + # at a specific moment like a button press (as here) or rather from + # the beginning. + async_helper.trigger_signal.connect(async_helper.launch_guest_run) + + main_window.show() + + signal.signal(signal.SIGINT, signal.SIG_DFL) + app.exec() diff --git a/examples/async/minimal/minimal.pyproject b/examples/async/minimal/minimal.pyproject new file mode 100644 index 000000000..97ff6dbc5 --- /dev/null +++ b/examples/async/minimal/minimal.pyproject @@ -0,0 +1,3 @@ +{ + "files": ["minimal.py"] +} diff --git a/examples/async/minimal/requirements.txt b/examples/async/minimal/requirements.txt new file mode 100644 index 000000000..ae0d704f0 --- /dev/null +++ b/examples/async/minimal/requirements.txt @@ -0,0 +1 @@ +trio -- cgit v1.2.3