├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── docs ├── changelog.rst ├── conf.py ├── examples │ ├── __init__.py │ ├── cats │ │ └── .gitignore │ ├── exception.py │ ├── explanation_async.py │ ├── explanation_async_parallel.py │ ├── explanation_sync.py │ ├── explanation_thread.py │ ├── images │ │ └── explanation1.png │ ├── simple.py │ └── tests │ │ ├── conftest.py │ │ ├── test_explanation_async.py │ │ ├── test_explanation_async_parallel.py │ │ ├── test_explanation_sync.py │ │ ├── test_explanation_thread.py │ │ └── test_simple.py ├── index.rst ├── license.rst ├── reference.rst ├── requirements.txt ├── testing.rst └── tutorial.rst ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── qt_async_threads │ ├── __init__.py │ ├── _async_runner_abc.py │ ├── _qt_async_runner.py │ ├── _sequential_runner.py │ ├── py.typed │ └── pytest_plugin.py ├── tests ├── __init__.py ├── test_async_tester.py ├── test_qt_async_runner.py ├── test_sequential_runner.py └── testing.py └── tox.ini /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | 10 | deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write # For PyPI trusted publishers. 14 | contents: write # For release notes. 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install build 28 | 29 | - name: Build package 30 | run: | 31 | python -m build 32 | 33 | - name: Publish package to PyPI 34 | uses: pypa/gh-action-pypi-publish@v1.8.5 35 | 36 | - name: GitHub Release 37 | uses: softprops/action-gh-release@v1 38 | with: 39 | files: dist/* 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python: ["3.10", "3.11"] 19 | qt-lib: ["pyqt5", "pyqt6", "pyside2", "pyside6"] 20 | os: [ubuntu-latest, windows-latest] 21 | include: 22 | - python: "3.10" 23 | tox-env: "py310" 24 | - python: "3.11" 25 | tox-env: "py311" 26 | exclude: 27 | - python: "3.11" 28 | qt-lib: "pyside2" 29 | 30 | steps: 31 | - uses: actions/checkout@v1 32 | - name: Set up Python 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - name: Install mesa 37 | if: runner.os == 'Linux' 38 | run: | 39 | sudo apt-get update -y 40 | sudo apt-get install -y libgles2-mesa-dev 41 | shell: bash 42 | - name: Install tox 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install tox 46 | - name: Test 47 | run: | 48 | tox -e ${{ matrix.tox-env }}-${{ matrix.qt-lib }} -- -ra --color=yes 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.10 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 25.1.0 6 | hooks: 7 | - id: black 8 | - repo: https://github.com/asottile/blacken-docs 9 | rev: 1.19.1 10 | hooks: 11 | - id: blacken-docs 12 | additional_dependencies: [black==23.7.0] 13 | - repo: https://github.com/asottile/reorder-python-imports 14 | rev: v3.15.0 15 | hooks: 16 | - id: reorder-python-imports 17 | name: "reorder python imports" 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v5.0.0 20 | hooks: 21 | - id: trailing-whitespace 22 | - id: end-of-file-fixer 23 | - id: check-yaml 24 | - id: check-merge-conflict 25 | - repo: https://github.com/PyCQA/autoflake 26 | rev: v2.3.1 27 | hooks: 28 | - id: autoflake 29 | name: autoflake 30 | args: ["--in-place", "--remove-unused-variables", "--remove-all-unused-imports"] 31 | language: python 32 | files: \.py$ 33 | - repo: https://github.com/pre-commit/mirrors-mypy 34 | rev: v1.16.0 35 | hooks: 36 | - id: mypy 37 | files: ^(src/|tests/|docs/examples/) 38 | args: [] 39 | additional_dependencies: 40 | - attrs 41 | - tomli 42 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | # Required 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | python: "3.10" 10 | 11 | # Build documentation in the docs/ directory with Sphinx 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | - method: pip 19 | path: . 20 | extra_requirements: 21 | - dev 22 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.6.0 2 | ----- 3 | 4 | - Now ``QtAsyncRunner.close()`` will cause all running coroutines to never be called back into the main thread. 5 | 6 | Letting coroutines resume into the main thread after ``close()`` 7 | has been called can be problematic, specially in tests, as ``close()`` is often called during test teardown. 8 | If the user missed to properly wait on a coroutine during the test, what can 9 | happen is that the coroutine will resume (when the thread finishes), possibly after resources have already 10 | been cleared, specially widgets. 11 | 12 | Dropping seems harsh, but follows what other libraries like ``asyncio`` do when faced with the same 13 | situation. 14 | 15 | We might consider adding a ``wait`` flag or something like that in the future to instead of cancelling the coroutines, 16 | wait for them. 17 | 18 | - ``AsyncTester.start_and_wait()`` now receives an optional ``timeout_s`` parameter, which overwrites 19 | ``AsyncTester.timeout_s``. 20 | 21 | 0.5.2 22 | ----- 23 | 24 | - New attribute ``AsyncTester.timeout_s``, with the timeout in seconds until ``start_and_wait`` 25 | raises ``TimeoutError``. 26 | 27 | 0.4.0 28 | ----- 29 | 30 | - Support `PyQt5`_, `PyQt6`_, `PySide2`_, and `PySide6`_ thanks to `qtpy`_. 31 | - Support Python 3.11. 32 | 33 | .. _PyQt5: https://pypi.org/project/PyQt5/ 34 | .. _PyQt6: https://pypi.org/project/PyQt6/ 35 | .. _PySide2: https://pypi.org/project/PySide2/ 36 | .. _PySide6: https://pypi.org/project/PySide6/ 37 | .. _qtpy: https://pypi.org/project/qtpy/ 38 | 39 | 0.3.1 40 | ----- 41 | 42 | - Relax requirements for PyQt to ``>=5.12``. 43 | 44 | 0.3.0 45 | ----- 46 | 47 | - Added missing ``py.typed`` file, enabling type-checking. 48 | 49 | 0.2.1 50 | ----- 51 | 52 | - Fixed small linting issues, automatic deploy. 53 | 54 | 0.1 55 | --- 56 | 57 | First release. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Bruno Oliveira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | qt-async-threads 3 | ================ 4 | 5 | .. image:: https://img.shields.io/pypi/v/qt-async-threads.svg 6 | :target: https://pypi.org/project/qt-async-threads/ 7 | 8 | .. image:: https://img.shields.io/conda/vn/conda-forge/qt-async-threads.svg 9 | :target: https://anaconda.org/conda-forge/qt-async-threads 10 | 11 | .. image:: https://img.shields.io/pypi/pyversions/qt-async-threads.svg 12 | :target: https://pypi.org/project/qt-async-threads/ 13 | 14 | .. image:: https://github.com/nicoddemus/qt-async-threads/workflows/test/badge.svg 15 | :target: https://github.com/nicoddemus/qt-async-threads/actions?query=workflow%3Atest 16 | 17 | .. image:: https://results.pre-commit.ci/badge/github/nicoddemus/qt-async-threads/main.svg 18 | :target: https://results.pre-commit.ci/latest/github/nicoddemus/qt-async-threads/main 19 | :alt: pre-commit.ci status 20 | 21 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 22 | :target: https://github.com/psf/black 23 | 24 | .. image:: https://readthedocs.org/projects/qt-async-threads/badge/?version=latest 25 | :target: https://qt-async-threads.readthedocs.io/en/latest/?badge=latest 26 | 27 | ---- 28 | 29 | ``qt-async-threads`` allows Qt applications to use convenient ``async/await`` syntax to run 30 | computational intensive or IO operations in threads, selectively changing the code slightly 31 | to provide a more responsive UI. 32 | 33 | The objective of this library is to provide a simple and convenient way to improve 34 | UI responsiveness in existing Qt applications by using ``async/await``, while 35 | at the same time not requiring large scale refactorings. 36 | 37 | Supports `PyQt5`_, `PyQt6`_, `PySide2`_, and `PySide6`_ thanks to `qtpy`_. 38 | 39 | Example 40 | ======= 41 | 42 | The widget below downloads pictures of cats when the user clicks on a button (some parts omitted for brevity): 43 | 44 | .. code-block:: python 45 | 46 | class CatsWidget(QWidget): 47 | def __init__(self, parent: QWidget) -> None: 48 | ... 49 | self.download_button.clicked.connect(self._on_download_button_clicked) 50 | 51 | def _on_download_button_clicked(self, checked: bool = False) -> None: 52 | self.progress_label.setText("Searching...") 53 | 54 | api_url = "https://api.thecatapi.com/v1/images/search" 55 | 56 | for i in range(10): 57 | try: 58 | # Search. 59 | search_response = requests.get(api_url) 60 | self.progress_label.setText("Found, downloading...") 61 | 62 | # Download. 63 | url = search_response.json()[0]["url"] 64 | download_response = requests.get(url) 65 | except ConnectionError as e: 66 | QMessageBox.critical(self, "Error", f"Error: {e}") 67 | return 68 | 69 | self._save_image_file(download_response) 70 | self.progress_label.setText(f"Done downloading image {i}.") 71 | 72 | self.progress_label.setText(f"Done, {downloaded_count} cats downloaded") 73 | 74 | 75 | This works well, but while the pictures are being downloaded the UI will freeze a bit, 76 | becoming unresponsive. 77 | 78 | With ``qt-async-threads``, we can easily change the code to: 79 | 80 | .. code-block:: python 81 | 82 | class CatsWidget(QWidget): 83 | def __init__(self, runner: QtAsyncRunner, parent: QWidget) -> None: 84 | ... 85 | # QtAsyncRunner allows us to submit code to threads, and 86 | # provide a way to connect async functions to Qt slots. 87 | self.runner = runner 88 | 89 | # `to_sync` returns a slot that Qt's signals can call, but will 90 | # allow it to asynchronously run code in threads. 91 | self.download_button.clicked.connect( 92 | self.runner.to_sync(self._on_download_button_clicked) 93 | ) 94 | 95 | async def _on_download_button_clicked(self, checked: bool = False) -> None: 96 | self.progress_label.setText("Searching...") 97 | 98 | api_url = "https://api.thecatapi.com/v1/images/search" 99 | 100 | for i in range(10): 101 | try: 102 | # Search. 103 | # `self.runner.run` calls requests.get() in a thread, 104 | # but without blocking the main event loop. 105 | search_response = await self.runner.run(requests.get, api_url) 106 | self.progress_label.setText("Found, downloading...") 107 | 108 | # Download. 109 | url = search_response.json()[0]["url"] 110 | download_response = await self.runner.run(requests.get, url) 111 | except ConnectionError as e: 112 | QMessageBox.critical(self, "Error", f"Error: {e}") 113 | return 114 | 115 | self._save_image_file(download_response) 116 | self.progress_label.setText(f"Done downloading image {i}.") 117 | 118 | self.progress_label.setText(f"Done, {downloaded_count} cats downloaded") 119 | 120 | By using a `QtAsyncRunner`_ instance and changing the slot to an ``async`` function, the ``runner.run`` calls 121 | will run the requests in a thread, without blocking the Qt event loop, making the UI snappy and responsive. 122 | 123 | Thanks to the ``async``/``await`` syntax, we can keep the entire flow in the same function as before, 124 | including handling exceptions naturally. 125 | 126 | We could rewrite the first example using a `ThreadPoolExecutor`_ or `QThreads`_, 127 | but that would require a significant rewrite. 128 | 129 | 130 | 131 | Documentation 132 | ============= 133 | 134 | For full documentation, please see https://qt-async-threads.readthedocs.io/en/latest. 135 | 136 | Differences with other libraries 137 | ================================ 138 | 139 | There are excellent libraries that allow to use async frameworks with Qt: 140 | 141 | * `qasync`_ integrates with `asyncio`_ 142 | * `qtrio`_ integrates with `trio`_ 143 | 144 | Those libraries fully integrate with their respective frameworks, allowing the application to asynchronously communicate 145 | with sockets, threads, file system, tasks, cancellation systems, use other async libraries 146 | (such as `httpx`_), etc. 147 | 148 | They are very powerful in their own right, however they have one downside in that they require your ``main`` 149 | entry point to also be ``async``, which might be hard to accommodate in an existing application. 150 | 151 | ``qt-async-threads``, on the other hand, focuses only on one feature: allow the user to leverage ``async``/``await`` 152 | syntax to *handle threads more naturally*, without the need for major refactorings in existing applications. 153 | 154 | License 155 | ======= 156 | 157 | Distributed under the terms of the `MIT`_ license. 158 | 159 | .. _MIT: https://github.com/pytest-dev/pytest-mock/blob/master/LICENSE 160 | .. _PyQt5: https://pypi.org/project/PyQt5/ 161 | .. _PyQt6: https://pypi.org/project/PyQt6/ 162 | .. _PySide2: https://pypi.org/project/PySide2/ 163 | .. _PySide6: https://pypi.org/project/PySide6/ 164 | .. _QThreads: https://doc.qt.io/qt-5/qthread.html 165 | .. _QtAsyncRunner: https://qt-async-threads.readthedocs.io/en/latest/reference.html#qt_async_threads.QtAsyncRunner 166 | .. _ThreadPoolExecutor: https://docs.python.org/3/library/concurrent.futures.html#threadpoolexecutor 167 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 168 | .. _httpx: https://www.python-httpx.org 169 | .. _qasync: https://pypi.org/project/qasync 170 | .. _qtpy: https://pypi.org/project/qtpy/ 171 | .. _qtrio: https://pypi.org/project/qtrio 172 | .. _trio: https://pypi.org/project/trio 173 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | .. include:: ../CHANGELOG.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | # import os 12 | # import sys 13 | # sys.path.insert(0, os.path.abspath('.')) 14 | # -- Project information ----------------------------------------------------- 15 | 16 | project = "qt-async-threads" 17 | copyright = "2022, Bruno Oliveira" 18 | author = "Bruno Oliveira" 19 | 20 | 21 | # -- General configuration --------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be 24 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 25 | # ones. 26 | extensions = [ 27 | "sphinx.ext.autodoc", 28 | "sphinx_copybutton", 29 | ] 30 | 31 | autodoc_member_order = "bysource" 32 | autodoc_typehints = "description" 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # List of patterns, relative to source directory, that match files and 38 | # directories to ignore when looking for source files. 39 | # This pattern also affects html_static_path and html_extra_path. 40 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 41 | 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | 45 | # The theme to use for HTML and HTML Help pages. See the documentation for 46 | # a list of builtin themes. 47 | # 48 | html_theme = "furo" 49 | 50 | # Add any paths that contain custom static files (such as style sheets) here, 51 | # relative to this directory. They are copied after the builtin static files, 52 | # so a file named "default.css" will overwrite the builtin "default.css". 53 | html_static_path = ["_static"] 54 | 55 | intersphinx_mapping = { 56 | "pytestqt": ("https://pytest-qt.readthedocs.io/en/latest", None), 57 | } 58 | -------------------------------------------------------------------------------- /docs/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicoddemus/qt-async-threads/f36a9a51ab9a1795098df3742f8943cf13ea574d/docs/examples/__init__.py -------------------------------------------------------------------------------- /docs/examples/cats/.gitignore: -------------------------------------------------------------------------------- 1 | *.jpg 2 | *.gif 3 | *.png 4 | -------------------------------------------------------------------------------- /docs/examples/exception.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | 4 | 5 | def install_except_hook() -> None: 6 | def excepthook(exc_type: object, exc_value: BaseException, exc_tb: object) -> None: 7 | traceback.print_exception(exc_value) 8 | 9 | sys.excepthook = excepthook 10 | -------------------------------------------------------------------------------- /docs/examples/explanation_async.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlsplit 3 | 4 | import requests 5 | from examples.exception import install_except_hook 6 | from qt_async_threads import QtAsyncRunner 7 | from qtpy.QtWidgets import QApplication 8 | from qtpy.QtWidgets import QFormLayout 9 | from qtpy.QtWidgets import QLabel 10 | from qtpy.QtWidgets import QMessageBox 11 | from qtpy.QtWidgets import QPushButton 12 | from qtpy.QtWidgets import QSpinBox 13 | from qtpy.QtWidgets import QWidget 14 | from requests.exceptions import ConnectionError 15 | 16 | 17 | class Window(QWidget): 18 | def __init__(self, directory: Path, runner: QtAsyncRunner) -> None: 19 | super().__init__() 20 | self.runner = runner 21 | 22 | self.setWindowTitle("Cat Downloader") 23 | self.directory = directory 24 | self._cancelled = False 25 | 26 | # Build controls. 27 | self.count_spin = QSpinBox() 28 | self.count_spin.setValue(5) 29 | self.count_spin.setMinimum(1) 30 | self.progress_label = QLabel("Idle, click below to start downloading") 31 | self.download_button = QPushButton("Download") 32 | self.stop_button = QPushButton("Stop") 33 | self.stop_button.setEnabled(False) 34 | 35 | layout = QFormLayout(self) 36 | layout.addRow("How many cats?", self.count_spin) 37 | layout.addRow("Status", self.progress_label) 38 | layout.addRow(self.download_button) 39 | layout.addRow(self.stop_button) 40 | 41 | # Connect signals. 42 | self.download_button.clicked.connect(self.runner.to_sync(self.on_download_button_clicked)) 43 | self.stop_button.clicked.connect(self.on_cancel_button_clicked) 44 | 45 | async def on_download_button_clicked(self, checked: bool = False) -> None: 46 | self.progress_label.setText("Searching...") 47 | self.download_button.setEnabled(False) 48 | self.stop_button.setEnabled(True) 49 | 50 | self._cancelled = False 51 | downloaded_count = 0 52 | try: 53 | for i in range(self.count_spin.value()): 54 | try: 55 | # Search. 56 | search_response = await self.runner.run( 57 | requests.get, "https://api.thecatapi.com/v1/images/search" 58 | ) 59 | search_response.raise_for_status() 60 | 61 | # Download. 62 | url = search_response.json()[0]["url"] 63 | download_response = await self.runner.run(requests.get, url) 64 | except ConnectionError as e: 65 | QMessageBox.critical(self, "Error", f"Error connecting to TheCatApi:\n{e}") 66 | return 67 | 68 | # Save the contents of the image to a file. 69 | parts = urlsplit(url) 70 | path = self.directory / f"{i:02d}_cat{Path(parts.path).suffix}" 71 | path.write_bytes(download_response.content) 72 | downloaded_count += 1 73 | 74 | # Show progress. 75 | self.progress_label.setText(f"Downloaded {path.name}") 76 | QApplication.processEvents() 77 | if self._cancelled: 78 | QMessageBox.information(self, "Cancelled", "Download cancelled") 79 | break 80 | finally: 81 | self.progress_label.setText(f"Done, {downloaded_count} cats downloaded") 82 | self.download_button.setEnabled(True) 83 | self.stop_button.setEnabled(False) 84 | 85 | def on_cancel_button_clicked(self) -> None: 86 | self._cancelled = True 87 | 88 | 89 | if __name__ == "__main__": 90 | install_except_hook() 91 | with QtAsyncRunner() as runner: 92 | app = QApplication([]) 93 | win = Window(Path(__file__).parent / "cats", runner) 94 | win.show() 95 | app.exec() 96 | -------------------------------------------------------------------------------- /docs/examples/explanation_async_parallel.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlsplit 3 | 4 | import requests 5 | from examples.exception import install_except_hook 6 | from qt_async_threads import QtAsyncRunner 7 | from qtpy.QtWidgets import QApplication 8 | from qtpy.QtWidgets import QFormLayout 9 | from qtpy.QtWidgets import QLabel 10 | from qtpy.QtWidgets import QMessageBox 11 | from qtpy.QtWidgets import QPushButton 12 | from qtpy.QtWidgets import QSpinBox 13 | from qtpy.QtWidgets import QWidget 14 | from requests import Response 15 | from requests.exceptions import ConnectionError 16 | 17 | 18 | class Window(QWidget): 19 | def __init__(self, directory: Path, runner: QtAsyncRunner) -> None: 20 | super().__init__() 21 | self.runner = runner 22 | 23 | self.setWindowTitle("Cat Downloader") 24 | self.directory = directory 25 | self._cancelled = False 26 | 27 | # Build controls. 28 | self.count_spin = QSpinBox() 29 | self.count_spin.setValue(5) 30 | self.count_spin.setMinimum(1) 31 | self.progress_label = QLabel("Idle, click below to start downloading") 32 | self.download_button = QPushButton("Download") 33 | self.stop_button = QPushButton("Stop") 34 | self.stop_button.setEnabled(False) 35 | 36 | layout = QFormLayout(self) 37 | layout.addRow("How many cats?", self.count_spin) 38 | layout.addRow("Status", self.progress_label) 39 | layout.addRow(self.download_button) 40 | layout.addRow(self.stop_button) 41 | 42 | # Connect signals. 43 | self.download_button.clicked.connect(self.runner.to_sync(self.on_download_button_clicked)) 44 | self.stop_button.clicked.connect(self.on_cancel_button_clicked) 45 | 46 | async def on_download_button_clicked(self, checked: bool = False) -> None: 47 | self.progress_label.setText("Searching...") 48 | self.download_button.setEnabled(False) 49 | self.stop_button.setEnabled(True) 50 | 51 | self._cancelled = False 52 | downloaded_count = 0 53 | 54 | def download_one() -> Response: 55 | # Search. 56 | search_response = requests.get("https://api.thecatapi.com/v1/images/search") 57 | search_response.raise_for_status() 58 | 59 | # Download. 60 | url = search_response.json()[0]["url"] 61 | return requests.get(url) 62 | 63 | try: 64 | functions = [download_one for _ in range(self.count_spin.value())] 65 | async for download_response in self.runner.run_parallel(functions): 66 | # Save the contents of the image to a file. 67 | parts = urlsplit(download_response.url) 68 | path = self.directory / f"{downloaded_count:02d}_cat{Path(parts.path).suffix}" 69 | path.write_bytes(download_response.content) 70 | downloaded_count += 1 71 | 72 | # Show progress. 73 | self.progress_label.setText(f"Downloaded {path.name}") 74 | QApplication.processEvents() 75 | if self._cancelled: 76 | QMessageBox.information(self, "Cancelled", "Download cancelled") 77 | return 78 | except ConnectionError as e: 79 | QMessageBox.critical(self, "Error", f"Error connecting to TheCatApi:\n{e}") 80 | return 81 | finally: 82 | self.progress_label.setText(f"Done, {downloaded_count} cats downloaded") 83 | self.download_button.setEnabled(True) 84 | self.stop_button.setEnabled(False) 85 | 86 | def on_cancel_button_clicked(self) -> None: 87 | self._cancelled = True 88 | 89 | 90 | if __name__ == "__main__": 91 | install_except_hook() 92 | with QtAsyncRunner() as runner: 93 | app = QApplication([]) 94 | win = Window(Path(__file__).parent / "cats", runner) 95 | win.show() 96 | app.exec() 97 | -------------------------------------------------------------------------------- /docs/examples/explanation_sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlsplit 3 | 4 | import requests 5 | from examples.exception import install_except_hook 6 | from qtpy.QtWidgets import QApplication 7 | from qtpy.QtWidgets import QFormLayout 8 | from qtpy.QtWidgets import QLabel 9 | from qtpy.QtWidgets import QMessageBox 10 | from qtpy.QtWidgets import QPushButton 11 | from qtpy.QtWidgets import QSpinBox 12 | from qtpy.QtWidgets import QWidget 13 | from requests.exceptions import ConnectionError 14 | 15 | 16 | class Window(QWidget): 17 | def __init__(self, directory: Path) -> None: 18 | super().__init__() 19 | 20 | self.setWindowTitle("Cat Downloader") 21 | self.directory = directory 22 | self._cancelled = False 23 | 24 | # Build controls. 25 | self.count_spin = QSpinBox() 26 | self.count_spin.setValue(5) 27 | self.count_spin.setMinimum(1) 28 | self.progress_label = QLabel("Idle, click below to start downloading") 29 | self.download_button = QPushButton("Download") 30 | self.stop_button = QPushButton("Stop") 31 | self.stop_button.setEnabled(False) 32 | 33 | layout = QFormLayout(self) 34 | layout.addRow("How many cats?", self.count_spin) 35 | layout.addRow("Status", self.progress_label) 36 | layout.addRow(self.download_button) 37 | layout.addRow(self.stop_button) 38 | 39 | # Connect signals. 40 | self.download_button.clicked.connect(self.on_download_button_clicked) 41 | self.stop_button.clicked.connect(self.on_cancel_button_clicked) 42 | 43 | def on_download_button_clicked(self, checked: bool = False) -> None: 44 | self.progress_label.setText("Searching...") 45 | self.download_button.setEnabled(False) 46 | self.stop_button.setEnabled(True) 47 | 48 | self._cancelled = False 49 | downloaded_count = 0 50 | try: 51 | for i in range(self.count_spin.value()): 52 | try: 53 | # Search. 54 | search_response = requests.get("https://api.thecatapi.com/v1/images/search") 55 | search_response.raise_for_status() 56 | 57 | # Download. 58 | url = search_response.json()[0]["url"] 59 | download_response = requests.get(url) 60 | except ConnectionError as e: 61 | QMessageBox.critical(self, "Error", f"Error connecting to TheCatApi:\n{e}") 62 | return 63 | 64 | # Save the contents of the image to a file. 65 | parts = urlsplit(url) 66 | path = self.directory / f"{i:02d}_cat{Path(parts.path).suffix}" 67 | path.write_bytes(download_response.content) 68 | downloaded_count += 1 69 | 70 | # Show progress. 71 | self.progress_label.setText(f"Downloaded {path.name}") 72 | QApplication.processEvents() 73 | if self._cancelled: 74 | QMessageBox.information(self, "Cancelled", "Download cancelled") 75 | break 76 | finally: 77 | self.progress_label.setText(f"Done, {downloaded_count} cats downloaded") 78 | self.download_button.setEnabled(True) 79 | self.stop_button.setEnabled(False) 80 | 81 | def on_cancel_button_clicked(self) -> None: 82 | self._cancelled = True 83 | 84 | 85 | if __name__ == "__main__": 86 | install_except_hook() 87 | app = QApplication([]) 88 | win = Window(Path(__file__).parent / "cats") 89 | win.show() 90 | app.exec() 91 | -------------------------------------------------------------------------------- /docs/examples/explanation_thread.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlsplit 3 | 4 | import requests 5 | from examples.exception import install_except_hook 6 | from qtpy.QtCore import pyqtSignal 7 | from qtpy.QtCore import QObject 8 | from qtpy.QtCore import QThread 9 | from qtpy.QtWidgets import QApplication 10 | from qtpy.QtWidgets import QFormLayout 11 | from qtpy.QtWidgets import QLabel 12 | from qtpy.QtWidgets import QMessageBox 13 | from qtpy.QtWidgets import QPushButton 14 | from qtpy.QtWidgets import QSpinBox 15 | from qtpy.QtWidgets import QWidget 16 | from requests.exceptions import ConnectionError 17 | 18 | 19 | class Window(QWidget): 20 | def __init__(self, directory: Path) -> None: 21 | super().__init__() 22 | 23 | self.setWindowTitle("Cat Downloader") 24 | self.directory = directory 25 | self._thread: DownloadThread | None = None 26 | 27 | # Build controls. 28 | self.count_spin = QSpinBox() 29 | self.count_spin.setValue(5) 30 | self.count_spin.setMinimum(1) 31 | self.progress_label = QLabel("Idle, click below to start downloading") 32 | self.download_button = QPushButton("Download") 33 | self.stop_button = QPushButton("Stop") 34 | self.stop_button.setEnabled(False) 35 | 36 | layout = QFormLayout(self) 37 | layout.addRow("How many cats?", self.count_spin) 38 | layout.addRow("Status", self.progress_label) 39 | layout.addRow(self.download_button) 40 | layout.addRow(self.stop_button) 41 | 42 | # Connect signals. 43 | self.download_button.clicked.connect(self.on_download_button_clicked) 44 | self.stop_button.clicked.connect(self.on_cancel_button_clicked) 45 | 46 | def on_download_button_clicked(self, checked: bool = False) -> None: 47 | self.progress_label.setText("Searching...") 48 | self.download_button.setEnabled(False) 49 | self.stop_button.setEnabled(True) 50 | 51 | self._thread = DownloadThread(self.count_spin.value(), self) 52 | self._thread.downloaded_signal.connect(self.on_downloaded) 53 | self._thread.finished.connect(self.on_download_finished) 54 | self._thread.start() 55 | 56 | def on_downloaded(self, index: int, name: str, data: bytes) -> None: 57 | # Save the contents of the image to a file. 58 | path = self.directory / f"{index:02d}_cat{Path(name).suffix}" 59 | path.write_bytes(data) 60 | 61 | # Show progress. 62 | self.progress_label.setText(f"Downloaded {name}") 63 | 64 | def on_download_finished(self) -> None: 65 | assert self._thread is not None 66 | if self._thread.cancelled: 67 | QMessageBox.information(self, "Cancelled", "Download cancelled") 68 | elif self._thread.error is not None: 69 | msg = f"Error connecting to TheCatApi:\n{self._thread.error}" 70 | QMessageBox.critical(self, "Error", msg) 71 | 72 | self.progress_label.setText(f"Done, {self._thread.downloaded_count} cats downloaded") 73 | self.download_button.setEnabled(True) 74 | self.stop_button.setEnabled(False) 75 | 76 | def on_cancel_button_clicked(self) -> None: 77 | assert self._thread is not None 78 | self._thread.cancelled = True 79 | 80 | 81 | class DownloadThread(QThread): 82 | # Signal emitted when a cat image has been downloaded. 83 | # Arguments: index, basename, image data 84 | downloaded_signal = pyqtSignal(int, str, bytes) 85 | 86 | def __init__(self, cat_count: int, parent: QObject) -> None: 87 | super().__init__(parent) 88 | self.cat_count = cat_count 89 | self.downloaded_count = 0 90 | self.cancelled = False 91 | self.error: str | None = None 92 | 93 | def run(self) -> None: 94 | """Executes this code in a separate thread, as to not block the main thread.""" 95 | for i in range(self.cat_count): 96 | try: 97 | # Search. 98 | search_response = requests.get("https://api.thecatapi.com/v1/images/search") 99 | search_response.raise_for_status() 100 | 101 | # Download. 102 | url = search_response.json()[0]["url"] 103 | download_response = requests.get(url) 104 | except ConnectionError as e: 105 | self.error = str(e) 106 | return 107 | 108 | parts = urlsplit(url) 109 | self.downloaded_signal.emit(i, Path(parts.path).name, download_response.content) 110 | self.downloaded_count += 1 111 | 112 | if self.cancelled: 113 | return 114 | 115 | 116 | if __name__ == "__main__": 117 | install_except_hook() 118 | app = QApplication([]) 119 | win = Window(Path(__file__).parent / "cats") 120 | win.show() 121 | app.exec() 122 | -------------------------------------------------------------------------------- /docs/examples/images/explanation1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicoddemus/qt-async-threads/f36a9a51ab9a1795098df3742f8943cf13ea574d/docs/examples/images/explanation1.png -------------------------------------------------------------------------------- /docs/examples/simple.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from examples.exception import install_except_hook 3 | from qt_async_threads import AbstractAsyncRunner 4 | from qt_async_threads import QtAsyncRunner 5 | from qtpy.QtCore import Qt 6 | from qtpy.QtWidgets import QApplication 7 | from qtpy.QtWidgets import QLabel 8 | from qtpy.QtWidgets import QPushButton 9 | from qtpy.QtWidgets import QVBoxLayout 10 | from qtpy.QtWidgets import QWidget 11 | 12 | 13 | class Window(QWidget): 14 | def __init__(self, runner: AbstractAsyncRunner) -> None: 15 | super().__init__() 16 | self.setWindowTitle("Cat Finder") 17 | self.runner = runner 18 | self.results_label = QLabel("Idle") 19 | self.results_label.setTextFormat(Qt.MarkdownText) 20 | self.results_label.setOpenExternalLinks(True) 21 | self.search_button = QPushButton("Search") 22 | 23 | layout = QVBoxLayout(self) 24 | layout.addWidget(self.results_label) 25 | layout.addWidget(self.search_button) 26 | 27 | self.search_button.clicked.connect(self.runner.to_sync(self._on_search_button_clicked)) 28 | 29 | async def _on_search_button_clicked(self, *args: object) -> None: 30 | response = await self.runner.run(requests.get, "https://api.thecatapi.com/v1/images/search") 31 | url = response.json()[0]["url"] 32 | self.results_label.setText(f"Found a cat! [click to open]({url})") 33 | 34 | 35 | if __name__ == "__main__": 36 | install_except_hook() 37 | app = QApplication([]) 38 | with QtAsyncRunner() as runner: 39 | win = Window(runner) 40 | win.show() 41 | app.exec() 42 | -------------------------------------------------------------------------------- /docs/examples/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import pytest 4 | from pytestqt.qtbot import QtBot 5 | from qt_async_threads import QtAsyncRunner 6 | 7 | 8 | @pytest.fixture 9 | def runner(qtbot: QtBot) -> Iterator[QtAsyncRunner]: 10 | with QtAsyncRunner() as runner: 11 | yield runner 12 | -------------------------------------------------------------------------------- /docs/examples/tests/test_explanation_async.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from examples.explanation_async import Window 4 | from pytestqt.qtbot import QtBot 5 | from qt_async_threads import QtAsyncRunner 6 | 7 | 8 | def test_explanation_async(qtbot: QtBot, tmp_path: Path, runner: QtAsyncRunner) -> None: 9 | win = Window(tmp_path, runner) 10 | win.directory = tmp_path 11 | qtbot.addWidget(win) 12 | 13 | win.count_spin.setValue(1) 14 | win.download_button.click() 15 | qtbot.waitUntil(runner.is_idle) 16 | assert win.progress_label.text() == "Done, 1 cats downloaded" 17 | assert len(list(tmp_path.iterdir())) == 1 18 | -------------------------------------------------------------------------------- /docs/examples/tests/test_explanation_async_parallel.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from examples.explanation_async_parallel import Window 4 | from pytestqt.qtbot import QtBot 5 | from qt_async_threads import QtAsyncRunner 6 | 7 | 8 | def test_explanation_async_parallel(qtbot: QtBot, tmp_path: Path, runner: QtAsyncRunner) -> None: 9 | win = Window(tmp_path, runner) 10 | win.directory = tmp_path 11 | qtbot.addWidget(win) 12 | 13 | win.count_spin.setValue(2) 14 | win.download_button.click() 15 | qtbot.waitUntil(runner.is_idle) 16 | assert win.progress_label.text() == "Done, 2 cats downloaded" 17 | assert len(list(tmp_path.iterdir())) == 2 18 | -------------------------------------------------------------------------------- /docs/examples/tests/test_explanation_sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from examples.explanation_sync import Window 4 | from pytestqt.qtbot import QtBot 5 | 6 | 7 | def test_explanation_sync(qtbot: QtBot, tmp_path: Path) -> None: 8 | win = Window(tmp_path) 9 | win.directory = tmp_path 10 | qtbot.addWidget(win) 11 | 12 | win.count_spin.setValue(1) 13 | win.download_button.click() 14 | assert win.progress_label.text() == "Done, 1 cats downloaded" 15 | assert len(list(tmp_path.iterdir())) == 1 16 | -------------------------------------------------------------------------------- /docs/examples/tests/test_explanation_thread.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from examples.explanation_sync import Window 4 | from pytestqt.qtbot import QtBot 5 | 6 | 7 | def test_explanation_thread(qtbot: QtBot, tmp_path: Path) -> None: 8 | win = Window(tmp_path) 9 | win.directory = tmp_path 10 | qtbot.addWidget(win) 11 | 12 | win.count_spin.setValue(1) 13 | win.download_button.click() 14 | 15 | def check() -> None: 16 | assert win.progress_label.text() == "Done, 1 cats downloaded" 17 | 18 | qtbot.waitUntil(check) 19 | assert len(list(tmp_path.iterdir())) == 1 20 | -------------------------------------------------------------------------------- /docs/examples/tests/test_simple.py: -------------------------------------------------------------------------------- 1 | from examples.simple import Window 2 | from pytestqt.qtbot import QtBot 3 | from qt_async_threads import QtAsyncRunner 4 | 5 | 6 | def test_simple(qtbot: QtBot, runner: QtAsyncRunner) -> None: 7 | win = Window(runner) 8 | qtbot.addWidget(win) 9 | 10 | win.search_button.click() 11 | qtbot.waitUntil(runner.is_idle) 12 | assert win.results_label.text().startswith("Found a cat") 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. qt-async-threads documentation master file, created by 2 | sphinx-quickstart on Tue Apr 26 18:58:19 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ================ 7 | qt-async-threads 8 | ================ 9 | 10 | ``qt-async-threads`` allows Qt applications to run computational or IO intensive operations in threads using 11 | convenient ``async/await`` syntax. 12 | 13 | 14 | 15 | .. literalinclude:: examples/simple.py 16 | :pyobject: Window 17 | 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | :caption: Contents: 22 | 23 | tutorial 24 | reference 25 | testing 26 | changelog 27 | license 28 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | License 3 | ========= 4 | 5 | .. include:: ../LICENSE 6 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Reference 3 | ========= 4 | 5 | Overview 6 | -------- 7 | 8 | To use this library, it is recommended that you instantiate a :class:`QtAsyncRunner ` 9 | at the start of the application, using it in context-manager, and then pass that instance along to your main 10 | window/widgets: 11 | 12 | .. code-block:: python 13 | 14 | def main(argv: list[str]) -> None: 15 | app = QApplication(argv) 16 | with QtAsyncRunner() as runner: 17 | main_window = MainWindow(runner) 18 | main_window.show() 19 | app.exec() 20 | 21 | Using it as a context manager ensures a clean exit. 22 | 23 | After that, your ``MainWindow`` can pass the ``runner`` along to the other parts of the application that need it. 24 | 25 | Alternatively, you might decide to create a local :class:`QtAsyncRunner ` instance 26 | into a widget (and this is a great way to try the library actually), and it will work fine, but for large scale 27 | usage creating a single instance is recommended, to limit the number of running threads in the application. 28 | 29 | Classes 30 | ------- 31 | 32 | 33 | .. autoclass:: qt_async_threads.AbstractAsyncRunner 34 | :members: 35 | 36 | .. autoclass:: qt_async_threads.QtAsyncRunner 37 | :members: 38 | 39 | .. autoclass:: qt_async_threads.SequentialRunner 40 | :members: 41 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | pyqt6 3 | sphinx 4 | sphinx-copybutton 5 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Testing 3 | ======= 4 | 5 | ``qt-async-threads`` provides some fixtures and guidelines on how to test applications. The fixtures require `pytest-qt`_. 6 | 7 | 8 | Patterns 9 | ======== 10 | 11 | When changing an existing slot into an ``async`` function, the test is affected because operations 12 | now not necessarily finish as soon as the slot is called. 13 | 14 | For example, we might have the following test for the example in the :ref:`tutorial`: 15 | 16 | .. code-block:: python 17 | 18 | def test_download(qtbot: QtBot, tmp_path: Path) -> None: 19 | window = Window(tmp_path) 20 | qtbot.addWidget(window) 21 | 22 | window.count_spin.setValue(2) 23 | window.download_button.click() 24 | 25 | assert len(list(tmp_path.iterdir())) == 2 26 | 27 | When we change the function to ``async``, the test will likely fail, because as soon as the function hits the 28 | ``runner.run`` call, the ``download_button.click()`` will return, and the assertion will fail because the 29 | files will not have been downloaded yet. 30 | 31 | We have some approaches: 32 | 33 | Wait on the side-effect 34 | ----------------------- 35 | 36 | We can leverage :meth:`QtBot.waitUntil ` to wait until a condition is met, here the condition being that 37 | we have 2 files downloaded in the directory: 38 | 39 | .. code-block:: python 40 | 41 | def test_download(qtbot: QtBot, tmp_path: Path, runner: QtAsyncRunner) -> None: 42 | window = Window(tmp_path, runner) 43 | qtbot.addWidget(window) 44 | 45 | window.count_spin.setValue(2) 46 | window.download_button.click() 47 | 48 | def files_downloaded() -> None: 49 | assert len(list(tmp_path.iterdir())) == 2 50 | 51 | qtbot.waitUntil(files_downloaded) 52 | 53 | :meth:`QtBot.waitUntil ` will call ``files_downloaded()`` in a loop, 54 | until the condition does not raise an :class:`AssertionError` or a time-out occurs. 55 | 56 | Wait for the runner to become idle 57 | ---------------------------------- 58 | 59 | We can also use :meth:`QtBot.waitUntil ` to wait for the runner to 60 | become idle after clicking on the button: 61 | 62 | .. code-block:: python 63 | 64 | def test_download(qtbot: QtBot, tmp_path: Path, runner: QtAsyncRunner) -> None: 65 | window = Window(tmp_path, runner) 66 | qtbot.addWidget(window) 67 | 68 | window.count_spin.setValue(2) 69 | window.download_button.click() 70 | qtbot.waitUntil(runner.is_idle) 71 | 72 | assert len(list(tmp_path.iterdir())) == 2 73 | 74 | 75 | 76 | .. note:: 77 | This approach only works if the signal is connected to the slot using a 78 | `Qt.DirectConnection `_ (which is the default). 79 | 80 | If for some reason the connection is of the type ``Qt.QueuedConnection``, this will not work because the signal 81 | will not be emitted directly by ``.click()``, instead it will be scheduled for later delivery in the 82 | next pass of the event loop, and ``runner.is_idle()`` will be True. 83 | 84 | 85 | Calling async functions 86 | ----------------------- 87 | 88 | If you need to call an ``async`` function directly in the test, you can use :meth:`AsyncTester.start_and_wait ` 89 | to call it. 90 | 91 | So it is possible to change from calling ``.click()`` directly to call the slot instead: 92 | 93 | .. code-block:: python 94 | 95 | def test_download( 96 | qtbot: QtBot, tmp_path: Path, runner: QtAsyncRunner, async_tester: AsyncTester 97 | ) -> None: 98 | window = Window(tmp_path, runner) 99 | qtbot.addWidget(window) 100 | 101 | window.count_spin.setValue(2) 102 | async_tester.start_and_wait(window._on_download_button_clicked()) 103 | 104 | assert len(list(tmp_path.iterdir())) == 2 105 | 106 | Here we change from calling ``.click()`` to call the slot directly just to exemplify, it is recommended to call functions 107 | which emit the signal when possible as they ensure the signal and slot are connected. 108 | 109 | However the technique is useful to test ``async`` functions in isolation when using this library. 110 | 111 | 112 | Fixtures/classes reference 113 | ========================== 114 | 115 | .. autofunction:: qt_async_threads.pytest_plugin.runner 116 | 117 | .. autofunction:: qt_async_threads.pytest_plugin.async_tester 118 | 119 | .. autoclass:: qt_async_threads.pytest_plugin.AsyncTester 120 | :members: 121 | 122 | 123 | 124 | .. _pytest-qt: https://github.com/pytest-dev/pytest-qt 125 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _`tutorial`: 2 | 3 | ======== 4 | Tutorial 5 | ======== 6 | 7 | This tutorial will show how to change the function in an existing application, that 8 | performs a blocking operation, so it no longer freezes the UI, providing a better 9 | user experience. 10 | 11 | Statement of the problem 12 | ------------------------ 13 | 14 | It is common for applications to spawn computational intensive operations based on user interaction, 15 | like downloading some files or performing CPU intensive computations. 16 | 17 | Calling those functions directly will usually lead to poor user experience, as the UI will become 18 | unresponsive while the operation is taking place. 19 | 20 | The usual solution to avoid making the UI unresponsive is to run the expensive operation in a separate thread, 21 | leaving the main thread free to process other user events (the event loop). 22 | 23 | However, this requires to break the normal flow of the code into separate functions, doing extra bookkeeping to 24 | communicate results/errors, and making the original interaction harder to test than before. 25 | 26 | Example 1: First implementation 27 | ------------------------------- 28 | 29 | Here is a small application that lets users download random images of cats using `TheCatApi `__. 30 | The user selects the number of cat images to download, and clicks on a button. 31 | 32 | .. image:: examples/images/explanation1.png 33 | :align: center 34 | 35 | Here's the main portion of the code: 36 | 37 | .. literalinclude:: examples/explanation_sync.py 38 | :pyobject: Window 39 | 40 | 41 | After clicking on the *Download* button, the images will start to be downloaded, and the user will be informed 42 | of the progress on a label. Note also that the code handles not only downloading the images, but also gracefully handles errors (using 43 | ``QMessageBox.critical`` to show connection problems), and allows the user to cancel the operation by clicking 44 | on the *Stop* button. 45 | 46 | However, the user will have a hard time if they try to actually stop the operation: the application is unresponsive, 47 | sluggish; clicks often don't produce any feedback, moving the mouse over the button and 48 | clicking on it have no effect, except if the user clicks on the button in quick succession. 49 | Trying to change the number of cats to download also doesn't have a response while the download is taking place. 50 | 51 | This happens because the ``request.get`` calls are blocking the Qt event loop, so it can't receive user events and process 52 | them accordingly (such as a click event on the *Stop* button, or mouse move events to highlight a button). 53 | 54 | Example 2: Using threads 55 | ------------------------ 56 | 57 | The usual solution to the responsiveness problem demonstrated previously is to run the blocking code in a ``QThread``. 58 | 59 | First we need to extract the download loop to run in a thread, taking care of handling errors, and emitting a signal 60 | to the main loop whenever one of the downloads finishes: 61 | 62 | .. literalinclude:: examples/explanation_thread.py 63 | :pyobject: DownloadThread 64 | 65 | In order to use this thread object, we need to refactor the ``Window`` code to start the thread, and properly respond 66 | to its events: 67 | 68 | .. literalinclude:: examples/explanation_thread.py 69 | :pyobject: Window.on_download_button_clicked 70 | 71 | .. literalinclude:: examples/explanation_thread.py 72 | :pyobject: Window.on_downloaded 73 | 74 | .. literalinclude:: examples/explanation_thread.py 75 | :pyobject: Window.on_download_finished 76 | 77 | .. literalinclude:: examples/explanation_thread.py 78 | :pyobject: Window.on_cancel_button_clicked 79 | 80 | 81 | This now gives us a responsive interface: clicking on the Stop button gives immediate feedback, as well as minor 82 | effects such as the button being highlighted when the mouse moves over it. 83 | 84 | While this works well, it required us a considerable refactoring of the code: 85 | 86 | 1. Previously the logic was straight forward, and could be read from top to bottom. Moving the code to a thread required us 87 | to split the logic and the flow. 88 | 2. We could easily catch exceptions and react accordingly by showing a message box, but we can't do that from a 89 | ``QThread`` because widgets must always be created/live in the main thread, so we need to do some message passing. 90 | 91 | All in all, this is not *terrible*, however it is not trivial either. 92 | 93 | Also one can see that is easy for an application to slowly grow portions of the code that are blocking but 94 | still quick enough that are not a problem, but then depending on the input data or some other external factor 95 | (like a slow connection) that *quick enough* is no longer *enough* so we then need to refactor it. 96 | 97 | As time evolves, an application will often grow many small pain points like this, requiring us to carefully examine 98 | the code and refactor to threads later, as writing using threads in the first place is costly/non-trivial. 99 | 100 | Example 3: Enter ``QtAsyncRunner`` 101 | ---------------------------------- 102 | 103 | ``qt-async-threads`` provides the :class:`QtAsyncRunner ` class which allows us 104 | to easily change our existing code to use threads, without the need for a major refactoring. 105 | 106 | First we need an instance of a ``QtAsyncRunner`` class. It is strongly suggested create this *once* in the application 107 | startup and pass it to the objects that need it, however it is possible to let each widget/panel create their own instance. 108 | 109 | Here we will receive the runner as part of the constructor: 110 | 111 | .. literalinclude:: examples/explanation_async.py 112 | :start-at: __init__ 113 | :end-before: setWindowTitle 114 | 115 | Next, we will change our original ``_on_download_button_clicked`` function so it becomes ``async``. This is easy, 116 | we just need to add the ``async`` keyword before ``def``: 117 | 118 | .. literalinclude:: examples/explanation_async.py 119 | :start-at: def on_download_button_clicked 120 | :end-before: self.download_button.setEnabled 121 | 122 | The objective here is for the ``request.get`` calls to run in a separate thread, so we use 123 | the :meth:`QtAsyncRunner.run ` method to run the function and its arguments 124 | into a thread, so we change this: 125 | 126 | .. literalinclude:: examples/explanation_sync.py 127 | :start-at: # Search 128 | :end-before: except ConnectionError 129 | 130 | Into this: 131 | 132 | .. literalinclude:: examples/explanation_async.py 133 | :start-at: # Search 134 | :end-before: except ConnectionError 135 | 136 | Note that ``run`` is ``async``, so we need to put ``await`` in front of it. 137 | 138 | Finally, we just need to change the signal connection: Qt doesn't know how to execute ``async`` methods, so 139 | we need to ask the ``runner`` to wrap it for us: 140 | 141 | 142 | .. literalinclude:: examples/explanation_async.py 143 | :start-at: Connect signals 144 | :end-before: async def 145 | 146 | And that's it! Now the application is just as responsive as the version using ``QThread``, but with minimal changes. 147 | 148 | .. important:: 149 | 150 | This is the point of the ``qt-async-threads`` package: easily taking an existing application and, 151 | with minimal changes, employ threads to execute blocking calls. 152 | 153 | 154 | Example 4: Running in parallel 155 | ------------------------------ 156 | 157 | We can improve things further: downloads are something which is efficient to be done in parallel to maximize 158 | bandwidth usage, and :meth:`QtAsyncRunner.run_parallel ` makes 159 | it easy to adjust our code to run many blocking functions in parallel. 160 | 161 | .. literalinclude:: examples/explanation_async_parallel.py 162 | :pyobject: Window.on_download_button_clicked 163 | 164 | We refactor the part of the code responsible for downloading the file into the ``download_one`` function, 165 | and then call ``QtAsyncRunner.run_parallel()`` passing a list of functions that will be executed in parallel. 166 | Using the ``async for`` syntax, we loop over the results as they get ready, and then proceed as usual. 167 | 168 | One small change is that we moved the handler for ``ConnectionError`` to cover the ``async for`` loop, as now 169 | the ``run_parallel()`` call can raise that exception. 170 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "setuptools-scm[toml]", 5 | "wheel", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools_scm] 10 | 11 | [tool.black] 12 | line-length = 100 13 | 14 | [tool.pytest.ini_options] 15 | addopts = "-ra" 16 | pythonpath = "docs" 17 | testpaths = ["docs", "tests"] 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = qt-async-threads 3 | description = Use convenient async/await syntax to spawn threads in Qt applications 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | url = https://qt-async-threads.readthedocs.io/ 7 | author = Bruno Oliveira 8 | author_email = nicoddemus@gmail.com 9 | license = MIT 10 | license_file = LICENSE 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Environment :: X11 Applications :: Qt 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: MIT License 16 | Operating System :: MacOS :: MacOS X 17 | Operating System :: Microsoft :: Windows 18 | Operating System :: POSIX 19 | Programming Language :: Python :: 3 20 | Topic :: Software Development :: Libraries 21 | Topic :: Software Development :: User Interfaces 22 | Topic :: Utilities 23 | keywords = qt, async, threads 24 | Source=https://github.com/nicoddemus/qt-async-threads 25 | Docs=https://qt-async-threads.readthedocs.io 26 | 27 | [options] 28 | packages = 29 | qt_async_threads 30 | install_requires = 31 | attrs 32 | boltons 33 | qtpy 34 | 35 | python_requires = >=3.10 36 | package_dir = 37 | =src 38 | setup_requires = 39 | setuptools 40 | setuptools-scm 41 | zip_safe = no 42 | 43 | [options.extras_require] 44 | dev = 45 | black 46 | mypy 47 | pre-commit 48 | pytest-qt 49 | requests 50 | 51 | [options.entry_points] 52 | pytest11 = 53 | qt_async_threads = qt_async_threads.pytest_plugin 54 | 55 | [options.package_data] 56 | qt_async_threads = py.typed 57 | 58 | [build_sphinx] 59 | source_dir = docs 60 | build_dir = docs/build 61 | all_files = 1 62 | 63 | 64 | [mypy] 65 | mypy_path = src 66 | disallow_incomplete_defs = true 67 | disallow_untyped_defs = true 68 | files = ["src", "tests/**/*.py"] 69 | ignore_missing_imports = true 70 | implicit_reexport = false 71 | no_implicit_optional = true 72 | show_error_codes = true 73 | strict_equality = true 74 | warn_redundant_casts = true 75 | warn_return_any = true 76 | warn_unused_configs = true 77 | warn_unused_ignores = true 78 | # workaround for https://github.com/python/mypy/issues/10709 79 | ignore_missing_imports_per_module = true 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /src/qt_async_threads/__init__.py: -------------------------------------------------------------------------------- 1 | from ._async_runner_abc import AbstractAsyncRunner 2 | from ._qt_async_runner import QtAsyncRunner 3 | from ._sequential_runner import SequentialRunner 4 | 5 | __all__ = ["AbstractAsyncRunner", "QtAsyncRunner", "SequentialRunner"] 6 | -------------------------------------------------------------------------------- /src/qt_async_threads/_async_runner_abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from abc import ABC 5 | from abc import abstractmethod 6 | from typing import Any 7 | from typing import AsyncIterator 8 | from typing import Callable 9 | from typing import Coroutine 10 | from typing import Iterable 11 | from typing import ParamSpec 12 | from typing import TypeVar 13 | 14 | 15 | Params = ParamSpec("Params") 16 | T = TypeVar("T") 17 | 18 | 19 | class AbstractAsyncRunner(ABC): 20 | """ 21 | Abstract interface to a runner. 22 | 23 | A runner allow us to start an ``async`` function so that the async function (coroutine) can easily submit 24 | computational expensive functions to run in a thread, yielding back to the caller so the caller 25 | can go and do other things. 26 | 27 | This is analogous to what is possible in async libraries like ``asyncio`` and ``trio``, 28 | but with the difference that this can easily be used from within a normal/blocking function 29 | in a Qt application. 30 | 31 | This allows us to write slots as async functions that can yield back to the Qt event loop 32 | when we want to run something in a thread, and resume the slot once the function finishes 33 | (using the concrete ``QtAsyncRunner`` implementation). 34 | 35 | For tests, there is the ``SequentialRunner`` which evaluates the async function 36 | to completion in the spot, not executing anything in threads. 37 | 38 | Use it as a context manager to ensure proper cleanup. 39 | 40 | **Usage** 41 | 42 | Usually you want to connect normal Qt signals to slots written as async functions to signals, 43 | something like this: 44 | 45 | .. code-block:: python 46 | 47 | def _build_ui(self): 48 | button.clicked.connect(self._on_button_clicked_sync_slot) 49 | 50 | 51 | def _on_button_clicked_sync_slot(self): 52 | self.runner.start_coroutine(self._on_button_clicked_async()) 53 | 54 | 55 | async def _on_button_clicked_async(self): 56 | result = await self.runner.run(compute_spectrum, self.spectrum) 57 | 58 | However, the ``to_sync`` method can be used to reduce the boilerplate: 59 | 60 | .. code-block:: python 61 | 62 | def _build_ui(self): 63 | button.clicked.connect(self.runner.to_sync(self._on_button_clicked)) 64 | 65 | 66 | async def _on_button_clicked(self): 67 | result = await self.runner.run(compute_spectrum, self.spectrum) 68 | 69 | 70 | **Running many functions in parallel** 71 | 72 | Often we want to submit several functions to run at the same time (respecting the underlying 73 | number of threads in the pool of course), in which case one can use ``run_parallel``: 74 | 75 | .. code-block:: python 76 | 77 | def compute(*args): 78 | ... 79 | 80 | 81 | async def _compute(self) -> None: 82 | funcs = [partial(compute, ...) for _ in range(attempts)] 83 | async for result in self.runner.run_parallel(funcs): 84 | # do something with ``result`` 85 | ... 86 | 87 | Using ``async for``, we submit the functions to a thread pool, and we asynchronously 88 | process the results as they are completed. 89 | """ 90 | 91 | def __enter__(self: T) -> T: 92 | return self 93 | 94 | def __exit__(self, *exc_info: object) -> None: 95 | self.close() 96 | 97 | @abstractmethod 98 | def is_idle(self) -> bool: 99 | """Return True if this runner is not currently executing anything.""" 100 | 101 | @abstractmethod 102 | def close(self) -> None: 103 | """ 104 | Close runner and cleanup resources. 105 | 106 | Cancels all running callables that were started with ``run`` or ``run_parallel``. Any 107 | callable will still run, however its results will be dropped: 108 | 109 | Letting coroutines resume into the main thread after close() 110 | has been called can be problematic specially in tests, as close() is often called at the end of the 111 | test. If the user has forgotten to properly wait on a coroutine() during the test, often what will 112 | happen is that the coroutine will resume (after the thread finishes) when other resources have already 113 | been cleared, specially widgets. 114 | 115 | Dropping seems harsh, but follows what other libraries like ``asyncio`` do when faced with the same 116 | situation. 117 | """ 118 | 119 | @abstractmethod 120 | async def run( 121 | self, func: Callable[Params, T], *args: Params.args, **kwargs: Params.kwargs 122 | ) -> T: 123 | """ 124 | Async function which executes the given callable in a separate 125 | thread, and yields the control back to async runner while the 126 | thread is executing. 127 | """ 128 | 129 | @abstractmethod 130 | async def run_parallel(self, funcs: Iterable[Callable[[], T]]) -> AsyncIterator[T]: 131 | """ 132 | Runs functions in parallel (without arguments, use ``partial`` as necessary), yielding 133 | their results as they get ready. 134 | """ 135 | 136 | @abstractmethod 137 | def start_coroutine(self, async_func: Coroutine) -> None: 138 | """ 139 | Starts a coroutine, and returns immediately (except in dummy implementations). 140 | """ 141 | 142 | @abstractmethod 143 | def run_coroutine(self, coroutine: Coroutine[Any, Any, T]) -> T: 144 | """ 145 | Starts a coroutine, and blocks execution until it finishes, returning its result. 146 | 147 | Note: this blocks the call and should be avoided in production, being used as a last resort 148 | in cases the main application window or event processing has not started yet (before QApplication.exec()), 149 | or for testing. 150 | """ 151 | 152 | def to_sync(self, async_func: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]: 153 | """ 154 | Returns a new sync function that will start its coroutine using ``start_coroutine`` when 155 | called, returning immediately. 156 | 157 | Use to connect Qt signals to async functions, see ``AbstractAsyncRunner`` docs for example usage. 158 | """ 159 | 160 | @functools.wraps(async_func) 161 | def func(*args: object, **kwargs: object) -> None: 162 | gen = async_func(*args, **kwargs) 163 | self.start_coroutine(gen) 164 | 165 | return func 166 | -------------------------------------------------------------------------------- /src/qt_async_threads/_qt_async_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import threading 6 | import time 7 | from collections.abc import AsyncIterator 8 | from collections.abc import Awaitable 9 | from collections.abc import Coroutine 10 | from concurrent.futures import Future 11 | from concurrent.futures import ThreadPoolExecutor 12 | from contextlib import suppress 13 | from functools import partial 14 | from typing import Any 15 | from typing import Callable 16 | from typing import cast 17 | from typing import Generator 18 | from typing import Iterable 19 | from typing import Iterator 20 | 21 | import attr 22 | from boltons.iterutils import chunked_iter 23 | from qtpy.QtCore import QObject 24 | from qtpy.QtCore import Signal 25 | from qtpy.QtWidgets import QApplication 26 | 27 | from ._async_runner_abc import AbstractAsyncRunner 28 | from ._async_runner_abc import Params 29 | from ._async_runner_abc import T 30 | 31 | log = logging.getLogger(__name__) 32 | 33 | 34 | class QtAsyncRunner(AbstractAsyncRunner): 35 | """ 36 | An implementation of AbstractRunner which runs computational intensive 37 | functions using a thread pool. 38 | """ 39 | 40 | def __init__(self, max_threads: int | None = None) -> None: 41 | """ 42 | :param max_threads: 43 | Maximum number of threads in the thread pool. If None, uses 44 | the number of CPUs in the system. 45 | """ 46 | super().__init__() 47 | self._max_threads = max_threads or os.cpu_count() or 1 48 | self._pool = ThreadPoolExecutor(max_workers=max_threads) 49 | self._closed = False 50 | 51 | # Keep track of running tasks, 52 | # mostly to know if we are idle or not. 53 | self._running_tasks: list[_AsyncTask] = [] 54 | 55 | # This signaller object is used to signal to us when a future running 56 | # in another thread finishes. Thanks to Qt's queued connections, 57 | # signals can be safely emitted from separate threads and are queued 58 | # to run in the same thread as the object receiving it lives (the main 59 | # thread in our case). 60 | self._signaller = _FutureDoneSignaller() 61 | self._signaller.future_done_signal.connect(self._resume_coroutine) 62 | 63 | @property 64 | def max_threads(self) -> int: 65 | """ 66 | Return the maximum number of threads used by the internal 67 | threading pool. 68 | """ 69 | return self._max_threads 70 | 71 | def is_idle(self) -> bool: 72 | return len(self._running_tasks) == 0 73 | 74 | def close(self) -> None: 75 | self._closed = True 76 | self._pool.shutdown(wait=True, cancel_futures=True) 77 | 78 | async def run( 79 | self, func: Callable[Params, T], *args: Params.args, **kwargs: Params.kwargs 80 | ) -> T: 81 | """ 82 | Runs the given function in a thread, and while it is running, yields 83 | the control back to the Qt event loop. 84 | 85 | When the thread finishes, this async function resumes, returning 86 | the return value from the function. 87 | """ 88 | funcs = [partial(func, *args, **kwargs)] 89 | async for result in self.run_parallel(funcs): 90 | return result 91 | assert False, "should never be reached" 92 | 93 | async def run_parallel( # type:ignore[override] 94 | self, funcs: Iterable[Callable[[], T]] 95 | ) -> AsyncIterator[T]: 96 | """ 97 | Runs functions in parallel (without arguments, use ``partial`` as necessary), yielding 98 | their results as they get ready. 99 | """ 100 | # We submit the functions in batches to avoid overloading the pool, which can cause other 101 | # coroutines to stall. For example, a simulation might work by executing 1000s of small functions, 102 | # which might take a few ms each. If we submitted all those 1000s of functions 103 | # at once (at the time ``run_parallel`` is called), then other coroutines that try to submit 104 | # functions to execute in threads would only be resumed much later, causing a noticeable 105 | # slow down in the application. 106 | batch_size = max(self._max_threads // 2, 1) 107 | for function_batch in chunked_iter(funcs, batch_size): 108 | # Submit all functions from the current batch to the thread pool, 109 | # using the _AsyncTask to track the futures and await when they finish. 110 | task = _AsyncTask({self._pool.submit(f) for f in function_batch}) 111 | self._running_tasks.append(task) 112 | for future in task.futures: 113 | future.add_done_callback(partial(self._on_task_future_done, task=task)) 114 | try: 115 | # We wait until all functions in this batch have finished 116 | # until we submit the next batch. This is not 100% optimal 117 | # but ensures we get a fairer share of the pool. 118 | while task.futures: 119 | await task 120 | for result in task.pop_done_futures(): 121 | yield result 122 | finally: 123 | task.shutdown() 124 | self._running_tasks.remove(task) 125 | 126 | def _on_future_done( 127 | self, 128 | future: Future, 129 | *, 130 | coroutine: Coroutine[Any, Any, Any], 131 | ) -> None: 132 | """ 133 | Called when a ``Future`` that was submitted to the thread pool finishes. 134 | 135 | This function is called from a separate thread, so we emit the signal 136 | of the internal ``_signaller``, which thanks to Qt's queued connections feature, 137 | will post the event to the main loop, and it will be processed there. 138 | """ 139 | self._signaller.future_done_signal.emit(future, coroutine) 140 | 141 | def _on_task_future_done(self, future: Future, *, task: _AsyncTask) -> None: 142 | """ 143 | Called when a ``Future`` belonging to a ``_AsyncTask`` has finished. 144 | 145 | Similar to ``_on_future_done``, this will emit a signal so the coroutine is resumed 146 | in the main thread. 147 | """ 148 | # At this point, we want to get the coroutine associated with the task, and resume 149 | # its execution in the main loop, but we must take care here because this callback is called 150 | # from another thread, and it may be called multiple times in succession, but we 151 | # want only **one** event to be sent, that's 152 | # why we use ``pop_coroutine``, which is lock-protected and will return 153 | # the coroutine and set it to None, so next calls of this method will get ``None`` 154 | # and won't trigger the event again. 155 | if (coroutine := task.pop_coroutine()) and not future.cancelled(): 156 | self._signaller.future_done_signal.emit(future, coroutine) 157 | 158 | def _resume_coroutine( 159 | self, 160 | future: Future, 161 | coroutine: Coroutine[Any, Any, Any], 162 | ) -> None: 163 | """ 164 | Slots connected to our internal ``_signaller`` object, 165 | called in the main thread after a future finishes, resuming the paused coroutine. 166 | """ 167 | if not future.cancelled() and not self._closed: 168 | assert threading.current_thread() is threading.main_thread() 169 | self.start_coroutine(coroutine) 170 | 171 | def start_coroutine(self, coroutine: Coroutine) -> None: 172 | """ 173 | Starts the coroutine, and returns immediately. 174 | """ 175 | # Note: this function will also be called to resume a paused coroutine that was 176 | # waiting for a thread to finish (by ``_resume_coroutine``). 177 | with suppress(StopIteration): 178 | value = coroutine.send(None) 179 | if isinstance(value, _AsyncTask): 180 | # At this point, return control to the event loop; when one 181 | # of the futures running in the task finishes, it will resume the 182 | # coroutine back in the main thread. 183 | value.coroutine = coroutine 184 | return 185 | else: 186 | assert False, f"Unexpected awaitable type: {value!r} {value}" 187 | 188 | def run_coroutine(self, coroutine: Coroutine[Any, Any, T]) -> T: 189 | """ 190 | Starts the coroutine, doing a busy loop while waiting for it to complete, 191 | returning then the result. 192 | 193 | Note: see warning in AbstractAsyncRunner about when to use this function. 194 | """ 195 | 196 | result: T | None = None 197 | exception: Exception | None = None 198 | completed = False 199 | 200 | async def wrapper() -> None: 201 | nonlocal result, exception, completed 202 | try: 203 | result = await coroutine 204 | except Exception as e: 205 | exception = e 206 | completed = True 207 | 208 | self.start_coroutine(wrapper()) 209 | while not completed: 210 | QApplication.processEvents() 211 | time.sleep(0.01) 212 | 213 | if exception is not None: 214 | raise exception 215 | return cast(T, result) 216 | 217 | 218 | @attr.s(auto_attribs=True, eq=False) 219 | class _AsyncTask(Awaitable[None]): 220 | """ 221 | Awaitable that is propagated up the async stack, containing 222 | running futures. 223 | 224 | It "awaits" while all futures are processing, and will 225 | stop and return once any of them complete. 226 | """ 227 | 228 | futures: set[Future] 229 | coroutine: Coroutine | None = None 230 | _lock: threading.Lock = attr.Factory(threading.Lock) 231 | 232 | def __await__(self) -> Generator[Any, None, None]: 233 | if any(x for x in self.futures if x.done()): 234 | return None 235 | else: 236 | yield self 237 | 238 | def pop_done_futures(self) -> Iterator[Any]: 239 | """ 240 | Yields futures that are done, removing them from the ``futures`` set 241 | at the same time. 242 | """ 243 | for future in list(self.futures): 244 | if future.done(): 245 | self.futures.discard(future) 246 | if not future.cancelled(): 247 | yield future.result() 248 | 249 | def pop_coroutine(self) -> Coroutine | None: 250 | """ 251 | Returns the current coroutine associated with this object, while 252 | also setting the ``coroutine`` attribute to ``None``. This 253 | is meant to be called from multiple threads, in a way that only 254 | one of them will be able to obtain the coroutine object, while the 255 | others will get ``None``. 256 | """ 257 | with self._lock: 258 | coroutine = self.coroutine 259 | self.coroutine = None 260 | return coroutine 261 | 262 | def shutdown(self) -> None: 263 | """Cancels any running futures and clears up its attributes.""" 264 | msg = "Should always be called from ``run_parallel``, at which point this should not have a coroutine associated" 265 | assert self.coroutine is None, msg 266 | while self.futures: 267 | future = self.futures.pop() 268 | future.cancel() 269 | 270 | 271 | class _FutureDoneSignaller(QObject): 272 | """ 273 | QObject subclass which we use as an intermediary to safely emit a signal 274 | to the main thread when a future finishes in another thread. 275 | """ 276 | 277 | # This emits(Future, Coroutine). We need to use ``object`` as the 278 | # second parameter because Qt doesn't allow us to use an ABC class there it seems. 279 | future_done_signal = Signal(Future, object) 280 | -------------------------------------------------------------------------------- /src/qt_async_threads/_sequential_runner.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import AsyncIterator 3 | from typing import Callable 4 | from typing import cast 5 | from typing import Coroutine 6 | from typing import Iterable 7 | 8 | from ._async_runner_abc import AbstractAsyncRunner 9 | from ._async_runner_abc import Params 10 | from ._async_runner_abc import T 11 | 12 | 13 | class SequentialRunner(AbstractAsyncRunner): 14 | """ 15 | Implementation of an AbstractRunner which doesn't actually run anything 16 | in other threads, acting just as a placeholder in situations we don't 17 | care to run functions in other threads, such as in tests. 18 | """ 19 | 20 | def is_idle(self) -> bool: 21 | return True 22 | 23 | def close(self) -> None: 24 | pass 25 | 26 | async def run( 27 | self, func: Callable[Params, T], *args: Params.args, **kwargs: Params.kwargs 28 | ) -> T: 29 | """ 30 | Sequential implementation, does not really run in a thread, just calls the 31 | function and returns its result. 32 | """ 33 | return func(*args, **kwargs) 34 | 35 | async def run_parallel( # type:ignore[override] 36 | self, funcs: Iterable[Callable[[], T]] 37 | ) -> AsyncIterator[T]: 38 | """ 39 | Sequential implementation, runs functions sequentially in the main thread. 40 | """ 41 | for func in funcs: 42 | yield func() 43 | 44 | def start_coroutine(self, coroutine: Coroutine) -> None: 45 | """ 46 | Sequential implementation, just runs the coroutine to completion. 47 | """ 48 | self.run_coroutine(coroutine) 49 | 50 | def run_coroutine(self, coroutine: Coroutine[None, Any, T]) -> T: 51 | """ 52 | Runs the given coroutine until it completes, returning its result. 53 | """ 54 | try: 55 | coroutine.send(None) 56 | except StopIteration as stop_e: 57 | return cast(T, stop_e.value) 58 | else: 59 | assert False, "should not get here" 60 | -------------------------------------------------------------------------------- /src/qt_async_threads/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicoddemus/qt-async-threads/f36a9a51ab9a1795098df3742f8943cf13ea574d/src/qt_async_threads/py.typed -------------------------------------------------------------------------------- /src/qt_async_threads/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from typing import Coroutine 6 | from typing import Iterator 7 | 8 | import attr 9 | import pytest 10 | from pytestqt.qtbot import QtBot 11 | from qt_async_threads import QtAsyncRunner 12 | 13 | 14 | @pytest.fixture 15 | def runner(qtbot: QtBot) -> Iterator[QtAsyncRunner]: 16 | """Returns a QtAsyncRunner, shutting it down at the end of the test.""" 17 | with QtAsyncRunner(max_threads=4) as runner: 18 | yield runner 19 | 20 | 21 | @pytest.fixture 22 | def async_tester(qtbot: QtBot, runner: QtAsyncRunner) -> AsyncTester: 23 | """ 24 | Return an :class:`~qt_async_threads.pytest_plugin.AsyncTester`, 25 | with utilities to handling async calls in tests. 26 | """ 27 | return AsyncTester(runner, qtbot) 28 | 29 | 30 | @attr.s(auto_attribs=True) 31 | class AsyncTester: 32 | """ 33 | Testing helper for async functions. 34 | """ 35 | 36 | runner: QtAsyncRunner 37 | qtbot: QtBot 38 | 39 | #: Timeout in seconds for ``QtBot.waitUntil`` during ``start_and_wait``. 40 | timeout_s: int = 5 41 | 42 | def _get_wait_idle_timeout(self, timeout_s: int) -> int: 43 | """ 44 | Return the maximum amount of time we should wait for the runner to 45 | become idle, in ms. 46 | """ 47 | in_ci = os.environ.get("CI") == "true" 48 | in_debugger = sys.gettrace() is not None 49 | if in_debugger and not in_ci: 50 | # Use a very large timeout if we are running in a debugger, 51 | # accounting that in CI the tracer is set due to coverage. 52 | timeout_s = 24 * 60 * 60 53 | return timeout_s * 1000 54 | 55 | def start_and_wait(self, coroutine: Coroutine, *, timeout_s: int | None = None) -> None: 56 | """ 57 | Starts the given coroutine and wait for the runner to be idle. 58 | 59 | Note this is not exactly the same as calling ``run_coroutine``, because 60 | the former waits only for the given coroutine, while this method waits 61 | for the runner itself to become idle (meaning this will wait even if 62 | the given coroutine starts other coroutines). 63 | 64 | :param timeout_s: 65 | If given, how long to wait. If not given, will use AsyncTester.timeout_s. 66 | """ 67 | self.runner.start_coroutine(coroutine) 68 | if timeout_s is None: 69 | timeout_s = self.timeout_s 70 | self.qtbot.waitUntil(self.runner.is_idle, timeout=self._get_wait_idle_timeout(timeout_s)) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicoddemus/qt-async-threads/f36a9a51ab9a1795098df3742f8943cf13ea574d/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_async_tester.py: -------------------------------------------------------------------------------- 1 | import time 2 | from functools import partial 3 | 4 | import pytest 5 | from pytestqt import exceptions 6 | from qt_async_threads.pytest_plugin import AsyncTester 7 | 8 | 9 | def test_start_and_wait(async_tester: AsyncTester) -> None: 10 | steps: list[str] = [] 11 | 12 | async def main() -> None: 13 | await async_tester.runner.run(lambda: None) 14 | steps.append("main") 15 | async_tester.runner.start_coroutine(inner()) 16 | 17 | async def inner() -> None: 18 | await async_tester.runner.run(lambda: None) 19 | steps.append("inner") 20 | 21 | async_tester.start_and_wait(main()) 22 | assert steps == ["main", "inner"] 23 | 24 | 25 | def test_start_and_wait_timeout(async_tester: AsyncTester) -> None: 26 | 27 | async def main() -> None: 28 | await async_tester.runner.run(partial(time.sleep, 2.0)) 29 | 30 | async_tester.start_and_wait(main()) 31 | 32 | # Test parameter. 33 | with pytest.raises(exceptions.TimeoutError): 34 | async_tester.start_and_wait(main(), timeout_s=1) 35 | 36 | # Test default. 37 | async_tester.timeout_s = 1 38 | with pytest.raises(exceptions.TimeoutError): 39 | async_tester.start_and_wait(main()) 40 | -------------------------------------------------------------------------------- /tests/test_qt_async_runner.py: -------------------------------------------------------------------------------- 1 | import random 2 | import threading 3 | import time 4 | from functools import partial 5 | from threading import Barrier 6 | 7 | import pytest 8 | from pytestqt.qtbot import QtBot 9 | from qt_async_threads import QtAsyncRunner 10 | from qtpy.QtWidgets import QApplication 11 | 12 | from tests.testing import assert_is_another_thread 13 | from tests.testing import assert_is_main_thread 14 | 15 | 16 | def test_with_thread(runner: QtAsyncRunner, qtbot: QtBot) -> None: 17 | """An async function which calls ``run``.""" 18 | results: list[int] = [] 19 | 20 | async def foo() -> None: 21 | assert_is_main_thread() 22 | result = await runner.run(double, 33) 23 | results.append(result) 24 | 25 | runner.start_coroutine(foo()) 26 | qtbot.waitUntil(runner.is_idle) 27 | assert results == [66] 28 | 29 | 30 | def test_no_thread(runner: QtAsyncRunner) -> None: 31 | """ 32 | Check a straightforward async function which does not call ``run``. 33 | 34 | We want to make sure our runner can evaluate trivial async functions, more of 35 | a sanity check thank anything else. 36 | """ 37 | results: list[int] = [] 38 | 39 | async def foo() -> None: 40 | assert_is_main_thread() 41 | results.append(42) 42 | 43 | start_func = runner.to_sync(foo) 44 | start_func() 45 | assert results == [42] 46 | 47 | 48 | def test_exception(runner: QtAsyncRunner, qtbot: QtBot) -> None: 49 | """ 50 | Check that exceptions raised in a thread are propagated naturally 51 | while we ``await`` it. 52 | """ 53 | error: Exception | None = None 54 | 55 | def raise_error() -> None: 56 | assert_is_another_thread() 57 | raise RuntimeError("oh no") 58 | 59 | async def foo() -> None: 60 | assert_is_main_thread() 61 | try: 62 | await runner.run(raise_error) 63 | except Exception as e: 64 | nonlocal error 65 | error = e 66 | 67 | runner.start_coroutine(foo()) 68 | qtbot.waitUntil(runner.is_idle) 69 | assert isinstance(error, RuntimeError) 70 | 71 | 72 | def test_start_several_at_same_time(qtbot: QtBot, runner: QtAsyncRunner) -> None: 73 | """ 74 | Sanity check that we can have many functions executing in 75 | threads at the same time. 76 | """ 77 | assert runner.is_idle() 78 | 79 | results: list[int] = [] 80 | 81 | async def foo(i: int) -> None: 82 | assert_is_main_thread() 83 | for _ in range(5): 84 | result = await runner.run(double, i) 85 | results.append(result) 86 | 87 | loop_count = 100 88 | for i in range(loop_count): 89 | runner.start_coroutine(foo(i)) 90 | 91 | qtbot.waitUntil(runner.is_idle) 92 | assert len(results) == loop_count * 5 93 | # Use a set() as the order is not guaranteed. 94 | assert set(results) == set([i * 2 for i in range(loop_count)] * 5) 95 | 96 | 97 | def test_run_parallel(qtbot: QtBot, runner: QtAsyncRunner) -> None: 98 | """ 99 | run_parallel() will run a sequence of functions in parallel. 100 | """ 101 | results: list[int] = [] 102 | 103 | async def foo(count: int) -> None: 104 | assert_is_main_thread() 105 | funcs = [partial(double, x, sleep_s=random.randrange(0, 10) / 1000.0) for x in range(count)] 106 | async for result in runner.run_parallel(funcs): 107 | results.append(result) 108 | 109 | count = 100 110 | runner.start_coroutine(foo(count)) 111 | runner.start_coroutine(foo(count * 2)) 112 | qtbot.waitUntil(runner.is_idle) 113 | 114 | assert len(results) == count * 3 115 | assert set(results) == {x * 2 for x in range(count * 2)} 116 | 117 | 118 | def test_run_parallel_in_batches(qtbot: QtBot, runner: QtAsyncRunner) -> None: 119 | """ 120 | Check that ``run_parallel`` submits functions in batches to the pool (BA-426). 121 | """ 122 | results: list[int | str] = [] 123 | 124 | async def foo(count: int) -> None: 125 | funcs = [partial(double, x, sleep_s=1 / 1000.0) for x in range(count)] 126 | async for x in runner.run_parallel(funcs): 127 | results.append(x) 128 | 129 | async def bar() -> None: 130 | x = await runner.run(lambda: "bar result") 131 | results.append(x) 132 | 133 | # Spawn foo(), which submits lots of functions to the pool, and bar() next. 134 | # If we are not batching the functions in run_parallel(), foo would send all its functions 135 | # in one go, which would cause bar()'s result to appear at the end of the 136 | # results list. 137 | count = 100 138 | runner.start_coroutine(foo(count)) 139 | runner.start_coroutine(bar()) 140 | qtbot.waitUntil(runner.is_idle) 141 | 142 | # We check that bar() results is in the first quarter of the list; if 143 | # we are not batching the functions, it almost always appears as the last 144 | # item, or rarely as one of the few last items. 145 | assert "bar result" in results[: count // 4] 146 | 147 | 148 | def test_run_parallel_stop_midway(qtbot: QtBot, runner: QtAsyncRunner) -> None: 149 | """ 150 | Ensure we can stop in the middle of ``async for``. 151 | """ 152 | results: list[int] = [] 153 | 154 | async def foo(count: int) -> None: 155 | assert_is_main_thread() 156 | funcs = [partial(double, x) for x in range(count)] 157 | async for result in runner.run_parallel(funcs): 158 | if len(results) >= count // 2: 159 | break 160 | results.append(result) 161 | 162 | count = 100 163 | runner.start_coroutine(foo(count)) 164 | qtbot.waitUntil(runner.is_idle) 165 | 166 | assert len(results) == 50 167 | 168 | 169 | def test_ensure_parallel(qtbot: QtBot, qapp: QApplication, runner: QtAsyncRunner) -> None: 170 | """ 171 | Ensure the ``run_parallel`` executes functions in parallel. 172 | """ 173 | 174 | executed_calls: set[str] = set() 175 | barrier = Barrier(parties=2, timeout=5.0) 176 | 177 | def slow_function(call_id: str) -> str: 178 | barrier.wait() 179 | executed_calls.add(call_id) 180 | return call_id 181 | 182 | async def execute() -> None: 183 | funcs = [ 184 | partial(slow_function, "call1"), 185 | partial(slow_function, "call2"), 186 | ] 187 | async for result in runner.run_parallel(funcs): 188 | assert result in ("call1", "call2") 189 | 190 | runner.start_coroutine(execute()) 191 | qtbot.wait_until(runner.is_idle) 192 | assert executed_calls == {"call1", "call2"} 193 | 194 | 195 | def double(x: int, *, sleep_s: float = 0.0) -> int: 196 | assert_is_another_thread() 197 | if sleep_s > 0.0: 198 | time.sleep(sleep_s) 199 | return x * 2 200 | 201 | 202 | class TestRunAsyncBlocking: 203 | def test_result(self, runner: QtAsyncRunner) -> None: 204 | async def foo() -> int: 205 | return await runner.run(double, 10) 206 | 207 | assert runner.run_coroutine(foo()) == 20 208 | 209 | def test_exception(self, runner: QtAsyncRunner) -> None: 210 | class MyError(Exception): 211 | pass 212 | 213 | def raise_error() -> None: 214 | raise MyError() 215 | 216 | async def foo() -> None: 217 | await runner.run(raise_error) 218 | 219 | with pytest.raises(MyError): 220 | runner.run_coroutine(foo()) 221 | 222 | 223 | def test_close_with_coroutines_executing(runner: QtAsyncRunner, qtbot: QtBot) -> None: 224 | """ 225 | Ensure we drop coroutines in case they return after we already closed the 226 | async runner. 227 | """ 228 | started = threading.Event() 229 | 230 | async def foo() -> None: 231 | assert_is_main_thread() 232 | started.set() 233 | _ = await runner.run(double, 33, sleep_s=2.0) 234 | # We should never reach this point, because as soon as ``started`` 235 | # was set, we call runner.close(), so we will never return from the 236 | # await runner.run() call above. 237 | assert False 238 | 239 | runner.start_coroutine(foo()) 240 | qtbot.waitUntil(started.is_set) 241 | runner.close() 242 | -------------------------------------------------------------------------------- /tests/test_sequential_runner.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Generator 3 | 4 | import pytest 5 | from qt_async_threads import SequentialRunner 6 | 7 | from tests.testing import assert_is_main_thread 8 | 9 | 10 | @pytest.fixture 11 | def sequential_runner() -> Generator[SequentialRunner, None, None]: 12 | with SequentialRunner() as runner: 13 | assert runner.is_idle() is True 14 | yield runner 15 | 16 | 17 | def test_run(sequential_runner: SequentialRunner) -> None: 18 | """ 19 | Functions submitted to ``run`` actually run in the main thread 20 | when using a ``SequentialRunner``. 21 | """ 22 | results: list[int] = [] 23 | 24 | def double(x: int) -> int: 25 | assert_is_main_thread() 26 | return x * 2 27 | 28 | async def foo() -> None: 29 | assert_is_main_thread() 30 | result = await sequential_runner.run(double, 33) 31 | results.append(result) 32 | 33 | sync_func = sequential_runner.to_sync(foo) 34 | sync_func() 35 | assert results == [66] 36 | 37 | 38 | def test_run_parallel(sequential_runner: SequentialRunner) -> None: 39 | """ 40 | Functions submitted to ``run_parallel`` actually run in the main thread 41 | when using a ``SequentialRunner``. 42 | """ 43 | results: list[int] = [] 44 | 45 | def double(x: int) -> int: 46 | assert_is_main_thread() 47 | return x * 2 48 | 49 | async def foo() -> None: 50 | assert_is_main_thread() 51 | funcs = [partial(double, i) for i in range(5)] 52 | async for result in sequential_runner.run_parallel(funcs): 53 | results.append(result) 54 | 55 | sequential_runner.run_coroutine(foo()) 56 | assert results == [0, 2, 4, 6, 8] 57 | 58 | 59 | def test_run_coroutine(sequential_runner: SequentialRunner) -> None: 60 | """ 61 | ``run_coroutine`` returns the result of the async function 62 | immediately. 63 | """ 64 | 65 | def halve(x: int) -> int: 66 | assert_is_main_thread() 67 | return x // 2 68 | 69 | async def foo(x: int) -> int: 70 | assert_is_main_thread() 71 | result = await sequential_runner.run(halve, 44) 72 | return result + x 73 | 74 | assert sequential_runner.run_coroutine(foo(10)) == 44 // 2 + 10 75 | 76 | 77 | def test_run_coroutine_error(sequential_runner: SequentialRunner) -> None: 78 | """SequentialRunner should propagate exceptions naturally.""" 79 | 80 | class MyException(Exception): 81 | pass 82 | 83 | def halve(x: int) -> int: 84 | assert_is_main_thread() 85 | raise MyException() 86 | 87 | async def foo(x: int) -> int: 88 | assert_is_main_thread() 89 | result = await sequential_runner.run(halve, 44) 90 | return result + x # pragma: no cover 91 | 92 | with pytest.raises(MyException): 93 | sequential_runner.run_coroutine(foo(10)) 94 | -------------------------------------------------------------------------------- /tests/testing.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | def assert_is_main_thread() -> None: 5 | """Assert that this function is being called from the main thread.""" 6 | assert threading.current_thread() is threading.main_thread() 7 | 8 | 9 | def assert_is_another_thread() -> None: 10 | """Assert that this function is being called from some thread other than main.""" 11 | assert threading.current_thread() is not threading.main_thread() 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py310, py311}-{pyqt5, pyqt6, pyside2, pyside6} 3 | isolated_build = True 4 | 5 | [testenv] 6 | extras = dev 7 | deps = 8 | pyqt5: PyQt5 >=5.12 9 | pyqt6: PyQt6 >=6.4 10 | pyside2: PySide2 >=5.15 11 | pyside6: PySide6 >=6.4 12 | setenv= 13 | QT_QPA_PLATFORM=offscreen 14 | passenv= 15 | DISPLAY 16 | XAUTHORITY 17 | USER 18 | USERNAME 19 | COLUMNS 20 | commands = 21 | pytest {posargs} 22 | 23 | [testenv:docs] 24 | basepython = python3.10 25 | usedevelop = True 26 | deps = 27 | -r docs/requirements.txt 28 | commands = 29 | sphinx-build -W --keep-going -b html docs docs/_build/html {posargs:} 30 | --------------------------------------------------------------------------------