From: Maria Matejka Date: Mon, 22 May 2023 10:05:52 +0000 (+0200) Subject: Python GUI: Reworked the asyncio helper completely X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=fff44269b9979db551f6bb89d63fb338aa458241;p=thirdparty%2Fbird.git Python GUI: Reworked the asyncio helper completely The original asyncio helper in the example is busy-waiting, playing ping-pong with Qt scheduler for the whole time. Using a separate worker thread instead. --- diff --git a/python/gui_view.py b/python/gui_view.py index 84809a3c6..a93e92d17 100644 --- a/python/gui_view.py +++ b/python/gui_view.py @@ -1,4 +1,4 @@ -from PySide6.QtCore import (Qt, QEvent, QObject, Signal, Slot) +from PySide6.QtCore import (Qt, QEvent, QObject, QRunnable, QThreadPool, Signal, Slot) from PySide6.QtWidgets import (QApplication, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget) from BIRD import BIRD @@ -7,6 +7,24 @@ import asyncio import signal import sys +# Async worker thread +class AsyncWorker(QRunnable): + @Slot() + def run(self): + self.loop = asyncio.new_event_loop() + self.loop.run_forever() + + def stop(self): + asyncio.run_coroutine_threadsafe(self._stop_internal(), self.loop) + + async def _stop_internal(self): + self.loop.stop() + + def dispatch(coro): + asyncio.run_coroutine_threadsafe(coro, AsyncWorker.worker.loop) + +if not hasattr(AsyncWorker, "worker"): + AsyncWorker.worker = AsyncWorker() class MainWindow(QMainWindow): @@ -30,100 +48,28 @@ class MainWindow(QMainWindow): layout.addWidget(self.text, alignment=Qt.AlignmentFlag.AlignCenter) async_trigger = QPushButton(text="Connect") - async_trigger.clicked.connect(self.async_start) + async_trigger.clicked.connect(self.connect) layout.addWidget(async_trigger, alignment=Qt.AlignmentFlag.AlignCenter) @Slot() - def async_start(self): - self.start_signal.emit() - - async def bird_connect(self): - async with self.bird as b: - await b.version.update() - await b.status.update() - - self.text.setText(f"Connected to {b.version.name} {b.version.version}") - - self.done_signal.emit() - -class AsyncHelper(QObject): - - class ReenterQtObject(QObject): - """ This is a QObject to which an event will be posted, allowing - asyncio to resume when the event is handled. event.fn() is - the next entry point of the asyncio event loop. """ - def event(self, event): - if event.type() == QEvent.Type.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 asyncio event loop. """ - def __init__(self, fn): - super().__init__(QEvent.Type(QEvent.Type.User + 1)) - self.fn = fn - - def __init__(self, worker, entry): - super().__init__() - self.reenter_qt = self.ReenterQtObject() - self.entry = entry - self.loop = asyncio.new_event_loop() - self.done = False + def connect(self): + async def f(): + async with self.bird as b: + await b.version.update() + await b.status.update() - self.worker = worker - if hasattr(self.worker, "start_signal") and isinstance(self.worker.start_signal, Signal): - self.worker.start_signal.connect(self.on_worker_started) - if hasattr(self.worker, "done_signal") and isinstance(self.worker.done_signal, Signal): - self.worker.done_signal.connect(self.on_worker_done) + self.text.setText(f"Connected to {b.version.name} {b.version.version}") - @Slot() - def on_worker_started(self): - """ To use asyncio and Qt together, one must run the asyncio - event loop as a "guest" inside the Qt "host" event loop. """ - if not self.entry: - raise Exception("No entry point for the asyncio event loop was set.") - asyncio.set_event_loop(self.loop) - self.loop.create_task(self.entry()) - self.loop.call_soon(self.next_guest_run_schedule) - self.done = False # Set this explicitly as we might want to restart the guest run. - self.loop.run_forever() - - @Slot() - def on_worker_done(self): - """ When all our current asyncio tasks are finished, we must end - the "guest run" lest we enter a quasi idle loop of switching - back and forth between the asyncio and Qt loops. We can - launch a new guest run by calling launch_guest_run() again. """ - self.done = True - - def continue_loop(self): - """ This function is called by an event posted to the Qt event - loop to continue the asyncio event loop. """ - if not self.done: - self.loop.call_soon(self.next_guest_run_schedule) - self.loop.run_forever() - - def next_guest_run_schedule(self): - """ This function serves to pause and re-schedule the guest - (asyncio) event loop inside the host (Qt) event loop. It is - registered in asyncio as a callback to be called at the next - iteration of the event loop. When this function runs, it - first stops the asyncio event loop, then by posting an event - on the Qt event loop, it both relinquishes to Qt's event - loop and also schedules the asyncio event loop to run again. - Upon handling this event, a function will be called that - resumes the asyncio event loop. """ - self.loop.stop() - QApplication.postEvent(self.reenter_qt, self.ReenterQtEvent(self.continue_loop)) + AsyncWorker.dispatch(f()) if __name__ == "__main__": app = QApplication(sys.argv) - main_window = MainWindow() - async_helper = AsyncHelper(main_window, main_window.bird_connect) + threadpool = QThreadPool() + threadpool.start(AsyncWorker.worker) - main_window.show() + mainwindow = MainWindow() + mainwindow.show() signal.signal(signal.SIGINT, signal.SIG_DFL) app.exec() + AsyncWorker.worker.stop()