diff --git a/README.md b/README.md index 880b411..cd39b52 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,68 @@ ## Introduction -`qasync` allows coroutines to be used in PyQt/PySide applications by providing an implementation of the `PEP 3156` event-loop. +`qasync` allows coroutines to be used in PyQt/PySide applications by providing an implementation of the `PEP 3156` event loop. -`qasync` is a fork of [asyncqt](https://github.com/gmarull/asyncqt), which is a fork of [quamash](https://github.com/harvimt/quamash). May it live longer than its predecessors. +With `qasync`, you can use `asyncio` functionalities directly inside Qt app's event loop, in the main thread. Using async functions for Python tasks can be much easier and cleaner than using `threading.Thread` or `QThread`. -#### The future of `qasync` +If you need some CPU-intensive tasks to be executed in parallel, `qasync` also got that covered, providing `QEventLoop.run_in_executor` which is functionally identical to that of `asyncio`. -`qasync` was created because `asyncqt` and `quamash` are no longer maintained. +### Basic Example + +```python +import sys +import asyncio + +from qasync import QEventLoop, QApplication +from PySide6.QtWidgets import QWidget, QVBoxLayout + +class MainWindow(QWidget): + def __init__(self): + super().__init__() + + self.setLayout(QVBoxLayout()) + self.lbl_status = QLabel("Idle", self) + self.layout().addWidget(self.lbl_status) + + @asyncClose + async def closeEvent(self, event): + pass + + @asyncSlot() + async def onMyEvent(self): + pass + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + event_loop = QEventLoop(app) + asyncio.set_event_loop(event_loop) + + app_close_event = asyncio.Event() + app.aboutToQuit.connect(app_close_event.set) + + main_window = MainWindow() + main_window.show() + + with event_loop: + event_loop.run_until_complete(app_close_event.wait()) +``` + +More detailed examples can be found [here](https://github.com/CabbageDevelopment/qasync/tree/master/examples). + +### The Future of `qasync` + +`qasync` is a fork of [asyncqt](https://github.com/gmarull/asyncqt), which is a fork of [quamash](https://github.com/harvimt/quamash). `qasync` was created because those are no longer maintained. May it live longer than its predecessors. **`qasync` will continue to be maintained, and will still be accepting pull requests.** ## Requirements -`qasync` requires Python >= 3.8, and PyQt5/PyQt6 or PySide2/PySide6. The library is tested on Ubuntu, Windows and MacOS. +- Python >= 3.8 +- PyQt5/PyQt6 or PySide2/PySide6 + +`qasync` is tested on Ubuntu, Windows and MacOS. If you need Python 3.6 or 3.7 support, use the [v0.25.0](https://github.com/CabbageDevelopment/qasync/releases/tag/v0.25.0) tag/release. diff --git a/examples/aiohttp_fetch.py b/examples/aiohttp_fetch.py index 2dc29f2..1fdcc4a 100644 --- a/examples/aiohttp_fetch.py +++ b/examples/aiohttp_fetch.py @@ -1,98 +1,82 @@ import asyncio -import functools import sys import aiohttp -# from PyQt5.QtWidgets import ( -from PySide2.QtWidgets import ( - QWidget, +# from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( + QApplication, QLabel, QLineEdit, - QTextEdit, QPushButton, + QTextEdit, QVBoxLayout, + QWidget, ) - -import qasync -from qasync import asyncSlot, asyncClose, QApplication +from qasync import QEventLoop, asyncClose, asyncSlot class MainWindow(QWidget): """Main window.""" - _DEF_URL = "https://jsonplaceholder.typicode.com/todos/1" - """str: Default URL.""" - - _SESSION_TIMEOUT = 1.0 - """float: Session timeout.""" + _DEF_URL: str = "https://jsonplaceholder.typicode.com/todos/1" + """Default URL.""" def __init__(self): super().__init__() self.setLayout(QVBoxLayout()) - self.lblStatus = QLabel("Idle", self) - self.layout().addWidget(self.lblStatus) + self.lbl_status = QLabel("Idle", self) + self.layout().addWidget(self.lbl_status) - self.editUrl = QLineEdit(self._DEF_URL, self) - self.layout().addWidget(self.editUrl) + self.edit_url = QLineEdit(self._DEF_URL, self) + self.layout().addWidget(self.edit_url) - self.editResponse = QTextEdit("", self) - self.layout().addWidget(self.editResponse) + self.edit_response = QTextEdit("", self) + self.layout().addWidget(self.edit_response) - self.btnFetch = QPushButton("Fetch", self) - self.btnFetch.clicked.connect(self.on_btnFetch_clicked) - self.layout().addWidget(self.btnFetch) + self.btn_fetch = QPushButton("Fetch", self) + self.btn_fetch.clicked.connect(self.on_btn_fetch_clicked) + self.layout().addWidget(self.btn_fetch) - self.session = aiohttp.ClientSession( - loop=asyncio.get_event_loop(), - timeout=aiohttp.ClientTimeout(total=self._SESSION_TIMEOUT), - ) + self.session: aiohttp.ClientSession @asyncClose - async def closeEvent(self, event): + async def closeEvent(self, event): # noqa:N802 await self.session.close() + async def boot(self): + self.session = aiohttp.ClientSession() + @asyncSlot() - async def on_btnFetch_clicked(self): - self.btnFetch.setEnabled(False) - self.lblStatus.setText("Fetching...") + async def on_btn_fetch_clicked(self): + self.btn_fetch.setEnabled(False) + self.lbl_status.setText("Fetching...") try: - async with self.session.get(self.editUrl.text()) as r: - self.editResponse.setText(await r.text()) + async with self.session.get(self.edit_url.text()) as r: + self.edit_response.setText(await r.text()) except Exception as exc: - self.lblStatus.setText("Error: {}".format(exc)) + self.lbl_status.setText("Error: {}".format(exc)) else: - self.lblStatus.setText("Finished!") + self.lbl_status.setText("Finished!") finally: - self.btnFetch.setEnabled(True) - - -async def main(): - def close_future(future, loop): - loop.call_later(10, future.cancel) - future.cancel() + self.btn_fetch.setEnabled(True) - loop = asyncio.get_event_loop() - future = asyncio.Future() - app = QApplication.instance() - if hasattr(app, "aboutToQuit"): - getattr(app, "aboutToQuit").connect( - functools.partial(close_future, future, loop) - ) - - mainWindow = MainWindow() - mainWindow.show() +if __name__ == "__main__": + app = QApplication(sys.argv) - await future - return True + event_loop = QEventLoop(app) + asyncio.set_event_loop(event_loop) + app_close_event = asyncio.Event() + app.aboutToQuit.connect(app_close_event.set) + + main_window = MainWindow() + main_window.show() -if __name__ == "__main__": - try: - qasync.run(main()) - except asyncio.exceptions.CancelledError: - sys.exit(0) + event_loop.create_task(main_window.boot()) + event_loop.run_until_complete(app_close_event.wait()) + event_loop.close() diff --git a/examples/executor_example.py b/examples/executor_example.py index 9e242a2..f208912 100644 --- a/examples/executor_example.py +++ b/examples/executor_example.py @@ -1,11 +1,10 @@ import functools -import sys import asyncio import time -import qasync +import sys -# from PyQt5.QtWidgets import ( -from PySide2.QtWidgets import QApplication, QProgressBar +# from PyQt6.QtWidgets import +from PySide6.QtWidgets import QApplication, QProgressBar from qasync import QEventLoop, QThreadExecutor @@ -32,4 +31,11 @@ def last_50(progress, loop): time.sleep(0.1) -qasync.run(master()) +if __name__ == "__main__": + app = QApplication(sys.argv) + + event_loop = QEventLoop(app) + asyncio.set_event_loop(event_loop) + + event_loop.run_until_complete(master()) + event_loop.close() diff --git a/examples/qml_httpx/app.py b/examples/qml_httpx/app.py new file mode 100644 index 0000000..f544cd9 --- /dev/null +++ b/examples/qml_httpx/app.py @@ -0,0 +1,41 @@ +import sys +import asyncio +from pathlib import Path + +from qasync import QEventLoop, QApplication +from PySide6.QtCore import QUrl +from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType + +from service import ExampleService + +QML_PATH = Path(__file__).parent.absolute().joinpath("qml") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + engine = QQmlApplicationEngine() + engine.addImportPath(QML_PATH) + + app.aboutToQuit.connect(engine.deleteLater) + engine.quit.connect(app.quit) + + # register our service, making it usable directly in QML + qmlRegisterType(ExampleService, "qasync", 1, 0, ExampleService.__name__) + + # alternatively, instantiate the service and inject it into the QML engine + # service = ExampleService() + # engine.rootContext().setContextProperty("service", service) + + event_loop = QEventLoop(app) + asyncio.set_event_loop(event_loop) + + app_close_event = asyncio.Event() + app.aboutToQuit.connect(app_close_event.set) + engine.quit.connect(app_close_event.set) + + qml_entry = QUrl.fromLocalFile(str(QML_PATH.joinpath("Main.qml"))) + engine.load(qml_entry) + + with event_loop: + event_loop.run_until_complete(app_close_event.wait()) diff --git a/examples/qml_httpx/qml/Main.qml b/examples/qml_httpx/qml/Main.qml new file mode 100644 index 0000000..85cf796 --- /dev/null +++ b/examples/qml_httpx/qml/Main.qml @@ -0,0 +1,18 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 + +ApplicationWindow { + id: root + title: "qasync" + visible: true + width: 420 + height: 240 + + Loader { + id: mainLoader + anchors.fill: parent + source: "Page.qml" + } +} diff --git a/examples/qml_httpx/qml/Page.qml b/examples/qml_httpx/qml/Page.qml new file mode 100644 index 0000000..1267da8 --- /dev/null +++ b/examples/qml_httpx/qml/Page.qml @@ -0,0 +1,65 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 + +Item { + ExampleService { + id: service + + // handle value changes inside the service object + onValueChanged: { + // use value + } + } + + Connections { + target: service + + // handle value changes with an external Connection + function onValueChanged(value) { + // use value + } + } + + ColumnLayout { + anchors { + fill: parent + margins: 10 + } + + RowLayout { + Layout.fillWidth: true + + Button { + id: button + Layout.preferredWidth: 100 + enabled: !service.isLoading + + text: { + return service.isLoading ? qsTr("Loading...") : qsTr("Fetch") + } + onClicked: function() { + service.fetch(url.text) + } + } + + TextField { + id: url + Layout.fillWidth: true + enabled: !service.isLoading + text: qsTr("https://jsonplaceholder.typicode.com/todos/1") + } + } + + TextEdit { + id: text + Layout.fillHeight: true + Layout.fillWidth: true + + // react to value changes from other widgets + text: service.value + } + } + +} diff --git a/examples/qml_httpx/service.py b/examples/qml_httpx/service.py new file mode 100644 index 0000000..3c2604f --- /dev/null +++ b/examples/qml_httpx/service.py @@ -0,0 +1,44 @@ +import httpx + +from qasync import asyncSlot +from PySide6.QtCore import QObject, Signal, Property, Slot + + +class ExampleService(QObject): + valueChanged = Signal(str, arguments=["value"]) + loadingChanged = Signal(bool, arguments=["loading"]) + + def __init__(self, parent=None): + QObject.__init__(self, parent) + + self._value = None + self._loading = False + + def _set_value(self, value): + if self._value != value: + self._value = value + self.valueChanged.emit(value) + + def _set_loading(self, value): + if self._loading != value: + self._loading = value + self.loadingChanged.emit(value) + + @Property(str, notify=valueChanged) + def value(self) -> str: + return self._value + + @Property(bool, notify=loadingChanged) + def isLoading(self) -> bool: + return self._loading + + @asyncSlot(str) + async def fetch(self, endpoint: str): + if not endpoint: + return + + self._set_loading(True) + async with httpx.AsyncClient() as client: + resp = await client.get(endpoint) + self._set_value(resp.text) + self._set_loading(False) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index c1de714..68ba88b 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -151,6 +151,8 @@ async def mycoro(): 'print("Hello async world!")', stdout=subprocess.PIPE, ) + if process.stdout is None: + raise Exception("Output from the process is none") received_stdout = await process.stdout.readexactly(len(b"Hello async world!\n")) await process.wait() assert process.returncode == 0 @@ -589,8 +591,8 @@ def cb3(): loop._add_reader(c_sock.fileno(), cb1) - clent_task = asyncio.ensure_future(client_coro()) - server_task = asyncio.ensure_future(server_coro()) + _client_task = asyncio.ensure_future(client_coro()) + _server_task = asyncio.ensure_future(server_coro()) both_done = asyncio.gather(client_done, server_done) loop.run_until_complete(asyncio.wait_for(both_done, timeout=1.0)) @@ -797,4 +799,5 @@ def teardown_module(module): for logger in loggers: handlers = getattr(logger, "handlers", []) for handler in handlers: - logger.removeHandler(handler) + if isinstance(logger, logging.Logger): + logger.removeHandler(handler)