├── .coveragerc ├── .github └── workflows │ ├── adhoc.yml │ ├── build.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── dist └── requirements.txt ├── docs ├── Makefile ├── _static │ ├── clock.png │ ├── http_client.gif │ └── stopwatch.gif ├── _templates │ └── footer.html ├── advanced.rst ├── conf.py ├── examples.rst ├── favicon.ico ├── index.rst ├── internals.rst ├── make.bat ├── quickstart.rst ├── reference.rst ├── requirements.txt └── usage.rst ├── examples ├── clock.py ├── color.py ├── hit_100.py ├── http_client.py ├── read_out.py ├── requirements.txt ├── runner.py ├── stopwatch1.py ├── stopwatch2.py ├── stopwatch3.py ├── stopwatches.py └── where_am_i.py ├── pyproject.toml ├── requirements.txt ├── src ├── qtinter │ ├── __init__.py │ ├── _base_events.py │ ├── _contexts.py │ ├── _helpers.py │ ├── _ki.py │ ├── _modal.py │ ├── _proactor_events.py │ ├── _selectable.py │ ├── _selector_events.py │ ├── _signals.py │ ├── _slots.py │ ├── _tasks.py │ ├── _unix_events.py │ ├── _windows_events.py │ └── bindings.py └── requirements.txt └── tests ├── asyncio_tests.py ├── binding_raise.py ├── binding_tests.py ├── binding_thread.py ├── dep_test_del.py ├── example_tests.py ├── gui_test_clicked.py ├── import1.py ├── import2.py ├── import3.py ├── import4.py ├── requirements.txt ├── runner1.py ├── runner2.py ├── runner3.py ├── runner4.py ├── shim.py ├── test_events.py ├── test_import.py ├── test_signal.py ├── test_sleep.py ├── test_slot.py └── test_tasks.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = src 3 | branch = True 4 | parallel = True 5 | -------------------------------------------------------------------------------- /.github/workflows/adhoc.yml: -------------------------------------------------------------------------------- 1 | name: adhoc 2 | on: 3 | push: 4 | 5 | jobs: 6 | Unit-Tests: 7 | name: Python-${{ matrix.python-version }}-${{ matrix.qt-binding }}-${{ matrix.platform }} 8 | runs-on: ${{ matrix.platform }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | qt-binding: [PySide6] 13 | python-version: ['3.7'] 14 | platform: [macos-latest] 15 | timeout-minutes: 10 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | architecture: x64 25 | - name: Run system tests 26 | run: python -m test.test_asyncio 27 | - name: Install Qt binding 28 | run: pip install ${{ matrix.qt-binding }} 29 | - name: Install Python packages 30 | run: pip install coverage 31 | - name: Run unit tests 32 | run: coverage run --source=src --append --branch -m unittest discover -s tests 33 | env: 34 | TEST_QT_MODULE: ${{ matrix.qt-binding }} 35 | PYTHONPATH: src 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | 4 | jobs: 5 | Build-Package: 6 | name: Build-Package 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 5 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.10' 16 | architecture: x64 17 | - name: Install build dependencies 18 | run: pip install -r dist/requirements.txt 19 | - name: Build package 20 | run: python -m build 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | schedule: 5 | - cron: '30 14 * * *' 6 | 7 | jobs: 8 | Unit-Tests: 9 | name: Python-${{ matrix.python-version }}-${{ matrix.qt-binding }}-${{ matrix.platform }} 10 | runs-on: ${{ matrix.platform }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 15 | qt-binding: [PyQt5, PyQt6, PySide2, PySide6] 16 | platform: [ubuntu-latest, macos-latest, windows-latest] 17 | exclude: 18 | - qt-binding: PySide2 19 | platform: windows-latest 20 | python-version: '3.11' 21 | timeout-minutes: 10 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | architecture: x64 31 | - name: Run system tests 32 | run: python -m test.test_asyncio 33 | - name: Install Qt dependencies 34 | if: ${{ matrix.platform == 'ubuntu-latest' }} 35 | run: sudo apt-get update && sudo apt-get install libgl1-mesa-dev 36 | # - name: Install Qt binding 37 | # if: ${{ matrix.qt-binding == 'PySide2' }} 38 | # run: pip install PySide2==5.15.2 39 | - name: Install Qt binding 40 | run: pip install ${{ matrix.qt-binding }} 41 | - name: Run Qt binding tests 42 | run: python tests/binding_tests.py -v 43 | env: 44 | TEST_QT_MODULE: ${{ matrix.qt-binding }} 45 | QT_QPA_PLATFORM: offscreen 46 | - name: Install Python packages 47 | run: pip install coverage 48 | - name: Run unit tests 49 | run: coverage run -m unittest discover -s tests -v 50 | env: 51 | TEST_QT_MODULE: ${{ matrix.qt-binding }} 52 | PYTHONPATH: src 53 | - name: Run GUI tests 54 | run: coverage run tests/gui_test_clicked.py 55 | env: 56 | TEST_QT_MODULE: ${{ matrix.qt-binding }} 57 | PYTHONPATH: src 58 | QT_QPA_PLATFORM: offscreen 59 | - name: Run asyncio test suite 60 | run: python tests/asyncio_tests.py 61 | env: 62 | QTINTERBINDING: ${{ matrix.qt-binding }} 63 | PYTHONPATH: src 64 | #- name: Test examples 65 | # run: python tests/example_tests.py 66 | # env: 67 | # QTINTERBINDING: ${{ matrix.qt-binding }} 68 | # PYTHONPATH: src 69 | - name: Combine coverage data 70 | run: coverage combine 71 | - name: Generate coverage report 72 | run: coverage xml 73 | - name: Upload coverage report 74 | uses: codecov/codecov-action@v3 75 | with: 76 | files: ./coverage.xml 77 | fail_ci_if_error: false 78 | verbose: true 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.egg-info/ 3 | __pycache__/ 4 | /coverage.* 5 | /dist/ 6 | /docs/_build/ 7 | /extras/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, fancidev 2 | Copyright (c) 2019, Sam McCormack 3 | Copyright (c) 2018, Gerard Marull-Paretas 4 | Copyright (c) 2014-2018, Mark Harviston, Arve Knudsen 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qtinter — Interop between asyncio and Qt for Python 2 | 3 | [![codecov](https://codecov.io/gh/fancidev/qtinter/branch/master/graph/badge.svg?token=JZ5ON6CHKA)](https://codecov.io/gh/fancidev/qtinter) 4 | [![docs](https://readthedocs.org/projects/qtinter/badge/?version=latest)](https://qtinter.readthedocs.io/en/latest/?badge=latest) 5 | [![tests](https://github.com/fancidev/qtinter/actions/workflows/tests.yml/badge.svg)](https://github.com/fancidev/qtinter/actions/workflows/tests.yml) 6 | [![PyPI](https://img.shields.io/pypi/v/qtinter)](https://pypi.org/project/qtinter/) 7 | 8 | `qtinter` is a Python module that brings together asyncio and Qt 9 | for Python, allowing you to use one from the other seamlessly. 10 | 11 | Read the [full documentation](https://qtinter.readthedocs.io) or check out the quickstart below. 12 | 13 | ## Installation 14 | 15 | ```commandline 16 | $ pip install qtinter 17 | ``` 18 | 19 | ## Using asyncio from Qt 20 | 21 | To use asyncio-based libraries in Qt for Python, enclose `app.exec()` 22 | inside context manager `qtinter.using_asyncio_from_qt()`. 23 | 24 | Example (taken from `examples/clock.py`): 25 | 26 | ```Python 27 | """Display LCD-style digital clock""" 28 | 29 | import asyncio 30 | import datetime 31 | import qtinter # <-- import module 32 | from PySide6 import QtWidgets 33 | 34 | class Clock(QtWidgets.QLCDNumber): 35 | def __init__(self, parent=None): 36 | super().__init__(parent) 37 | self.setDigitCount(8) 38 | 39 | def showEvent(self, event): 40 | self._task = asyncio.create_task(self._tick()) 41 | 42 | def hideEvent(self, event): 43 | self._task.cancel() 44 | 45 | async def _tick(self): 46 | while True: 47 | t = datetime.datetime.now() 48 | self.display(t.strftime("%H:%M:%S")) 49 | await asyncio.sleep(1.0 - t.microsecond / 1000000 + 0.05) 50 | 51 | if __name__ == "__main__": 52 | app = QtWidgets.QApplication([]) 53 | 54 | widget = Clock() 55 | widget.setWindowTitle("qtinter - Digital Clock example") 56 | widget.resize(300, 50) 57 | 58 | with qtinter.using_asyncio_from_qt(): # <-- enable asyncio in qt code 59 | widget.show() 60 | app.exec() 61 | ``` 62 | 63 | ## Using Qt from asyncio 64 | 65 | To use Qt components from asyncio-based code, enclose the asyncio 66 | entry-point inside context manager `qtinter.using_qt_from_asyncio()`. 67 | 68 | Example (taken from `examples/color.py`): 69 | 70 | ```Python 71 | """Display the RGB code of a color chosen by the user""" 72 | 73 | import asyncio 74 | import qtinter # <-- import module 75 | from PySide6 import QtWidgets 76 | 77 | async def choose_color(): 78 | dialog = QtWidgets.QColorDialog() 79 | dialog.show() 80 | future = asyncio.Future() 81 | dialog.finished.connect(future.set_result) 82 | result = await future 83 | if result == QtWidgets.QDialog.DialogCode.Accepted: 84 | return dialog.selectedColor().name() 85 | else: 86 | return None 87 | 88 | if __name__ == "__main__": 89 | app = QtWidgets.QApplication([]) 90 | with qtinter.using_qt_from_asyncio(): # <-- enable qt in asyncio code 91 | color = asyncio.run(choose_color()) 92 | if color is not None: 93 | print(color) 94 | ``` 95 | 96 | ## Using modal dialogs 97 | 98 | To execute a modal dialog without blocking the asyncio event loop, 99 | wrap the dialog entry-point in `qtinter.modal()` and `await` on it. 100 | 101 | Example (taken from `examples/hit_100.py`): 102 | 103 | ```Python 104 | import asyncio 105 | import qtinter 106 | from PySide6 import QtWidgets 107 | 108 | async def main(): 109 | async def counter(): 110 | nonlocal n 111 | while True: 112 | print(f"\r{n}", end='', flush=True) 113 | await asyncio.sleep(0.025) 114 | n += 1 115 | 116 | n = 0 117 | counter_task = asyncio.create_task(counter()) 118 | await qtinter.modal(QtWidgets.QMessageBox.information)( 119 | None, "Hit 100", "Click OK when you think you hit 100.") 120 | counter_task.cancel() 121 | if n == 100: 122 | print("\nYou did it!") 123 | else: 124 | print("\nTry again!") 125 | 126 | if __name__ == "__main__": 127 | app = QtWidgets.QApplication([]) 128 | with qtinter.using_qt_from_asyncio(): 129 | asyncio.run(main()) 130 | ``` 131 | 132 | 133 | ## Requirements 134 | 135 | `qtinter` supports the following: 136 | 137 | - Python version: 3.7 or higher 138 | - Qt binding: PyQt5, PyQt6, PySide2, PySide6 139 | - Operating system: Linux, MacOS, Windows 140 | 141 | 142 | ## License 143 | 144 | BSD License. 145 | 146 | 147 | ## Contributing 148 | 149 | Please raise an issue if you have any questions. Pull requests are more 150 | than welcome! 151 | 152 | 153 | ## Credits 154 | 155 | `qtinter` is derived from 156 | [qasync](https://github.com/CabbageDevelopment/qasync) but rewritten from 157 | scratch. qasync is derived from 158 | [asyncqt](https://github.com/gmarull/asyncqt), which is derived from 159 | [quamash](https://github.com/harvimt/quamash). 160 | -------------------------------------------------------------------------------- /dist/requirements.txt: -------------------------------------------------------------------------------- 1 | # The following packages are needed for packaging qtinter. 2 | build 3 | setuptools 4 | setuptools-scm 5 | twine 6 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fancidev/qtinter/HEAD/docs/_static/clock.png -------------------------------------------------------------------------------- /docs/_static/http_client.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fancidev/qtinter/HEAD/docs/_static/http_client.gif -------------------------------------------------------------------------------- /docs/_static/stopwatch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fancidev/qtinter/HEAD/docs/_static/stopwatch.gif -------------------------------------------------------------------------------- /docs/_templates/footer.html: -------------------------------------------------------------------------------- 1 | {% extends '!footer.html' %} 2 | {% block extrafooter %} 3 | Favicon designed by Loop icons created by Dreamstale - Flaticon. 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | Advanced Topics 4 | =============== 5 | 6 | :mod:`qtinter` implements a *logical* asyncio event loop on top of a 7 | *physical* Qt event loop, allowing Qt objects and asyncio coroutines 8 | to run in the same thread and thus integrate seamlessly. 9 | 10 | Best Practice 11 | ------------- 12 | 13 | Clean-up 14 | -------- 15 | 16 | The :func:`using_asyncio_from_qt` context manager handles clean-up 17 | automatically. The :func:`using_qt_from_asyncio` context manager 18 | restores the default event loop policy upon exit; loop-level clean-up 19 | is handled by :func:`asyncio.run`. 20 | 21 | If you create and manipulate a :class:`QiBaseEventLoop` directly, 22 | you should do the proper clean-up after it is no longer used. The 23 | steps are: 24 | 25 | 1. Cancel all pending tasks. 26 | 27 | 2. Wait for all pending tasks to complete. 28 | 29 | 3. Run :meth:`asyncio.loop.shutdown_asyncgens`. 30 | 31 | 4. Run :meth:`asyncio.loop.shutdown_default_executor` (since Python 3.9). 32 | 33 | 5. Call :meth:`asyncio.loop.close`. 34 | 35 | Steps 2-4 are coroutines and therefore must be run from within the event 36 | loop. 37 | 38 | .. note:: 39 | 40 | At the point of clean-up, a Qt event loop may no longer exist and 41 | is not creatable if ``QCoreApplication.exit()`` has been called. 42 | Therefore the :func:`using_asyncio_from_qt` context manager runs 43 | Steps 2-4 in a *physical* asyncio event loop. 44 | 45 | 46 | Qt binding resolution 47 | --------------------- 48 | 49 | :mod:`qtinter` checks for the Qt binding used by the process 50 | (interpreter) the first time a :func:`qtinter.QiBaseEventLoop` 51 | is run. It remembers this binding afterwards. 52 | 53 | If exactly one of ``PyQt5``, ``PyQt6``, ``PySide2`` or ``PySide6`` is 54 | imported in :external:data:`sys.modules` at the time of binding lookup, 55 | it is chosen. 56 | 57 | If none of the above modules are imported at the time of lookup, 58 | the environment variable ``QTINTERBINDING`` is checked. If it is 59 | set to one of ``PyQt5``, ``PyQt6``, ``PySide2`` or ``PySide6``, 60 | that binding is used; otherwise, :external:exc:`ImportError` is raised. 61 | 62 | If more than one supported binding modules are imported at the time of 63 | lookup, :external:exc:`ImportError` is raised. 64 | 65 | 66 | Handling keyboard interrupt 67 | --------------------------- 68 | 69 | 70 | Writing portable code 71 | --------------------- 72 | 73 | 74 | Related libraries 75 | ----------------- 76 | 77 | qasync, qtrio, qtpy, bindings, uvloop 78 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import setuptools_scm 10 | 11 | project = 'qtinter' 12 | copyright = '2022, fancidev' 13 | author = 'fancidev' 14 | release = setuptools_scm.get_version(root='..', relative_to=__file__) 15 | # release = '0.7.0' 16 | 17 | # -- General configuration --------------------------------------------------- 18 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 19 | 20 | extensions = [ 21 | 'sphinx.ext.intersphinx', 22 | 'sphinx_rtd_theme', 23 | 'sphinx_togglebutton', 24 | ] 25 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 26 | 27 | templates_path = ['_templates'] 28 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 29 | 30 | add_function_parentheses = True 31 | add_module_names = True 32 | 33 | # -- Options for HTML output ------------------------------------------------- 34 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 35 | 36 | html_theme = 'sphinx_rtd_theme' 37 | html_static_path = ['_static'] 38 | html_favicon = 'favicon.ico' 39 | html_domain_indices = False 40 | 41 | # -- Options for Latex output ------------------------------------------------ 42 | 43 | latex_domain_indices = False 44 | 45 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | Examples 4 | ======== 5 | 6 | This page shows a few examples that demonstrate the usage of :mod:`qtinter`. 7 | For each example, the source code is listed, and the lines that demonstrate 8 | the usage of :mod:`qtinter`'s API are highlighted. 9 | 10 | 11 | .. _color-example: 12 | 13 | Color Chooser 14 | ------------- 15 | 16 | This example implements a command-line utility that displays the RGB value 17 | of a color chosen by the user from a color dialog. 18 | 19 | It demonstrates the use of :func:`using_qt_from_asyncio` to add Qt support 20 | to an asyncio-based program. 21 | 22 | Sample output: 23 | 24 | .. code-block:: console 25 | 26 | $ python color.py 27 | #ff8655 28 | 29 | Source code (``examples/color.py``): 30 | 31 | .. literalinclude:: ../examples/color.py 32 | :language: python 33 | :emphasize-lines: 5,20 34 | :linenos: 35 | 36 | 37 | .. _clock-example: 38 | 39 | Digital Clock 40 | ------------- 41 | 42 | This example displays an LCD-style digital clock. 43 | 44 | It demonstrates the use of :func:`using_asyncio_from_qt` to add 45 | asyncio support to a Qt application. 46 | 47 | Sample screenshot: 48 | 49 | .. image:: _static/clock.png 50 | :scale: 50% 51 | 52 | Source code (``examples/clock.py``): 53 | 54 | .. literalinclude:: ../examples/clock.py 55 | :language: python 56 | :emphasize-lines: 5,32 57 | :linenos: 58 | 59 | 60 | .. _http-client-example: 61 | 62 | Http Client 63 | ----------- 64 | 65 | This example shows how to download a web page asynchronously using the 66 | ``httpx`` module and optionally cancel the download. 67 | 68 | .. image:: _static/http_client.gif 69 | 70 | Source code: 71 | 72 | .. toggle:: 73 | 74 | .. literalinclude:: ../examples/http_client.py 75 | :language: python 76 | :emphasize-lines: 4,68,218 77 | :linenos: 78 | 79 | 80 | .. _read-out-example: 81 | 82 | Read Out 83 | -------- 84 | 85 | .. _QtTextToSpeech: https://doc-snapshots.qt.io/qt6-dev/qttexttospeech-index.html 86 | 87 | .. _say: https://ss64.com/osx/say.html 88 | 89 | This example implements a command line utility that reads out the text 90 | from standard input. It is a cross-platform version of the macOS `say`_ 91 | command. 92 | 93 | The example demonstrates the use of :func:`using_qt_from_asyncio` to 94 | use a Qt component (`QtTextToSpeech`_) in asyncio-driven code, and the 95 | use of :func:`asyncsignal` to wait for a Qt signal. 96 | 97 | .. note:: 98 | 99 | On Unix, press ``Ctrl+D`` to terminate input. On Windows, press ``Ctrl+Z``. 100 | 101 | Sample output (on macOS 12): 102 | 103 | .. code-block:: console 104 | 105 | $ python read_out.py -h 106 | usage: read_out.py [options] 107 | Read out text from stdin. 108 | Options: 109 | -e Echo each line before reading it out 110 | -h Show this screen and exit 111 | -l locale One of en_US, fr_FR (default: en_US) 112 | -p pitch Number between -1.0 and +1.0 (default: 0.0) 113 | -r rate Number between -1.0 and +1.0 (default: 0.0) 114 | -v voice One of Alex, Fiona, Fred, Samantha, Victoria (default: Alex) 115 | 116 | Source code: 117 | 118 | .. literalinclude:: ../examples/read_out.py 119 | :language: python 120 | :emphasize-lines: 6,22,77 121 | :linenos: 122 | 123 | 124 | .. _where-am-i-example: 125 | 126 | Where am I 127 | ---------- 128 | 129 | .. _QtPositioning: https://doc-snapshots.qt.io/qt6-dev/qtpositioning-index.html 130 | 131 | This example implements a command line utility that prints the current 132 | geolocation. 133 | 134 | It demonstrates the use of :func:`using_qt_from_asyncio` to use 135 | a Qt component (`QtPositioning`_) in asyncio-driven code. 136 | It also demonstrates the use of :func:`asyncsignal` and 137 | :func:`multisignal` to wait for the first of multiple Qt signals. 138 | 139 | Sample output: 140 | 141 | .. code-block:: console 142 | 143 | $ python where_am_i.py 144 | 12° 34' 56.7" N, 98° 76' 54.3" E, 123.456m 145 | 146 | Source code: 147 | 148 | .. literalinclude:: ../examples/where_am_i.py 149 | :language: python 150 | :emphasize-lines: 5,23,42 151 | :linenos: 152 | 153 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fancidev/qtinter/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. module:: qtinter 2 | :synopsis: Interop between asyncio and Qt for Python 3 | 4 | qtinter --- Interop between asyncio and Qt for Python 5 | ===================================================== 6 | 7 | :mod:`qtinter` is a Python module that brings together asyncio and Qt 8 | for Python, allowing you to use one from the other seamlessly. 9 | 10 | :mod:`qtinter` strives to be **simple** and **reliable**: 11 | 12 | * *Simple*: You only need to add **two lines of code** to use asyncio 13 | from Qt and vice versa. **No refactoring** of your existing codebase 14 | is required. 15 | 16 | * *Reliable*: :mod:`qtinter` features **100% code coverage** and passes 17 | the **entire asyncio test suite**. Rest assured that your favorite 18 | asyncio or Qt component will work. 19 | 20 | Read on for detailed documentation on :mod:`qtinter`. 21 | 22 | .. toctree:: 23 | :maxdepth: 2 24 | 25 | quickstart 26 | examples 27 | usage 28 | advanced 29 | reference 30 | internals 31 | -------------------------------------------------------------------------------- /docs/internals.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | Under the Hood 4 | ============== 5 | 6 | This page explains how :mod:`qtinter` is implemented. 7 | 8 | We first give an overview of how :external:mod:`asyncio` works. 9 | We then give an overview of how Qt works. We then explain how 10 | :mod:`qtinter` bridges the two. 11 | 12 | 13 | .. _loop-modes: 14 | 15 | Loop modes 16 | ---------- 17 | 18 | A :class:`QiBaseEventLoop` has three *modes of operations*: 19 | *owner mode*, *guest mode*, and *native mode*. 20 | 21 | .. note:: 22 | 23 | Both owner mode and guest mode require a ``QtCore.QCoreApplication``, 24 | ``QtGui.QGuiApplication`` or ``QtWidgets.QApplication`` instance to 25 | exist in order to run the loop. This is because these modes use Qt's 26 | signal-slot mechanism to schedule callbacks. 27 | 28 | Owner mode 29 | ~~~~~~~~~~ 30 | 31 | *Owner mode* provides 100% asyncio event loop semantics. It should be used 32 | if your code calls :func:`asyncio.run` or equivalent as its entry point. 33 | 34 | You normally launch a :class:`QiBaseEventLoop` in host mode using the 35 | :func:`using_qt_from_asyncio` context manager. Alternatively, call 36 | :func:`new_event_loop` to create a :class:`QiBaseEventLoop` in host mode 37 | and then manipulate it manually. 38 | 39 | .. note:: 40 | 41 | :class:`QiBaseEventLoop` executes a ``QtCore.QEventLoop`` when 42 | operating in owner mode. If a Qt event loop is already running, 43 | the new loop will run nested, which may cause the usual subtle 44 | consequences with nested loops and therefore is not recommended. 45 | If you already have a Qt event loop running and want to use asyncio 46 | functionalities, use the :func:`using_asyncio_from_qt` context 47 | manager instead. 48 | 49 | .. note:: 50 | 51 | If ``QtCore.QCoreApplication.exit()`` has been called, it will be 52 | no longer possible to start a ``QtCore.QEventLoop`` and hence not 53 | possible to run a :class:`QiBaseEventLoop` in owner mode. You 54 | may run the :class:`QiBaseEventLoop` in native mode if needed. 55 | 56 | Guest mode 57 | ~~~~~~~~~~ 58 | 59 | *Guest mode* runs a *logical* asyncio event loop on top of a *physical* 60 | Qt event loop. It is designed to enable asyncio access for Qt-driven 61 | code. 62 | 63 | Guest mode is normally activated using the :func:`using_asyncio_from_qt` 64 | context manager. Under the hood, the context manager calls 65 | :func:`new_event_loop` to create a :class:`QiBaseEventLoop` object 66 | and then calls its :meth:`QiBaseEventLoop.set_mode` method with 67 | argument :data:`QiLoopMode.GUEST`. 68 | 69 | The physical Qt event loop must be run by the application code, 70 | e.g. by calling ``app.exec()``. 71 | 72 | .. note:: 73 | 74 | In guest mode, the running state of the logical asyncio event loop 75 | is decoupled from and independent of the running state of the physical 76 | Qt event loop. 77 | 78 | Native mode 79 | ~~~~~~~~~~~ 80 | 81 | A :class:`QiBaseEventLoop` in *native mode* runs a *physical* asyncio 82 | event loop and behaves exactly like a standard asyncio event loop; 83 | no Qt functionality is involved. 84 | 85 | Native mode is activated by the :func:`using_asyncio_from_qt` context 86 | manager in its clean-up code before running the coroutines to cancel 87 | pending tasks and shutdown async generators. This mode allows 88 | coroutines to run even after ``QtCore.QCoreApplication.exec`` 89 | has been called. 90 | 91 | To manually activate native mode, call :meth:`QiBaseEventLoop.set_mode` 92 | with argument :data:`QiLoopMode.NATIVE`. 93 | 94 | .. note:: 95 | 96 | Because no Qt event loop is running in native mode, you should not 97 | use any Qt objects in this mode. In particular, the clean-up code 98 | in your coroutines should work without requiring a running Qt event 99 | loop. 100 | 101 | 102 | Interleaved code 103 | ---------------- 104 | 105 | By implementing a (logical) asyncio event loop on top of a (physical) 106 | Qt event loop, what's not changed (from the perspective of the asyncio 107 | event loop) is that all calls (other than call_soon_threadsafe) are 108 | still made from the same thread. This frees us from multi-threading 109 | complexities. 110 | 111 | What has changed, however, is that in a physical asyncio event loop, 112 | no code can run when the scheduler (specifically, _run_once) is blocked 113 | in select(), while in a logical asyncio event loop, a select() call that 114 | would otherwise block yields, allowing any code to run while the loop 115 | is "logically" blocked in select. 116 | 117 | For example, BaseEventLoop.stop() is implemented by setting the flag 118 | ``_stopping`` to True, which is then checked at the end of the iteration 119 | to stop the loop. This works because stop can only ever be called from 120 | a callback, and a callback can only ever be called after select returns 121 | and before the next iteration of _run_once. The behavior changes if select 122 | yields and stop is called -- the event loop will not wake up until some 123 | IO is available. 124 | 125 | We refer to code that runs (from the Qt event loop) after select yields 126 | and before _run_once is called again as *interleaved code*. We must 127 | examine and handle the implications of such code. 128 | 129 | We do this by fitting interleaved code execution into the 'classical' 130 | asyncio event loop model. Specifically, we treat interleaved code as 131 | if they were scheduled with :meth:`asyncio.loop.call_soon_threadsafe`, 132 | which wakes up the selector and executes the code. With some loss of 133 | generality, we assume no IO event or timed callback is ready at the 134 | exact same time, so that the scheduler will be put back into blocking 135 | select immediately after the code finishes running (unless the code 136 | calls stop). This simplification is acceptable because the precise 137 | timing of multiple IO or timer events should not be relied upon. 138 | 139 | In practice, we cannot actually wake up the asyncio scheduler every 140 | time interleaved code is executed, firstly because there's no way to 141 | detect their execution, and secondly because doing so would be highly 142 | inefficient. Instead, we assume that interleaved code that does not 143 | access the event loop object or its selector is benign enough to be 144 | treated as independent from the asyncio event loop mechanism and may 145 | thus be safely ignored. 146 | 147 | This leaves us to just consider interleaved code that accesses the 148 | event loop object or its selector and examine its impact on scheduling. 149 | The scheduler depends on three things: the ``_ready`` queue for "soon" 150 | callbacks, the ``_scheduled`` queue for timer callbacks, and ``_selector`` 151 | for IO events. If the interleaved code touches any of these things, 152 | it needs to be handled. 153 | 154 | While the public interface of :class:`asyncio.AbstractEventLoop` has 155 | numerous methods, the methods that modify those three things boil down 156 | to :meth:`asyncio.loop.call_soon`, :meth:`asyncio.loop.call_at`, 157 | :meth:`asyncio.loop.call_later`, (arguably) :meth:`asyncio.loop.stop`, 158 | and anything that modifies the selector (proactor). When any of these 159 | happens, we physically or logically wake up the selector to simulate 160 | a call to :meth:`asyncio.loop.call_soon_threadsafe`. 161 | 162 | 163 | .. _eager-execution: 164 | 165 | Eager execution 166 | --------------- 167 | 168 | When the wrapper function returned by :func:`asyncslot` is called, it 169 | calls :meth:`QiBaseEventLoop.run_task`, which creates a task wrapping 170 | the coroutine and *eagerly executes* the first *step* of the task. 171 | 172 | This *eager execution* feature would lead to task nesting if the wrapper 173 | function is called from a coroutine. Scenarios that lead to the wrapper 174 | function being called from a coroutine include: 175 | 176 | - directly calling or awaiting the wrapper; 177 | 178 | - emitting a signal to which the wrapper is connected by a direct connection; 179 | 180 | - starting a nested Qt event loop (without using :func:`modal`) on which 181 | a signal connected to the wrapper is emitted. 182 | 183 | asyncio does not allow task nesting. Yet some of the above scenarios are 184 | valid and cannot be systematically avoided. To make :func:`asyncslot` 185 | useful in practice, :class:`QiBaseEventLoop` extends asyncio's semantics 186 | to support a particular form of task nesting, namely: 187 | 188 | If :meth:`QiBaseEventLoop.run_task` is called when there is an active 189 | task running, that task is automatically 'suspended' when the call 190 | begins and 'resumed' after the call returns. 191 | 192 | This extension only applies to :meth:`QiBaseEventLoop.run_task` and is 193 | therefore "opt-in": Code that does not call :func:`asyncslot` or 194 | :meth:`QiBaseEventLoop.run_task` retains full compliance with asyncio's 195 | semantics. 196 | 197 | .. note:: 198 | 199 | An alternative implementation of :meth:`QiBaseEventLoop.run_task` 200 | that is free of task nesting by construction is to execute the 201 | first step of the coroutine in the caller's context instead of 202 | in its own task context. 203 | 204 | The main problem with this approach is that there is no natural 205 | way to retrieve the task object that wraps the remainder of the 206 | coroutine: 207 | 208 | - It cannot be retrieved within the first step of the coroutine 209 | because a task object for the remainder is not created yet; and 210 | 211 | - If returned directly to the caller, it offers no advantage 212 | over calling :func:`asyncio.create_task` directly to obtain 213 | the task object. 214 | 215 | In addition, that part of a coroutine may run out of a task context 216 | (if invoked from a callback) is just surprising. 217 | 218 | Due to these problems, we choose the current implementation in favor 219 | of this alternative. 220 | 221 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | Getting Started 4 | =============== 5 | 6 | This section shows the essentials of :mod:`qtinter` and gets you up 7 | and running in five minutes. 8 | 9 | 10 | Requirements 11 | ------------ 12 | 13 | :mod:`qtinter` supports the following: 14 | 15 | - Python version: 3.7 and higher 16 | 17 | - Qt binding: PyQt5, PyQt6, PySide2, PySide6 18 | 19 | - Operating system: Linux, MacOS, Windows 20 | 21 | 22 | 23 | Installation 24 | ------------ 25 | 26 | :mod:`qtinter` is installed via ``pip``: 27 | 28 | .. code-block:: console 29 | 30 | $ pip install qtinter 31 | 32 | The above command does *not* install any Qt bindings because it is 33 | assumed that you already have one. If that's not the case, you may 34 | install a Qt binding together with :mod:`qtinter` using the 35 | following command: 36 | 37 | .. code-block:: console 38 | 39 | $ pip install qtinter[PyQt6] 40 | 41 | Replace ``PyQt6`` with one of ``PyQt5``, ``PyQt6``, ``PySide2`` or 42 | ``PySide6`` of your choice. 43 | 44 | 45 | Using asyncio from Qt 46 | --------------------- 47 | 48 | If your code uses Qt as its entry point (e.g. by calling ``app.exec()``) 49 | and you want to use an asyncio-based library, follow these steps. 50 | 51 | Step 1 --- import :mod:`qtinter`: 52 | 53 | .. code-block:: python 54 | 55 | import qtinter 56 | 57 | Step 2 --- enclose the Qt entry point inside 58 | :func:`qtinter.using_asyncio_from_qt` context manager: 59 | 60 | .. code-block:: python 61 | 62 | app = QtWidgets.QApplication([]) 63 | with qtinter.using_asyncio_from_qt(): 64 | app.exec() 65 | 66 | Step 3 --- (optionally) connect coroutine functions to Qt signals by 67 | wrapping them with :func:`qtinter.asyncslot`: 68 | 69 | .. code-block:: python 70 | 71 | my_signal.connect(qtinter.asyncslot(my_coroutine_function)) 72 | 73 | And that's it! 74 | 75 | To see these in action, check out the :ref:`clock-example` example 76 | and the :ref:`http-client-example` example. 77 | For usage details, see :ref:`using-using-asyncio-from-qt` and 78 | :ref:`using-asyncslot`. 79 | 80 | 81 | Using Qt from asyncio 82 | --------------------- 83 | 84 | If your code uses asyncio as its entry point (e.g. by calling 85 | :func:`asyncio.run()`) and you want to use a Qt component, follow these steps. 86 | 87 | Step 1 --- import :mod:`qtinter`: 88 | 89 | .. code-block:: python 90 | 91 | import qtinter 92 | 93 | Step 2 --- enclose the asyncio entry point inside 94 | :func:`qtinter.using_qt_from_asyncio` context manager: 95 | 96 | .. code-block:: python 97 | 98 | app = QtWidgets.QApplication([]) 99 | with qtinter.using_qt_from_asyncio(): 100 | asyncio.run(my_coro()) 101 | 102 | Step 3 --- (optionally) wait for Qt signals by wrapping them with 103 | :func:`qtinter.asyncsignal`: 104 | 105 | .. code-block:: python 106 | 107 | await qtinter.asyncsignal(button.clicked) 108 | 109 | And that's it! 110 | 111 | To see these in action, check out the :ref:`read-out-example` example 112 | and the :ref:`where-am-i-example` example. 113 | For usage details, see :ref:`using-using-qt-from-asyncio` and 114 | :ref:`using-asyncsignal`. 115 | 116 | 117 | Using modal dialogs 118 | ------------------- 119 | 120 | By default, opening a modal dialog from a coroutine or callback blocks 121 | the asyncio event loop until the dialog is closed. 122 | 123 | To get the asyncio event loop running without the hazard of potential 124 | re-entrance, wrap the dialog entry-point in :func:`modal` and ``await`` 125 | on it. For example: 126 | 127 | .. code-block:: python 128 | 129 | await qtinter.modal(QtWidgets.QMessageBox.warning)(self, "Title", "Message") 130 | 131 | What's great about the above is that the asyncio event loop remains 132 | free of nesting, and the presence of ``await`` makes the suspension 133 | point crystal clear. 134 | 135 | For further details, see :ref:`using-modal`. 136 | 137 | 138 | License 139 | ------- 140 | 141 | BSD License. 142 | 143 | 144 | Contributing 145 | ------------ 146 | 147 | .. _GitHub: https://github.com/fancidev/qtinter 148 | 149 | The source code is hosted on `GitHub`_. Please raise an issue if you have 150 | any questions. Pull requests are more than welcome! 151 | 152 | 153 | Credits 154 | ------- 155 | 156 | :mod:`qtinter` is derived from qasync_ but rewritten from scratch. qasync_ 157 | is derived from asyncqt_, which is derived from quamash_. 158 | 159 | .. _qasync: https://github.com/CabbageDevelopment/qasync 160 | .. _asyncqt: https://github.com/gmarull/asyncqt 161 | .. _quamash: https://github.com/harvimt/quamash 162 | 163 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | API Reference 4 | ============= 5 | 6 | :mod:`qtinter` provides the following functions and classes in its 7 | public API: 8 | 9 | `Context managers`_ for asyncio-Qt interop: 10 | 11 | * :func:`using_asyncio_from_qt` enables Qt-driven code 12 | to use asyncio-based components. 13 | 14 | * :func:`using_qt_from_asyncio` enables asyncio-driven code 15 | to use Qt-based components. 16 | 17 | 18 | `Helper functions`_ to make interp code fit naturally into the 19 | current coding pattern: 20 | 21 | * :func:`asyncsignal` makes a Qt signal *awaitable*; 22 | useful for asyncio-driven code. 23 | 24 | * :func:`asyncsignalstream` exposes a Qt signal as an asynchronous 25 | iterator; useful for asyncio-driven code. 26 | 27 | * :func:`asyncslot` connects a coroutine function 28 | to a Qt signal; useful for Qt-driven code. 29 | 30 | * :func:`modal` allows the asyncio event loop to continue running 31 | in a nested Qt event loop. 32 | 33 | * :func:`multisignal` collects multiple Qt signals and re-emits 34 | them with a tag. 35 | 36 | * :func:`run_task` creates an :class:`asyncio.Task` and eagerly 37 | executes its first step. 38 | 39 | 40 | `Loop factory`_ to create `event loop objects`_ directly: 41 | 42 | * :func:`new_event_loop` creates an asyncio-compatible *logical* 43 | event loop object that runs on top of a *physical* Qt event loop. 44 | 45 | 46 | `Low-level classes`_ that do the actual work of bridging Qt and asyncio: 47 | 48 | * `Event loop interface`_ 49 | 50 | * `Event loop objects`_ 51 | 52 | * `Event loop policy objects`_ 53 | 54 | 55 | `Private API`_ that supports the internal implementation of :mod:`qtinter`. 56 | 57 | 58 | Context managers 59 | ---------------- 60 | 61 | .. function:: using_asyncio_from_qt() 62 | 63 | Context manager that enables enclosed *Qt-driven* code to use 64 | asyncio-based libraries. 65 | 66 | Your code is *Qt-driven* if it calls ``app.exec()`` or equivalent 67 | as its entry point. 68 | 69 | Example: 70 | 71 | .. code-block:: python 72 | 73 | app = QtWidgets.QApplication([]) 74 | with qtinter.using_asyncio_from_qt(): 75 | app.exec() 76 | 77 | .. function:: using_qt_from_asyncio() 78 | 79 | Context manager that enables enclosed *asyncio-driven* code to use 80 | Qt components. 81 | 82 | Your code is *asyncio-driven* if it calls :func:`asyncio.run()` or 83 | equivalent as its entry point. 84 | 85 | .. note:: 86 | 87 | This context manager modifies the global (per-interpreter) asyncio 88 | event loop policy. Do not use this context manager if your code 89 | uses event loops from multiple threads. 90 | Instead, call :func:`new_event_loop` to create an 91 | event loop object and call its methods directly. 92 | Since Python 3.11, use :class:`asyncio.Runner` and pass 93 | :class:`new_event_loop` as its *loop_factory* parameter. 94 | 95 | 96 | Helper functions 97 | ---------------- 98 | 99 | .. function:: asyncsignal(signal: BoundSignal[typing.Unpack[Ts]]) -> typing.Tuple[typing.Unpack[Ts]] 100 | :async: 101 | 102 | Wait for *signal* to emit and return the emitted arguments in a 103 | :class:`tuple`. 104 | 105 | .. _PyQt5.QtCore.pyqtSignal: https://www.riverbankcomputing.com/static/Docs/PyQt5/signals_slots.html#PyQt5.QtCore.pyqtSignal 106 | .. _PyQt6.QtCore.pyqtSignal: https://www.riverbankcomputing.com/static/Docs/PyQt6/signals_slots.html#PyQt6.QtCore.pyqtSignal 107 | .. _PySide2.QtCore.Signal: https://doc.qt.io/qtforpython-5/PySide2/QtCore/Signal.html 108 | .. _PySide6.QtCore.Signal: https://doc.qt.io/qtforpython/PySide6/QtCore/Signal.html#PySide6.QtCore.PySide6.QtCore.Signal 109 | 110 | .. _AutoConnection: https://doc.qt.io/qt-6/qt.html#ConnectionType-enum 111 | 112 | *signal* must be a bound Qt signal object, i.e. a bound 113 | `PyQt5.QtCore.pyqtSignal`_, `PyQt6.QtCore.pyqtSignal`_, 114 | `PySide2.QtCore.Signal`_ or `PySide6.QtCore.Signal`_, or 115 | an object with a ``connect`` method of equivalent semantics, 116 | such as an instance of :class:`multisignal`. 117 | 118 | *signal* is connected to using an `AutoConnection`_ when the 119 | returned coroutine object is awaited. It is disconnected from 120 | after the signal is emitted once. 121 | 122 | .. _proxyAuthenticationRequired: https://doc.qt.io/qt-6/qwebsocket.html#proxyAuthenticationRequired 123 | 124 | .. note:: 125 | 126 | Signals that require immediate response from the slot cannot be used 127 | with this function. An example is `proxyAuthenticationRequired`_. 128 | 129 | .. _destroyed: https://doc.qt.io/qt-6/qobject.html#destroyed 130 | 131 | .. note:: 132 | 133 | This function will wait indefinitely if the signal is never 134 | emitted, e.g. if the sender object is deleted before emitting 135 | a signal. To handle the latter situation, keep a strong 136 | reference to the sender object, or listen to its destroyed_ 137 | signal. 138 | 139 | .. function:: asyncsignalstream(signal: BoundSignal[typing.Unpack[Ts]]) -> typing.AsyncIterator[typing.Tuple[typing.Unpack[Ts]]] 140 | 141 | Return an :external:term:`asynchronous iterator` that produces the emitted arguments from *signal* as a :class:`tuple`. 142 | 143 | *signal* is connected to via an AutoConnection_ before the function 144 | returns. It is disconnected from when the returned iterator object 145 | is deleted. Emitted arguments in the interim are stored in an 146 | internal buffer that grows without bound. It is advised to consume 147 | the iterator timely to avoid exhausting memory. 148 | 149 | Example: 150 | 151 | .. code-block:: python 152 | 153 | timer = QtCore.QTimer() 154 | timer.setInterval(1000) 155 | timer.start() 156 | 157 | what = 'tick' 158 | async for _ in qtinter.asyncsignalstream(timer.timeout): 159 | print(what) 160 | what = 'tock' if what == 'tick' else 'tick' 161 | 162 | .. function:: asyncslot(fn: typing.Callable[[typing.Unpack[Ts]], typing.Coroutine[T]], *, task_runner: Callable[[typing.Coroutine[T]], asyncio.Task[T]] = qtinter.run_task) -> typing.Callable[[typing.Unpack[Ts]], asyncio.Task[T]] 163 | 164 | Return a callable object wrapping coroutine function *fn* so that 165 | it can be connected to a Qt signal. 166 | 167 | When the returned wrapper is called, *fn* is called with the same 168 | arguments to produce a coroutine object. The coroutine object is 169 | then passed to *task_runner* to create an :class:`asyncio.Task` 170 | object that handles its execution. The task object is returned 171 | by the wrapper. 172 | 173 | The default *task_runner*, :class:`run_task`, eagerly executes the 174 | task until the first ``yield``, ``return`` or ``raise`` (whichever 175 | comes first) before returning the task object. The remainder of 176 | the coroutine is scheduled for later execution. 177 | 178 | .. note:: 179 | 180 | :func:`asyncslot` keeps a strong reference to any task object 181 | it creates until the task completes. 182 | 183 | .. note:: 184 | 185 | If *fn* is a (bound) method object, the returned wrapper will also 186 | be a method object whose lifetime is equal to that of *fn*, except 187 | that a strong reference to the returned wrapper keeps *fn* alive. 188 | 189 | .. function:: modal(fn: typing.Callable[[typing.Unpack[Ts]], T]) -> \ 190 | typing.Callable[[typing.Unpack[Ts]], typing.Coroutine[T]] 191 | 192 | Return a coroutine function that wraps a regular function *fn*. 193 | The coroutine function takes the same arguments as *fn*. 194 | 195 | When the returned coroutine function is called and awaited, *fn* 196 | is scheduled to be called *as interleaved code* immediately after 197 | the caller is suspended. The result (exception) of *fn* is 198 | returned (raised) by the coroutine. 199 | 200 | .. note:: 201 | 202 | This function is similar to :meth:`asyncio.loop.run_in_executor` 203 | except that *fn* is executed in the same thread as interleaved 204 | code. 205 | 206 | This function is designed to be called from a coroutine to schedule 207 | an *fn* that creates a nested Qt event loop. In this case, the 208 | logical asyncio event loop is allowed to continue running without 209 | nesting. For example: 210 | 211 | .. code-block:: python 212 | 213 | await qtinter.modal(QtWidgets.QMessageBox.warning)(self, "Title", "Message") 214 | 215 | .. class:: multisignal(signal_map: typing.Mapping[BoundSignal, typing.Any]) 216 | 217 | Collect multiple bound signals and re-emit their arguments along with 218 | their mapped value. 219 | 220 | A :class:`multisignal` object defines the following instance method: 221 | 222 | .. method:: connect(slot: typing.Callable[[typing.Any, typing.Tuple], typing.Any]) -> None 223 | 224 | Connect *slot* to each signal in (the keys of) *signal_map*, 225 | such that if signal *s* is mapped to *v* in *signal_map* and 226 | is emitted with arguments ``*args``, *slot* is called with 227 | ``v`` and ``args`` as arguments from the thread that called 228 | :meth:`connect`. Its return value is ignored. 229 | 230 | The sender objects of signals in *signal_map* must be alive 231 | when :meth:`connect` is called, or the process will crash 232 | with SIGSEGV. 233 | 234 | :class:`multisignal` objects do not have a *disconnect* method. 235 | The connections are automatically disconnected when the sender 236 | (of a signal) or the receiver (of *slot*) is deleted. 237 | 238 | :class:`multisignal` may be used with :func:`asyncsignal` 239 | to listen to multiple signals. 240 | 241 | Example: 242 | 243 | .. code-block:: python 244 | 245 | fast_timer = QtCore.QTimer() 246 | slow_timer = QtCore.QTimer() 247 | # ... 248 | ms = qtinter.multisignal({ 249 | fast_timer.timeout: 'fast', 250 | slow_timer.timeout: 'slow', 251 | }) 252 | ms.connect(print) 253 | 254 | # Output: 255 | # fast () 256 | # slow () 257 | 258 | .. function:: run_task(coro: typing.Coroutine[T], *, \ 259 | allow_task_nesting: bool = True, \ 260 | name: typing.Optional[str] = None, \ 261 | context: typing.Optional[contextvars.Context] = None \ 262 | ) -> asyncio.Task[T] 263 | 264 | Create an :external:class:`asyncio.Task` wrapping the coroutine 265 | *coro* and execute it immediately until the first ``yield``, 266 | ``return`` or ``raise``, whichever comes first. The remainder 267 | of the coroutine is scheduled for later execution. Return the 268 | :external:class:`asyncio.Task` object. 269 | 270 | If *allow_task_nesting* is ``True`` (the default), this method 271 | is allowed to be called from a running task --- the calling task 272 | is 'suspended' before executing the first step of *coro* and 273 | 'resumed' after that step completes. If *allow_task_nesting* 274 | is ``False``, this method can only be called from a callback. 275 | 276 | An asyncio event loop must be running when this function is called. 277 | 278 | *Since Python 3.8*: Added the *name* parameter. 279 | 280 | *Since Python 3.11*: Added the *context* parameter. 281 | 282 | 283 | Loop factory 284 | ------------ 285 | 286 | .. function:: new_event_loop() -> asyncio.AbstractEventLoop 287 | 288 | Return a new instance of an asyncio-compatible event loop object that 289 | runs on top of a Qt event loop. 290 | 291 | Use this function instead of :func:`using_qt_from_asyncio` 292 | if your code uses different types of event loops from multiple threads. 293 | For example, starting from Python 3.11, if your code uses 294 | :class:`asyncio.Runner` as its entry point, pass this function as the 295 | *loop_factory* parameter when constructing :class:`asyncio.Runner`. 296 | 297 | 298 | Low-level classes 299 | ----------------- 300 | 301 | You normally do not need to use these low-level API directly. 302 | 303 | 304 | Event loop interface 305 | ~~~~~~~~~~~~~~~~~~~~ 306 | 307 | All `event loop objects`_ below are derived from the abstract base class 308 | :class:`QiBaseEventLoop`. 309 | 310 | .. class:: QiBaseEventLoop 311 | 312 | Counterpart to the (undocumented) :class:`asyncio.BaseEventLoop` class, 313 | implemented on top of a Qt event loop. 314 | 315 | In addition to asyncio's :external:ref:`asyncio-event-loop-methods`, 316 | this class defines the following methods for Qt interop: 317 | 318 | .. method:: exec_modal(fn: typing.Callable[[], typing.Any]) -> None 319 | 320 | Schedule *fn* to be called as interleaved code (i.e. not as a 321 | callback) immediately after the current callback completes. 322 | The return value of *fn* is ignored. 323 | 324 | This method must be called from a coroutine or callback. There 325 | can be at most one pending *fn* at any time. 326 | 327 | If the current callback raises :exc:`KeyboardInterrupt` or 328 | :exc:`SystemExit`, *fn* will be called the next time the loop 329 | is run. 330 | 331 | .. method:: set_mode(mode: QiLoopMode) -> None: 332 | 333 | Set loop operating mode to *mode*. 334 | 335 | This method can only be called when the loop is not closed and 336 | not running, and no stop is pending. 337 | 338 | A newly created loop object is in :data:`QiLoopMode.OWNER` mode. 339 | 340 | .. method:: start() -> None: 341 | 342 | Start the loop (i.e. put it into *running* state) and return without 343 | waiting for it to stop. 344 | 345 | This method can only be called in guest mode and when the loop 346 | is not already running. 347 | 348 | .. class:: QiLoopMode 349 | 350 | An :external:class:`enum.Enum` that defines the possible operating 351 | modes of a :class:`QiBaseEventLoop`. Its members are: 352 | 353 | .. data:: OWNER 354 | 355 | Appropriate for use with asyncio-driven code. 356 | 357 | .. data:: GUEST 358 | 359 | Appropriate for use with Qt-driven code. 360 | 361 | .. data:: NATIVE 362 | 363 | Appropriate for running clean-up code. 364 | 365 | For details on the semantics of these modes, see :ref:`loop-modes`. 366 | 367 | 368 | Event loop objects 369 | ~~~~~~~~~~~~~~~~~~ 370 | 371 | .. class:: QiDefaultEventLoop 372 | 373 | *In Python 3.7*: alias to :class:`QiSelectorEventLoop`. 374 | 375 | *Since Python 3.8*: alias to :class:`QiSelectorEventLoop` 376 | on Unix and :class:`QiProactorEventLoop` on Windows. 377 | 378 | .. class:: QiProactorEventLoop(proactor=None) 379 | 380 | Counterpart to :class:`asyncio.ProactorEventLoop`, implemented on top of 381 | a Qt event loop. 382 | 383 | *Availability*: Windows. 384 | 385 | .. class:: QiSelectorEventLoop(selector=None) 386 | 387 | Counterpart to :class:`asyncio.SelectorEventLoop`, implemented on top of 388 | a Qt event loop. 389 | 390 | 391 | Event loop policy objects 392 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 393 | 394 | .. class:: QiDefaultEventLoopPolicy 395 | 396 | *In Python 3.7*: alias to :class:`QiSelectorEventLoopPolicy`. 397 | 398 | *Since Python 3.8*: alias to :class:`QiSelectorEventLoopPolicy` 399 | on Unix and :class:`QiProactorEventLoopPolicy` on Windows. 400 | 401 | .. class:: QiProactorEventLoopPolicy 402 | 403 | Event loop policy that creates :class:`QiProactorEventLoop`. 404 | 405 | *Availability*: Windows. 406 | 407 | .. class:: QiSelectorEventLoopPolicy 408 | 409 | Event loop policy that creates :class:`QiSelectorEventLoop`. 410 | 411 | 412 | Private API 413 | ----------- 414 | 415 | The following classes and functions are used internally to support 416 | :mod:`qtinter`'s implementation. They are documented here solely 417 | for developing :mod:`qtinter`, and are subject to change at any time. 418 | 419 | .. class:: SemiWeakRef(o, ref=weakref.ref) 420 | 421 | Return an object that is deleted when *o* is deleted, except that 422 | a strong reference to the returned object in user code keeps *o* 423 | alive. 424 | 425 | *ref* should be a weak reference class suitable for *o*: If *o* is 426 | a method object, *ref* should be set to :class:`weakref.WeakMethod`; 427 | otherwise, *ref* should be set to :class:`weakref.ref`. 428 | 429 | .. method:: referent() 430 | 431 | Return *o* if it is still live, or ``None``. 432 | 433 | .. function:: copy_signal_arguments(args: typing.Tuple[typing.Unpack[Ts]]) -> typing.Tuple[typing.Unpack[Ts]] 434 | 435 | Return a copy of signal arguments *args* where necessary. 436 | 437 | In PyQt5/6, signal arguments passed to a slot may be temporary objects 438 | whose lifetime is only valid during the slot's execution. In order to 439 | use the signal arguments after the slot returns, one must call this 440 | function to make a copy of them, or the program may crash with SIGSEGV 441 | when the signal arguments are accessed later. 442 | 443 | PySide2/6 already passes a copy of the signal arguments to slots, 444 | whose lifetime is controlled by the usual Python mechanisms. This 445 | function returns *args* as is in this case. 446 | 447 | .. function:: get_positional_parameter_count(fn: typing.Callable) -> int 448 | 449 | Return the number of positional parameters of *fn*, or ``-1`` 450 | if *fn* takes variadic positional parameters (``*args``). 451 | 452 | Raises :class:`TypeError` if *fn* takes any keyword-only parameter 453 | without a default. 454 | 455 | .. function:: transform_slot(slot: typing.Callable[[typing.Unpack[Ts]], T], transform: typing.Callable[[typing.Callable[[typing.Unpack[Ts]], T], typing.Tuple[typing.Unpack[Rs]], typing.Unpack[Es]], R], *extras: typing.Unpack[Es]) -> typing.Callable[[typing.Unpack[Rs]], R] 456 | 457 | Return a callable *wrapper* that takes variadic arguments ``*args``, 458 | such that ``wrapper(*arg)`` returns ``transform(slot, args, *extra)``. 459 | 460 | If *slot* is a bound method object, *wrapper* will also be a bound 461 | method object with the same lifetime as *slot*, except that a strong 462 | reference to *wrapper* keeps *slot* alive. 463 | 464 | If *slot* is not a bound method object, *wrapper* will be a function 465 | object that holds a strong reference to *slot*. 466 | 467 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # The following packages are required to build the documentation. 2 | sphinx 3 | sphinx-rtd-theme 4 | sphinx-togglebutton 5 | setuptools-scm 6 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: qtinter 2 | 3 | Developer Guide 4 | =============== 5 | 6 | This page explains how to use :mod:`qtinter`. 7 | 8 | 9 | .. _using-using-asyncio-from-qt: 10 | 11 | Using :func:`using_asyncio_from_qt` 12 | ----------------------------------- 13 | 14 | 15 | .. _using-using-qt-from-asyncio: 16 | 17 | Using :func:`using_qt_from_asyncio` 18 | ----------------------------------- 19 | 20 | 21 | .. _using-asyncslot: 22 | 23 | Using :func:`asyncslot` 24 | ----------------------- 25 | 26 | :func:`asyncslot` is a helper function that wraps a :term:`coroutine 27 | function` into a normal function. The wrapped function is suitable 28 | for connecting to a Qt signal. 29 | 30 | It is useless to connect a coroutine function (decorated with 31 | ``Slot``/``pyqtSlot`` or not) *directly* to a Qt signal, as calling 32 | it merely returns a coroutine object rather than performing real work. 33 | 34 | By wrapping a coroutine function with :func:`asyncslot` and connecting 35 | the resulting wrapper to a Qt signal, the coroutine function will be 36 | called with the signal arguments when the signal is emitted. The 37 | returned coroutine object is then wrapped in an :class:`asyncio.Task` 38 | and executed immediately until the first ``yield``, ``return`` or ``raise``, 39 | whichever comes first. The remainder of the coroutine is scheduled for 40 | later execution. 41 | 42 | The recommended pattern for using :func:`asyncslot` is the following: 43 | 44 | 1. Code the business logic in a coroutine function and connect the 45 | function to a Qt signal by wrapping it with :func:`asyncslot`. 46 | On entry to this function, store the running :class:`asyncio.Task` 47 | instance for cancellation later. 48 | 49 | 2. Cancel the running task when a 'cancel signal' is emitted. 50 | 51 | 3. Cancel the running task when it is no longer needed (e.g. when the 52 | window is closed). 53 | 54 | We demonstrate this pattern using a simple *Stopwatch* example that 55 | looks like the following: 56 | 57 | .. image:: _static/stopwatch.gif 58 | 59 | This sample application has a START button, a STOP button and an 60 | LCD display to display the time elapsed. The 'core' of the app 61 | is a coroutine (``_tick``) that updates the LCD display constantly: 62 | 63 | .. code-block:: python 64 | 65 | async def _tick(self): 66 | t0 = time.time() 67 | while True: 68 | t = time.time() 69 | self.lcdNumber.display(format(t - t0, ".1f")) 70 | await asyncio.sleep(0.05) 71 | 72 | The steps of the pattern are implemented as follows: 73 | 74 | 1. Code the stopwatch logic in a coroutine function (``_start``) and 75 | connect it to the START button by wrapping it with :func:`asyncslot`. 76 | 77 | On entry, store the running :class:`asyncio.Task` instance for 78 | cancellation later, and update the UI states. Before exit, 79 | restore the UI states, and reset the task instance to break the 80 | reference cycle. 81 | 82 | .. code-block:: python 83 | 84 | def __init__(self): 85 | ... 86 | self.startButton.clicked.connect(qtinter.asyncslot(self._start)) 87 | ... 88 | 89 | async def _start(self): 90 | self.task = asyncio.current_task() 91 | self.startButton.setEnabled(False) 92 | self.stopButton.setEnabled(True) 93 | try: 94 | await self._tick() 95 | finally: 96 | self.startButton.setEnabled(True) 97 | self.stopButton.setEnabled(False) 98 | self.task = None 99 | 100 | 2. Connect the STOP button to a plain slot (``_stop``) that cancels 101 | the running task. 102 | 103 | .. code-block:: python 104 | 105 | def __init__(self): 106 | ... 107 | self.stopButton.clicked.connect(self._stop) 108 | ... 109 | 110 | def _stop(self): 111 | self.task.cancel() 112 | 113 | 3. Cancel the running task (if one exists) when the widget is closed. 114 | 115 | .. code-block:: python 116 | 117 | def closeEvent(self, event): 118 | if self.task is not None: 119 | self.task.cancel() 120 | event.accept() 121 | 122 | **Always cancel a task when it is no longer needed.** 123 | :func:`asyncslot` keeps a strong reference to all running tasks 124 | it starts. If you don't cancel a task explicitly, the task will 125 | keep running until :func:`using_asyncio_from_qt` exits. 126 | 127 | *Remark*. It is possible to *decorate* a coroutine function with 128 | :class:`asyncslot` and connect the decorated function directly 129 | to a Qt signal. However, this approach is not recommended because 130 | **a decorated coroutine function is transformed into a regular function**, 131 | which brings subtle semantic differences and causes confusion. 132 | 133 | .. note:: 134 | 135 | :func:`asyncslot` makes two extensions to asyncio's semantics 136 | in order to work smoothly: 137 | 138 | 1. *Eager task execution*. 139 | The first "step" of a task created by :func:`asyncslot` is 140 | executed immediately rather than scheduled for later execution. 141 | This extension supports a common pattern where some code must be 142 | executed immediately in response to a signal, such as updating 143 | UI states in the above example. 144 | 145 | 2. *Nested task execution*. 146 | If a coroutine function wrapped by :func:`asyncslot` is called 147 | from a coroutine (e.g. as the result of a signal being emitted), 148 | the calling task is "suspended" when the call begins and 149 | "resumed" after the call returns. This extension makes 150 | :func:`asyncslot` easier to use in a number of scenarios. 151 | 152 | For details on these semantic extensions, see :ref:`eager-execution`. 153 | 154 | 155 | .. _using-asyncslot-without: 156 | 157 | If you prefer to stick to asyncio's API and semantics, it is perfectly 158 | possible and supported to schedule coroutines without using 159 | :func:`asyncslot`. Reusing the *Stopwatch* example above, 160 | the key steps are: 161 | 162 | 1. Connect the START button to a plain slot (``_start``). 163 | In this slot, update the UI states, schedule the coroutine using 164 | :func:`asyncio.create_task`, and hook the task's "done callback" 165 | to a clean-up routine (``_stopped``) to restore the UI states 166 | and reset the reference to the task. 167 | 168 | .. code-block:: python 169 | 170 | def __init__(self): 171 | ... 172 | self.startButton.clicked.connect(self._start) 173 | ... 174 | 175 | def _start(self): 176 | self.startButton.setEnabled(False) 177 | self.stopButton.setEnabled(True) 178 | self.task = asyncio.create_task(self._tick()) 179 | self.task.add_done_callback(self._stopped) 180 | 181 | def _stopped(self, task: asyncio.Task): 182 | self.startButton.setEnabled(True) 183 | self.stopButton.setEnabled(False) 184 | self.task = None 185 | 186 | .. note:: 187 | 188 | Code that disables the START button cannot be moved into 189 | ``_tick()``, because it must be executed immediately when the 190 | button is clicked. 191 | 192 | Consequently, code that restores the UI states cannot be moved 193 | into ``_tick()``, because the task might be cancelled before it 194 | starts running. 195 | 196 | 2. (Same as before) Connect the STOP button to a plain slot (``_stop``) 197 | to cancel the running task. 198 | 199 | .. code-block:: python 200 | 201 | def __init__(self): 202 | ... 203 | self.stopButton.clicked.connect(self._stop) 204 | ... 205 | 206 | def _stop(self): 207 | self.task.cancel() 208 | 209 | 3. (Same as before) Cancel the running task (if one exists) when the widget 210 | is closed. 211 | 212 | .. code-block:: python 213 | 214 | def closeEvent(self, event): 215 | if self.task is not None: 216 | self.task.cancel() 217 | event.accept() 218 | 219 | Again, **always cancel a task when it is no longer needed.** 220 | Otherwise the task may keep running in the background until 221 | :func:`using_asyncio_from_qt` exits (or gets garbage-collected 222 | at an arbitrary point). 223 | 224 | 225 | .. _using-asyncsignal: 226 | 227 | Using :func:`asyncsignal` 228 | ------------------------- 229 | 230 | 231 | .. _using-modal: 232 | 233 | Using :func:`modal` 234 | ------------------- 235 | 236 | -------------------------------------------------------------------------------- /examples/clock.py: -------------------------------------------------------------------------------- 1 | """Display LCD-style digital clock""" 2 | 3 | import asyncio 4 | import datetime 5 | import qtinter # <-- import module 6 | from PySide6 import QtWidgets 7 | 8 | class Clock(QtWidgets.QLCDNumber): 9 | def __init__(self, parent=None): 10 | super().__init__(parent) 11 | self.setDigitCount(8) 12 | 13 | def showEvent(self, event): 14 | self._task = asyncio.create_task(self._tick()) 15 | 16 | def hideEvent(self, event): 17 | self._task.cancel() 18 | 19 | async def _tick(self): 20 | while True: 21 | t = datetime.datetime.now() 22 | self.display(t.strftime("%H:%M:%S")) 23 | await asyncio.sleep(1.0 - t.microsecond / 1000000 + 0.05) 24 | 25 | if __name__ == "__main__": 26 | app = QtWidgets.QApplication([]) 27 | 28 | widget = Clock() 29 | widget.setWindowTitle("qtinter - Digital Clock example") 30 | widget.resize(300, 50) 31 | 32 | with qtinter.using_asyncio_from_qt(): # <-- enable asyncio in qt code 33 | widget.show() 34 | app.exec() 35 | -------------------------------------------------------------------------------- /examples/color.py: -------------------------------------------------------------------------------- 1 | """Display the RGB code of a color chosen by the user""" 2 | 3 | import asyncio 4 | import qtinter # <-- import module 5 | from PySide6 import QtWidgets 6 | 7 | async def choose_color(): 8 | dialog = QtWidgets.QColorDialog() 9 | dialog.show() 10 | future = asyncio.Future() 11 | dialog.finished.connect(future.set_result) 12 | result = await future 13 | if result == QtWidgets.QDialog.DialogCode.Accepted: 14 | return dialog.selectedColor().name() 15 | else: 16 | return None 17 | 18 | if __name__ == "__main__": 19 | app = QtWidgets.QApplication([]) 20 | with qtinter.using_qt_from_asyncio(): # <-- enable qt in asyncio code 21 | color = asyncio.run(choose_color()) 22 | if color is not None: 23 | print(color) 24 | -------------------------------------------------------------------------------- /examples/hit_100.py: -------------------------------------------------------------------------------- 1 | """Demo non-blocking dialog exec""" 2 | 3 | import asyncio 4 | import qtinter 5 | from PySide6 import QtWidgets 6 | 7 | 8 | async def main(): 9 | async def counter(): 10 | nonlocal n 11 | while True: 12 | print(f"\r{n}", end='', flush=True) 13 | await asyncio.sleep(0.025) 14 | n += 1 15 | 16 | n = 0 17 | counter_task = asyncio.create_task(counter()) 18 | await qtinter.modal(QtWidgets.QMessageBox.information)( 19 | None, "Hit 100", "Click OK when you think you hit 100.") 20 | counter_task.cancel() 21 | if n == 100: 22 | print("\nYou did it!") 23 | else: 24 | print("\nTry again!") 25 | 26 | 27 | if __name__ == "__main__": 28 | app = QtWidgets.QApplication([]) 29 | with qtinter.using_qt_from_asyncio(): 30 | asyncio.run(main()) 31 | -------------------------------------------------------------------------------- /examples/http_client.py: -------------------------------------------------------------------------------- 1 | """Demo asyncio http download and cancellation from Qt app.""" 2 | 3 | import asyncio 4 | import qtinter 5 | import sys 6 | import time 7 | from PySide6 import QtCore, QtWidgets 8 | from typing import Optional 9 | import requests 10 | import http.server 11 | import threading 12 | import httpx 13 | 14 | 15 | def create_http_server(): 16 | """Create a minimal local http server. 17 | 18 | This server sleeps for 3 seconds before sending back a response. 19 | This makes it easier to visualize the difference between synchronous 20 | and asynchronous download. 21 | 22 | The server implementation is unrelated to qtinter. 23 | """ 24 | class MyRequestHandler(http.server.BaseHTTPRequestHandler): 25 | def do_GET(self): 26 | time.sleep(3) # simulate slow response 27 | self.send_response(200) 28 | self.send_header("Content-type", "text/html") 29 | self.end_headers() 30 | content = f"You requested {self.path}".encode("utf-8") 31 | self.wfile.write(content) 32 | 33 | server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), 34 | MyRequestHandler) 35 | thread = threading.Thread(target=server.serve_forever) 36 | thread.start() 37 | return server 38 | 39 | 40 | class MyWidget(QtWidgets.QWidget): 41 | def __init__(self): 42 | super().__init__() 43 | 44 | self.setWindowTitle("qtinter - Http Client Example") 45 | 46 | # Show an indefinite progress bar to visualize whether the Qt event 47 | # loop is blocked -- the progress bar freezes if the Qt event loop 48 | # is blocked. 49 | self._progress = QtWidgets.QProgressBar() 50 | self._progress.setRange(0, 0) 51 | 52 | # URL input. 53 | self._url = QtWidgets.QLineEdit(self) 54 | 55 | # The 'Sync GET' button downloads the web page synchronously, 56 | # which blocks the Qt event loop. The progress bar freezes 57 | # when this button is clicked until the download completes. 58 | self._sync_button = QtWidgets.QPushButton("Sync GET") 59 | self._sync_button.clicked.connect(self.sync_download) 60 | 61 | # The 'Async GET' button downloads the web page asynchronously. 62 | # The progress bar keeps running while the download is in progress. 63 | self._async_button = QtWidgets.QPushButton("Async GET") 64 | 65 | # [DEMO] To connect an async function to the clicked signal, wrap 66 | # the async function with qtinter.asyncslot. 67 | self._async_button.clicked.connect( 68 | qtinter.asyncslot(self.async_download)) 69 | 70 | # [DEMO] When an async download is in progress, _async_task is set 71 | # to the task executing the download, so that it may be cancelled 72 | # by clicking the 'Cancel' button. 73 | self._async_task: Optional[asyncio.Task] = None 74 | 75 | # The 'Cancel' button is enabled when async download is in progress. 76 | self._cancel_button = QtWidgets.QPushButton("Cancel") 77 | self._cancel_button.setEnabled(False) 78 | self._cancel_button.clicked.connect(self.cancel_async_download) 79 | 80 | # Response from the http server is shown in the below box. 81 | self._output = QtWidgets.QTextEdit(self) 82 | self._output.setReadOnly(True) 83 | 84 | # Set up layout. 85 | self._buttons = QtWidgets.QHBoxLayout() 86 | self._buttons.setContentsMargins(0, 0, 0, 0) 87 | self._buttons.setSpacing(5) 88 | self._buttons.addWidget(self._sync_button) 89 | self._buttons.addWidget(self._async_button) 90 | self._buttons.addWidget(self._cancel_button) 91 | self._layout = QtWidgets.QVBoxLayout(self) 92 | self._layout.setContentsMargins(10, 10, 10, 10) 93 | self._layout.setSpacing(5) 94 | self._layout.addWidget(self._progress) 95 | self._layout.addWidget(self._url) 96 | self._layout.addLayout(self._buttons) 97 | self._layout.addWidget(self._output) 98 | 99 | # Start a local HTTP server to simulate slow response. The server 100 | # runs in a separate thread. 101 | self._server = create_http_server() 102 | 103 | # Set default URL to the locally-run http server. 104 | self._url.setText("http://{}:{}/dummy" 105 | .format(*self._server.server_address)) 106 | 107 | def closeEvent(self, event): 108 | # Cancel the running download task if one exists. 109 | if self._async_task is not None: 110 | self._async_task.cancel() 111 | 112 | # Shut down the local HTTP server. The shutdown() call blocks; 113 | # you may observe a short freeze of the progress bar. 114 | self._server.shutdown() 115 | event.accept() 116 | 117 | def sync_download(self): 118 | # When the 'Sync GET' button is clicked, download the web page 119 | # using the (blocking) requests library. This has two drawbacks: 120 | # 1. The GUI freezes during the download. For example, the progress 121 | # bar stops updating. 122 | # 2. Buttons remain clickable even if disabled before download starts 123 | # and re-enabled after download completes. A workaround seems to 124 | # be waiting for 10 milliseconds before re-enabling the button; 125 | # hard-coding a timeout is certainly not ideal. 126 | url = self._url.text() 127 | self._async_button.setEnabled(False) 128 | try: 129 | response = requests.get(url) 130 | self._output.setText(response.text) 131 | finally: 132 | QtCore.QTimer.singleShot( 133 | 10, lambda: self._async_button.setEnabled(True)) 134 | 135 | # [DEMO] asynchronous slot for the 'Async GET' button. 136 | async def async_download(self): 137 | # Store the asyncio task wrapping the running coroutine so that 138 | # it may be cancelled by calling its cancel() method. 139 | self._async_task = asyncio.current_task() 140 | assert self._async_task is not None 141 | 142 | # Update GUI elements -- this is a common pattern in event handling. 143 | # Because qtinter.asyncslot() executes the coroutine immediately 144 | # until the first yield, the changes take effect immediately, 145 | # eliminating potential race conditions. The actual GUI repainting 146 | # happens after the first yield when control is returned to the Qt 147 | # event loop. 148 | self._sync_button.setEnabled(False) 149 | self._async_button.setEnabled(False) 150 | self._cancel_button.setEnabled(True) 151 | self._output.clear() 152 | 153 | try: 154 | # Download web page using httpx library. The httpx library 155 | # works with both asyncio and trio, and uses anyio to detect 156 | # the type of the running event loop. That httpx works with 157 | # qtinter shows that qtinter's event loop behaves like 158 | # an asyncio event loop. 159 | async with httpx.AsyncClient() as client: 160 | url = self._url.text() 161 | response = await client.get(url) 162 | # TODO: test asyncgen close 163 | body = await response.aread() 164 | self._output.setText(body.decode("utf-8")) 165 | 166 | except asyncio.CancelledError: 167 | # Catching a CancelledError indicates the task is cancelled. 168 | # This can happen either because the user clicked the 'Cancel' 169 | # button, or because the window is closed. 170 | if self.isVisible(): 171 | # Cancelled by the CANCEL button -- display a message box. 172 | # Use qtinter.modal() to avoid blocking the asyncio event 173 | # loop because QMessageBox.information() creates a nested 174 | # Qt event loop. 175 | await qtinter.modal(QtWidgets.QMessageBox.information)( 176 | self, "Note", "Download cancelled by user!") 177 | else: 178 | # Cancelled by window close -- do nothing. By now the Qt 179 | # event loop may have terminated already, and the underlying 180 | # QiBaseEventLoop may be operating in NATIVE mode. 181 | pass 182 | 183 | finally: 184 | # Restore GUI element states. 185 | self._cancel_button.setEnabled(False) 186 | self._async_button.setEnabled(True) 187 | self._sync_button.setEnabled(True) 188 | self._async_task = None 189 | 190 | # [DEMO] When the 'Cancel' button is clicked, cancel the async download. 191 | def cancel_async_download(self): 192 | # The 'Cancel' button is enabled only if the async download is 193 | # in progress, so the task object must be set. 194 | assert self._async_task is not None 195 | 196 | # Initiate cancellation request. This throws asyncio.CancelledError 197 | # into the (suspended) coroutine, which must catch the exception and 198 | # perform actual cancellation. (Note that it is possible for the 199 | # task to be done before CancelledError is thrown, as a cancellation 200 | # request is scheduled by call_soon() and it might so happen that 201 | # a task completion callback is scheduled before it.) Cancelling 202 | # a done task has no effect. 203 | self._async_task.cancel() 204 | 205 | 206 | def main(): 207 | # Create a QApplication instance, as usual. 208 | app = QtWidgets.QApplication([]) 209 | 210 | # Create widgets, as usual. 211 | widget = MyWidget() 212 | widget.resize(400, 200) 213 | widget.show() 214 | 215 | # [DEMO] To enable asyncio-based components from Qt-driven code, 216 | # enclose app.exec() inside the qtinter.using_asyncio_from_qt() 217 | # context manager. This context manager takes care of starting 218 | # up and shutting down an asyncio-compatible logical event loop. 219 | with qtinter.using_asyncio_from_qt(): 220 | sys.exit(app.exec()) 221 | 222 | 223 | if __name__ == "__main__": 224 | main() 225 | -------------------------------------------------------------------------------- /examples/read_out.py: -------------------------------------------------------------------------------- 1 | """Read out input using QTextToSpeech""" 2 | 3 | import asyncio 4 | import getopt 5 | import os 6 | import qtinter 7 | import sys 8 | from PyQt6 import QtCore, QtTextToSpeech 9 | 10 | 11 | async def read_out(engine: QtTextToSpeech.QTextToSpeech, echo=False): 12 | while True: 13 | try: 14 | line = await asyncio.get_running_loop().run_in_executor(None, input) 15 | except EOFError: 16 | break 17 | if echo: 18 | print(line) 19 | engine.say(line) 20 | if engine.state() == QtTextToSpeech.QTextToSpeech.State.Speaking: 21 | # If the line contains no speakable content, state remains Ready. 22 | state, = await qtinter.asyncsignal(engine.stateChanged) 23 | assert state == QtTextToSpeech.QTextToSpeech.State.Ready 24 | 25 | 26 | def main(): 27 | if sys.platform == 'darwin': 28 | # QtTextToSpeech requires QEventDispatcherCoreFoundation on macOS. 29 | # Set QT_EVENT_DISPATCHER_CORE_FOUNDATION or use QtGui.QGuiApplication. 30 | os.environ['QT_EVENT_DISPATCHER_CORE_FOUNDATION'] = '1' 31 | app = QtCore.QCoreApplication([]) 32 | 33 | engine = QtTextToSpeech.QTextToSpeech() 34 | locales = dict((l.name(), l) for l in engine.availableLocales()) 35 | voices = dict((v.name(), v) for v in engine.availableVoices()) 36 | usage = (f"usage: {sys.argv[0]} [options]\n" 37 | f"Read out text from stdin.\n" 38 | f"Options:\n" 39 | f" -e Echo each line before reading it out\n" 40 | f" -h Show this screen and exit\n" 41 | f" -l locale One of {', '.join(sorted(locales))} " 42 | f"(default: {engine.locale().name()})\n" 43 | f" -p pitch Number between -1.0 and +1.0 (default: 0.0)\n" 44 | f" -r rate Number between -1.0 and +1.0 (default: 0.0)\n" 45 | f" -v voice One of {', '.join(sorted(voices))} " 46 | f"(default: {engine.voice().name()})\n") 47 | 48 | try: 49 | args, rest = getopt.getopt(sys.argv[1:], "ehl:p:r:v:") 50 | except getopt.error: 51 | print(usage, file=sys.stderr) 52 | return 1 53 | 54 | if rest: 55 | print(usage, file=sys.stderr) 56 | return 1 57 | 58 | echo = False 59 | for opt, val in args: 60 | if opt == "-e": 61 | echo = True 62 | elif opt == "-h": 63 | print(usage) 64 | return 0 65 | elif opt == "-l": 66 | engine.setLocale(locales[val]) 67 | elif opt == "-p": 68 | engine.setPitch(float(val)) 69 | elif opt == "-r": 70 | engine.setRate(float(val)) 71 | elif opt == "-v": 72 | engine.setVoice(voices[val]) 73 | else: 74 | print(usage, file=sys.stderr) 75 | return 1 76 | 77 | with qtinter.using_qt_from_asyncio(): 78 | asyncio.run(read_out(engine, echo)) 79 | 80 | 81 | if __name__ == "__main__": 82 | sys.exit(main()) 83 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | # These packages are required for running the examples in this directory. 2 | requests 3 | httpx 4 | -------------------------------------------------------------------------------- /examples/runner.py: -------------------------------------------------------------------------------- 1 | """Demonstrates asyncio.Runner usage""" 2 | 3 | import asyncio 4 | import datetime 5 | import qtinter 6 | import sys 7 | from PySide6 import QtCore 8 | 9 | 10 | if sys.version_info < (3, 11): 11 | raise RuntimeError("This example requires Python 3.11 or above") 12 | 13 | 14 | async def coro(): 15 | print("Press Ctrl+C to stop") 16 | timer = QtCore.QTimer() 17 | timer.setInterval(500) 18 | timer.start() 19 | while True: 20 | await qtinter.asyncsignal(timer.timeout) 21 | print(f"\r{datetime.datetime.now().strftime('%H:%M:%S')}", end="") 22 | 23 | 24 | if __name__ == "__main__": 25 | app = QtCore.QCoreApplication([]) 26 | with asyncio.Runner(loop_factory=qtinter.new_event_loop) as runner: 27 | runner.run(coro()) 28 | -------------------------------------------------------------------------------- /examples/stopwatch1.py: -------------------------------------------------------------------------------- 1 | """Stopwatch implementation that does not use 'asyncslot'""" 2 | 3 | import asyncio 4 | import time 5 | import qtinter 6 | from PyQt6 import QtWidgets 7 | from typing import Optional 8 | 9 | 10 | class MyWidget(QtWidgets.QWidget): 11 | def __init__(self): 12 | super().__init__() 13 | 14 | self.lcdNumber = QtWidgets.QLCDNumber() 15 | self.lcdNumber.setSmallDecimalPoint(True) 16 | 17 | self.startButton = QtWidgets.QPushButton() 18 | self.startButton.setText("START") 19 | self.startButton.clicked.connect(self._start) 20 | 21 | self.stopButton = QtWidgets.QPushButton() 22 | self.stopButton.setText("STOP") 23 | self.stopButton.clicked.connect(self._stop) 24 | self.stopButton.setEnabled(False) 25 | 26 | self.hBoxLayout = QtWidgets.QHBoxLayout() 27 | self.hBoxLayout.addWidget(self.startButton) 28 | self.hBoxLayout.addWidget(self.stopButton) 29 | 30 | self.vBoxLayout = QtWidgets.QVBoxLayout(self) 31 | self.vBoxLayout.addWidget(self.lcdNumber) 32 | self.vBoxLayout.addLayout(self.hBoxLayout) 33 | 34 | self.setWindowTitle("qtinter - Stopwatch example") 35 | 36 | self.task: Optional[asyncio.Task] = None 37 | 38 | def closeEvent(self, event): 39 | if self.task is not None: 40 | self.task.cancel() 41 | event.accept() 42 | 43 | def _start(self): 44 | self.startButton.setEnabled(False) 45 | self.stopButton.setEnabled(True) 46 | self.task = asyncio.create_task(self._tick()) 47 | self.task.add_done_callback(self._stopped) 48 | 49 | def _stop(self): 50 | self.task.cancel() 51 | 52 | def _stopped(self, task: asyncio.Task): 53 | self.startButton.setEnabled(True) 54 | self.stopButton.setEnabled(False) 55 | self.task = None 56 | 57 | async def _tick(self): 58 | t0 = time.time() 59 | while True: 60 | t = time.time() 61 | print(f"\r{self!r} {t - t0:.2f}", end="") 62 | self.lcdNumber.display(format(t - t0, ".1f")) 63 | await asyncio.sleep(0.05) 64 | 65 | 66 | if __name__ == "__main__": 67 | app = QtWidgets.QApplication([]) 68 | 69 | widget = MyWidget() 70 | widget.resize(300, 200) 71 | widget.show() 72 | 73 | with qtinter.using_asyncio_from_qt(): # <-- enclose in context manager 74 | app.exec() 75 | -------------------------------------------------------------------------------- /examples/stopwatch2.py: -------------------------------------------------------------------------------- 1 | """Stopwatch implementation that uses 'asyncslot' as decorator""" 2 | 3 | import asyncio 4 | import time 5 | import qtinter 6 | from PyQt6 import QtWidgets 7 | from typing import Optional 8 | 9 | 10 | class MyWidget(QtWidgets.QWidget): 11 | def __init__(self): 12 | super().__init__() 13 | 14 | self.lcdNumber = QtWidgets.QLCDNumber() 15 | self.lcdNumber.setSmallDecimalPoint(True) 16 | 17 | self.startButton = QtWidgets.QPushButton() 18 | self.startButton.setText("START") 19 | self.startButton.clicked.connect(self._start) 20 | 21 | self.stopButton = QtWidgets.QPushButton() 22 | self.stopButton.setText("STOP") 23 | self.stopButton.clicked.connect(self._stop) 24 | self.stopButton.setEnabled(False) 25 | 26 | self.hBoxLayout = QtWidgets.QHBoxLayout() 27 | self.hBoxLayout.addWidget(self.startButton) 28 | self.hBoxLayout.addWidget(self.stopButton) 29 | 30 | self.vBoxLayout = QtWidgets.QVBoxLayout(self) 31 | self.vBoxLayout.addWidget(self.lcdNumber) 32 | self.vBoxLayout.addLayout(self.hBoxLayout) 33 | 34 | self.setWindowTitle("qtinter - Stopwatch example") 35 | 36 | self.task: Optional[asyncio.Task] = None 37 | 38 | def closeEvent(self, event): 39 | if self.task is not None: 40 | self.task.cancel() 41 | event.accept() 42 | 43 | @qtinter.asyncslot 44 | async def _start(self): 45 | self.task = asyncio.current_task() 46 | self.startButton.setEnabled(False) 47 | self.stopButton.setEnabled(True) 48 | try: 49 | await self._tick() 50 | finally: 51 | self.startButton.setEnabled(True) 52 | self.stopButton.setEnabled(False) 53 | self.task = None 54 | 55 | def _stop(self): 56 | self.task.cancel() 57 | 58 | async def _tick(self): 59 | t0 = time.time() 60 | while True: 61 | t = time.time() 62 | print(f"\r{self!r} {t - t0:.2f}", end="") 63 | self.lcdNumber.display(format(t - t0, ".1f")) 64 | await asyncio.sleep(0.05) 65 | 66 | 67 | if __name__ == "__main__": 68 | app = QtWidgets.QApplication([]) 69 | 70 | widget = MyWidget() 71 | widget.resize(300, 200) 72 | widget.show() 73 | 74 | with qtinter.using_asyncio_from_qt(): # <-- enclose in context manager 75 | app.exec() 76 | -------------------------------------------------------------------------------- /examples/stopwatch3.py: -------------------------------------------------------------------------------- 1 | """Stopwatch implementation that uses 'asyncslot' as wrapper""" 2 | 3 | import asyncio 4 | import time 5 | import qtinter 6 | from PyQt6 import QtWidgets 7 | from typing import Optional 8 | 9 | 10 | class MyWidget(QtWidgets.QWidget): 11 | def __init__(self): 12 | super().__init__() 13 | 14 | self.lcdNumber = QtWidgets.QLCDNumber() 15 | self.lcdNumber.setSmallDecimalPoint(True) 16 | 17 | self.startButton = QtWidgets.QPushButton() 18 | self.startButton.setText("START") 19 | self.startButton.clicked.connect(qtinter.asyncslot(self._start)) 20 | 21 | self.stopButton = QtWidgets.QPushButton() 22 | self.stopButton.setText("STOP") 23 | self.stopButton.clicked.connect(self._stop) 24 | self.stopButton.setEnabled(False) 25 | 26 | self.hBoxLayout = QtWidgets.QHBoxLayout() 27 | self.hBoxLayout.addWidget(self.startButton) 28 | self.hBoxLayout.addWidget(self.stopButton) 29 | 30 | self.vBoxLayout = QtWidgets.QVBoxLayout(self) 31 | self.vBoxLayout.addWidget(self.lcdNumber) 32 | self.vBoxLayout.addLayout(self.hBoxLayout) 33 | 34 | self.setWindowTitle("qtinter - Stopwatch example") 35 | 36 | self.task: Optional[asyncio.Task] = None 37 | 38 | def closeEvent(self, event): 39 | if self.task is not None: 40 | self.task.cancel() 41 | event.accept() 42 | 43 | async def _start(self): 44 | self.task = asyncio.current_task() 45 | self.startButton.setEnabled(False) 46 | self.stopButton.setEnabled(True) 47 | try: 48 | await self._tick() 49 | finally: 50 | self.startButton.setEnabled(True) 51 | self.stopButton.setEnabled(False) 52 | self.task = None 53 | 54 | def _stop(self): 55 | self.task.cancel() 56 | 57 | async def _tick(self): 58 | t0 = time.time() 59 | while True: 60 | t = time.time() 61 | print(f"\r{self!r} {t - t0:.2f}", end="") 62 | self.lcdNumber.display(format(t - t0, ".1f")) 63 | await asyncio.sleep(0.05) 64 | 65 | 66 | if __name__ == "__main__": 67 | app = QtWidgets.QApplication([]) 68 | 69 | widget = MyWidget() 70 | widget.resize(300, 200) 71 | widget.show() 72 | 73 | with qtinter.using_asyncio_from_qt(): # <-- enclose in context manager 74 | app.exec() 75 | -------------------------------------------------------------------------------- /examples/stopwatches.py: -------------------------------------------------------------------------------- 1 | """Launcher for multiple stopwatches""" 2 | 3 | import qtinter 4 | from PyQt6 import QtWidgets 5 | import stopwatch1 6 | import stopwatch2 7 | import stopwatch3 8 | 9 | 10 | class LauncherWidget(QtWidgets.QWidget): 11 | def __init__(self): 12 | super().__init__() 13 | 14 | self.button1 = QtWidgets.QPushButton(self) 15 | self.button1.setText("Launch Stopwatch 1") 16 | self.button1.clicked.connect(self.launch_stopwatch_1) 17 | 18 | self.button2 = QtWidgets.QPushButton(self) 19 | self.button2.setText("Launch Stopwatch 2") 20 | self.button2.clicked.connect(self.launch_stopwatch_2) 21 | 22 | self.button3 = QtWidgets.QPushButton(self) 23 | self.button3.setText("Launch Stopwatch 3") 24 | self.button3.clicked.connect(self.launch_stopwatch_3) 25 | 26 | self.layout = QtWidgets.QVBoxLayout(self) 27 | self.layout.addWidget(self.button1) 28 | self.layout.addWidget(self.button2) 29 | self.layout.addWidget(self.button3) 30 | 31 | self.widgets = [] 32 | 33 | def launch_stopwatch_1(self): 34 | widget = stopwatch1.MyWidget() 35 | widget.resize(250, 150) 36 | widget.show() 37 | self.widgets.append(widget) 38 | 39 | def launch_stopwatch_2(self): 40 | widget = stopwatch2.MyWidget() 41 | widget.resize(250, 150) 42 | widget.show() 43 | self.widgets.append(widget) 44 | 45 | def launch_stopwatch_3(self): 46 | widget = stopwatch3.MyWidget() 47 | widget.resize(250, 150) 48 | widget.show() 49 | self.widgets.append(widget) 50 | 51 | def closeEvent(self, event): 52 | QtWidgets.QApplication.quit() 53 | event.accept() 54 | 55 | 56 | if __name__ == "__main__": 57 | app = QtWidgets.QApplication([]) 58 | 59 | launcher = LauncherWidget() 60 | # launcher.resize(300, 200) 61 | launcher.show() 62 | 63 | with qtinter.using_asyncio_from_qt(): 64 | app.exec() 65 | -------------------------------------------------------------------------------- /examples/where_am_i.py: -------------------------------------------------------------------------------- 1 | """Report current geolocation""" 2 | 3 | import asyncio 4 | import os 5 | import qtinter 6 | import sys 7 | from PyQt6 import QtCore, QtPositioning 8 | 9 | 10 | async def get_location() -> str: 11 | """Return the current location as a string.""" 12 | 13 | # A QGeoPositionInfoSource object needs a parent to control its lifetime. 14 | app = QtCore.QCoreApplication.instance() 15 | source = QtPositioning.QGeoPositionInfoSource.createDefaultSource(app) 16 | if source is None: 17 | raise RuntimeError("No QGeoPositionInfoSource is available") 18 | 19 | # This is a pattern to call a Qt method *after* installing signal handlers. 20 | asyncio.get_running_loop().call_soon(source.requestUpdate, 0) 21 | 22 | # Wait for position update or error message. 23 | which, [result] = await qtinter.asyncsignal(qtinter.multisignal({ 24 | source.positionUpdated: "ok", 25 | source.errorOccurred: "error", 26 | })) 27 | 28 | if which == "ok": 29 | position: QtPositioning.QGeoPositionInfo = result 30 | return position.coordinate().toString() 31 | else: 32 | error: QtPositioning.QGeoPositionInfoSource.Error = result 33 | raise RuntimeError(f"Cannot obtain geolocation: {error}") 34 | 35 | 36 | def main(): 37 | if sys.platform == 'darwin': 38 | # QtPositioning requires QEventDispatcherCoreFoundation on macOS. 39 | # Set QT_EVENT_DISPATCHER_CORE_FOUNDATION or use QtGui.QGuiApplication. 40 | os.environ['QT_EVENT_DISPATCHER_CORE_FOUNDATION'] = '1' 41 | app = QtCore.QCoreApplication([]) 42 | with qtinter.using_qt_from_asyncio(): 43 | print(asyncio.run(get_location())) 44 | 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "qtinter" 7 | authors = [ { name="fancidev", email="fancidev@gmail.com" } ] 8 | description = "Interop between asyncio and Qt for Python" 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | keywords = ["asyncio", "coroutine", "qt", "signal", "slot"] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: X11 Applications :: Qt", 15 | "Framework :: AsyncIO", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: BSD License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python :: 3", 20 | ] 21 | dynamic = ["version"] 22 | 23 | [project.optional-dependencies] 24 | PyQt5 = ["PyQt5"] 25 | PyQt6 = ["PyQt6"] 26 | PySide2 = ["PySide2"] 27 | PySide6 = ["PySide6"] 28 | 29 | [project.urls] 30 | "Homepage" = "https://github.com/fancidev/qtinter" 31 | "Bug Tracker" = "https://github.com/fancidev/qtinter/issues" 32 | 33 | [tool.setuptools_scm] 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # The following dependencies are needed for developing, building and 2 | # testing qtinter, as well as running the examples. You do not need 3 | # to install these if you simply *use* qtinter. 4 | 5 | -r src/requirements.txt 6 | -r dist/requirements.txt 7 | -r tests/requirements.txt 8 | -r examples/requirements.txt 9 | -------------------------------------------------------------------------------- /src/qtinter/__init__.py: -------------------------------------------------------------------------------- 1 | """Implements asyncio event loop based on Qt event loop. 2 | 3 | The asyncio event loop class hierarchy is as follows: 4 | 5 | class submodule alias 6 | ------------------------------------------------------------------------------ 7 | BaseEventLoop base_events 8 | BaseSelectorEventLoop selector_events 9 | _UnixSelectorEventLoop unix_events SelectorEventLoop [1] 10 | _WindowsSelectorEventLoop windows_events SelectorEventLoop [2,3] 11 | BaseProactorEventLoop proactor_events 12 | ProactorEventLoop windows_events 13 | BaseDefaultEventLoopPolicy events 14 | _UnixDefaultEventLoopPolicy unix_events DefaultEventLoopPolicy [1] 15 | WindowsSelectorEventLoopPolicy windows_events DefaultEventLoopPolicy [2] 16 | WindowsProactorEventLoopPolicy windows_events DefaultEventLoopPolicy [3] 17 | 18 | [1] under unix 19 | [2] under Windows, for Python 3.7 20 | [3] under Windows, for Python 3.8 and above 21 | 22 | For ease of reference and to facilitate testing, qtinter's source code is 23 | arrange in a similar structure: 24 | 25 | class submodule alias 26 | ------------------------------------------------------------------------------ 27 | QiBaseEventLoop _base_events 28 | QiBaseSelectorEventLoop _selector_events 29 | QiSelectorEventLoop _unix_events QiDefaultEventLoop [1] 30 | QiSelectorEventLoop _windows_events QiDefaultEventLoop [2] 31 | QiBaseProactorEventLoop _proactor_events 32 | QiProactorEventLoop _windows_events QiDefaultEventLoop [3] 33 | (asyncio.events.BaseDefaultEventLoopPolicy) 34 | QiSelectorEventLoopPolicy _unix_events QiDefaultEventLoopPolicy [1] 35 | QiSelectorEventLoopPolicy _windows_events QiDefaultEventLoopPolicy [2] 36 | QiProactorEventLoopPolicy _windows_events QiDefaultEventLoopPolicy [3] 37 | 38 | """ 39 | import sys 40 | 41 | if sys.version_info < (3, 7): # pragma: no cover 42 | raise ImportError('qtinter requires Python 3.7 or higher') 43 | 44 | from ._base_events import * 45 | from ._selector_events import * 46 | from ._proactor_events import * 47 | from ._signals import * 48 | from ._slots import * 49 | from ._modal import * 50 | from ._contexts import * 51 | from ._tasks import * 52 | 53 | 54 | __all__ = ( 55 | _base_events.__all__ + 56 | _selector_events.__all__ + 57 | _proactor_events.__all__ + 58 | _signals.__all__ + 59 | _slots.__all__ + 60 | _modal.__all__ + 61 | _contexts.__all__ + 62 | _tasks.__all__ 63 | ) 64 | 65 | 66 | if sys.platform == 'win32': 67 | from ._windows_events import * 68 | __all__ += _windows_events.__all__ 69 | else: 70 | from ._unix_events import * 71 | __all__ += _unix_events.__all__ 72 | 73 | 74 | def new_event_loop(): 75 | return QiDefaultEventLoop() 76 | 77 | 78 | __all__ += ('new_event_loop',) 79 | -------------------------------------------------------------------------------- /src/qtinter/_contexts.py: -------------------------------------------------------------------------------- 1 | """Context managers for asyncio-Qt interop""" 2 | 3 | import asyncio.runners 4 | import contextlib 5 | import sys 6 | from typing import Callable, Optional 7 | from ._base_events import QiBaseEventLoop, QiLoopMode 8 | 9 | if sys.platform == 'win32': 10 | from ._windows_events import QiDefaultEventLoop, QiDefaultEventLoopPolicy 11 | else: 12 | from ._unix_events import QiDefaultEventLoop, QiDefaultEventLoopPolicy 13 | 14 | 15 | __all__ = 'using_asyncio_from_qt', 'using_qt_from_asyncio', 16 | 17 | 18 | @contextlib.contextmanager 19 | def using_asyncio_from_qt( 20 | *, 21 | debug: Optional[bool] = None, 22 | loop_factory: Optional[Callable[[], QiBaseEventLoop]] = None 23 | ): 24 | # Adapted from asyncio.runners 25 | if loop_factory is None: 26 | loop = QiDefaultEventLoop() 27 | else: 28 | loop = loop_factory() 29 | 30 | loop.set_mode(QiLoopMode.GUEST) 31 | if debug is not None: 32 | loop.set_debug(debug) 33 | 34 | try: 35 | asyncio.events.set_event_loop(loop) 36 | loop.start() 37 | yield 38 | finally: 39 | if loop.is_running(): 40 | # Don't stop again if user code has already stopped the loop. 41 | loop.stop() 42 | # Note: the following steps will be run in NATIVE mode because 43 | # it is undesirable, and maybe even impossible, to launch a 44 | # Qt event loop at this point -- e.g. QCoreApplication.exit() 45 | # may have been called. 46 | loop.set_mode(QiLoopMode.NATIVE) 47 | try: 48 | asyncio.runners._cancel_all_tasks(loop) 49 | loop.run_until_complete(loop.shutdown_asyncgens()) 50 | if hasattr(loop, "shutdown_default_executor"): 51 | loop.run_until_complete(loop.shutdown_default_executor()) 52 | finally: 53 | asyncio.events.set_event_loop(None) 54 | loop.close() 55 | 56 | 57 | @contextlib.contextmanager 58 | def using_qt_from_asyncio(): 59 | policy = QiDefaultEventLoopPolicy() 60 | asyncio.set_event_loop_policy(policy) 61 | try: 62 | yield 63 | finally: 64 | asyncio.set_event_loop_policy(None) 65 | -------------------------------------------------------------------------------- /src/qtinter/_helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions used by _signals.py and _slots.py""" 2 | 3 | import functools 4 | import inspect 5 | import weakref 6 | from typing import Callable, Dict 7 | 8 | 9 | __all__ = 'get_positional_parameter_count', 'transform_slot', 10 | 11 | 12 | # Holds strong references to SemiWeakRef objects keyed by their id(). 13 | # These SemiWeakRef objects have no external strong references to them, 14 | # and _references keep them alive until their referent is finalized. 15 | _references: Dict[int, "SemiWeakRef"] = dict() 16 | 17 | 18 | class SemiWeakRef: 19 | """SemiWeakRef(o) is deleted when o is deleted, except that a strong 20 | reference to SemiWeakRef(o) in user code keeps o alive.""" 21 | def __init__(self, o, ref=weakref.ref): 22 | super().__init__() # cooperative multiple inheritance 23 | 24 | # Keep a strong reference to o. 25 | self._strong_referent = o 26 | 27 | # Raises TypeError if o does not support weak reference. 28 | self._weak_referent = ref( 29 | o, functools.partial(_references.pop, id(self))) 30 | 31 | def __del__(self): 32 | # The finalizer is called when there are no strong references to 33 | # this object. Resurrect this object by adding it to _references. 34 | # Then drop the strong reference to the referent. 35 | # 36 | # Note: the finalizer is guaranteed to be called only once; see 37 | # PEP 442. However, the code below does not depend on this fact. 38 | if self._strong_referent is not None: 39 | _references[id(self)] = self 40 | self._strong_referent = None 41 | 42 | def referent(self): 43 | return self._weak_referent() 44 | 45 | 46 | def get_positional_parameter_count(fn: Callable) -> int: 47 | """Return the number of positional parameters of fn, or -1 if fn 48 | has a variadic positional parameter (*args). 49 | 50 | Raises TypeError if fn has any keyword-only parameter without a default. 51 | """ 52 | sig = inspect.signature(fn) 53 | params = sig.parameters 54 | 55 | # Parameters come in the following order of kinds (each kind is optional): 56 | # [POSITIONAL_ONLY] 57 | # [POSITION_OR_KEYWORD] 58 | # [VAR_POSITIONAL] 59 | # [KEYWORD_ONLY] 60 | # [VAR_KEYWORD] 61 | param_count = 0 62 | for p in params.values(): 63 | if p.kind == p.POSITIONAL_ONLY: 64 | param_count += 1 65 | elif p.kind == p.POSITIONAL_OR_KEYWORD: 66 | param_count += 1 67 | elif p.kind == p.VAR_POSITIONAL: 68 | param_count = -1 69 | elif p.kind == p.KEYWORD_ONLY: 70 | if p.default is p.empty: 71 | raise TypeError(f"asyncslot cannot be applied to {fn!r} " 72 | f"because it contains keyword-only argument " 73 | f"'{p.name}' without default") 74 | else: 75 | assert p.kind == p.VAR_KEYWORD 76 | pass # **kwargs will always be empty 77 | return param_count 78 | 79 | 80 | def transform_slot(slot, transform, *extra): 81 | """Return a callable wrapper that takes variadic arguments *args, 82 | such that wrapper(*arg) returns transform(slot, args, *extra). 83 | 84 | If slot is a bound method object, wrapper will also be a bound 85 | method object with the same lifetime as slot, except that a strong 86 | reference to wrapper keeps slot alive. 87 | 88 | If slot is not a bound method object, wrapper will be a function 89 | object that holds a strong reference to slot. 90 | """ 91 | # TODO: Enclose entire function body in try...finally... to remove 92 | # TODO: strong reference to fn if an exception occurs. 93 | 94 | # fn may have been decorated with Slot() or pyqtSlot(): 95 | # - Slot() adds '_slots' to the function's __dict__. 96 | # - pyqtSlot() adds '__pyqtSignature__' to the function's __dict__. 97 | # In either case, functools.wraps() or functools.update_wrapper() 98 | # will update the wrapper's __dict__ with these attributes. 99 | 100 | if hasattr(slot, "__self__"): 101 | # slot is a method object. Return a method object whose lifetime 102 | # is equal to that of the receiver object of slot, so that a 103 | # connection will be automatically disconnected if the receiver 104 | # object is deleted. 105 | from .bindings import QtCore 106 | 107 | # PyQt5/6 requires decorated slots to be hosted in QObject. 108 | # PySide2/6 requires decorated slots to be hosted in plain object. 109 | if QtCore.__name__.startswith("PyQt"): 110 | BaseClass = QtCore.QObject 111 | else: 112 | BaseClass = object 113 | 114 | class _Wrapper(SemiWeakRef, BaseClass): 115 | # Subclass in order to modify function's __dict__. 116 | def handle(self, *args): 117 | method = self.referent() 118 | assert method is not None, \ 119 | "slot called after receiver is supposedly finalized" 120 | transform(method, args, *extra) 121 | 122 | functools.update_wrapper(handle, slot) 123 | handle.__dict__.pop("__wrapped__") # remove strong ref to fn 124 | 125 | return _Wrapper(slot, weakref.WeakMethod).handle 126 | 127 | else: 128 | # fn is not a method object. Keep a strong reference to it. 129 | @functools.wraps(slot) 130 | def wrapper(*args): 131 | return transform(slot, args, *extra) 132 | 133 | return wrapper 134 | -------------------------------------------------------------------------------- /src/qtinter/_ki.py: -------------------------------------------------------------------------------- 1 | """Helper functions for handling SIGINT""" 2 | 3 | import functools 4 | import signal 5 | import sys 6 | from typing import Any, Callable 7 | 8 | 9 | __all__ = ( 10 | "with_deferred_ki", 11 | "enable_deferred_ki", 12 | "disable_deferred_ki", 13 | "raise_deferred_ki", 14 | ) 15 | 16 | 17 | class _Flag: 18 | __slots__ = '_flag', 19 | 20 | def __init__(self): 21 | self._flag = False 22 | 23 | def set(self) -> None: 24 | self._flag = True 25 | 26 | def is_set(self) -> bool: 27 | return self._flag 28 | 29 | def clear(self) -> None: 30 | self._flag = False 31 | 32 | 33 | def with_deferred_ki(fn: Callable[..., Any]): 34 | """Decorates function fn so that SIGINT does not raise KeyboardInterrupt 35 | in the immediate body of fn. However, if SIGINT is received in a 36 | function called by fn, it still raises KeyboardInterrupt. 37 | """ 38 | @functools.wraps(fn) 39 | def wrapper(*args, deferred_ki=_Flag(), **kwargs): 40 | old_deferred_ki = deferred_ki 41 | deferred_ki = _Flag() 42 | if old_deferred_ki.is_set(): 43 | deferred_ki.set() 44 | old_deferred_ki.clear() 45 | fn(*args, **kwargs) 46 | return wrapper 47 | 48 | 49 | def _deferred_ki_SIGINT_handler(sig, frame): 50 | assert sig == signal.SIGINT 51 | # if frame is not None: 52 | # print(frame.f_locals) 53 | if frame and "deferred_ki" in frame.f_locals: 54 | frame.f_locals["deferred_ki"].set() 55 | elif frame and frame.f_back and "deferred_ki" in frame.f_back.f_locals: # pragma: no cover 56 | frame.f_back.f_locals["deferred_ki"].set() 57 | else: 58 | return signal.default_int_handler(sig, frame) 59 | 60 | 61 | def enable_deferred_ki(): 62 | # Install SIGINT handlers to enable @defer_ki decoration at runtime. 63 | if signal.getsignal(signal.SIGINT) is signal.default_int_handler: 64 | try: 65 | signal.signal(signal.SIGINT, _deferred_ki_SIGINT_handler) 66 | return True 67 | except (ValueError, OSError): 68 | pass 69 | return False 70 | 71 | 72 | def disable_deferred_ki(): 73 | # Restore SIGINT handler to system default. 74 | if signal.getsignal(signal.SIGINT) is _deferred_ki_SIGINT_handler: 75 | try: 76 | signal.signal(signal.SIGINT, signal.default_int_handler) 77 | return True 78 | except (ValueError, OSError): # pragma: no cover 79 | pass 80 | return False 81 | 82 | 83 | def raise_deferred_ki(): 84 | assert "deferred_ki" in sys._getframe(2).f_locals, \ 85 | "raise_deferred_ki must be called from a function " \ 86 | "decorated with @with_deferred_ki" 87 | flag: _Flag = sys._getframe(2).f_locals["deferred_ki"] 88 | if flag.is_set(): 89 | raise KeyboardInterrupt 90 | -------------------------------------------------------------------------------- /src/qtinter/_modal.py: -------------------------------------------------------------------------------- 1 | """Implement helper function modal""" 2 | 3 | import asyncio 4 | import functools 5 | from ._base_events import QiBaseEventLoop 6 | 7 | 8 | __all__ = 'modal', 9 | 10 | 11 | def modal(fn): 12 | 13 | @functools.wraps(fn) 14 | async def modal_wrapper(*args, **kwargs): 15 | loop = asyncio.get_running_loop() 16 | if not isinstance(loop, QiBaseEventLoop): 17 | raise RuntimeError(f'qtinter.modal() requires QiBaseEventLoop, ' 18 | f'but got {loop!r}') 19 | 20 | def modal_fn(): 21 | try: 22 | result = fn(*args, **kwargs) 23 | except BaseException as exc: 24 | future.set_exception(exc) 25 | else: 26 | future.set_result(result) 27 | 28 | future = asyncio.Future() 29 | loop.exec_modal(modal_fn) 30 | return await asyncio.shield(future) 31 | 32 | return modal_wrapper 33 | -------------------------------------------------------------------------------- /src/qtinter/_proactor_events.py: -------------------------------------------------------------------------------- 1 | """ _proactor_events.py - no-op counterpart to asyncio.BaseProactorEventLoop """ 2 | 3 | import asyncio.proactor_events 4 | from ._base_events import * 5 | 6 | 7 | __all__ = 'QiBaseProactorEventLoop', 8 | 9 | 10 | class QiBaseProactorEventLoop( 11 | QiBaseEventLoop, 12 | asyncio.proactor_events.BaseProactorEventLoop 13 | ): 14 | pass 15 | -------------------------------------------------------------------------------- /src/qtinter/_selectable.py: -------------------------------------------------------------------------------- 1 | """Define the communication interface between event loop and selector.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Any, Optional 5 | 6 | 7 | __all__ = '_QiNotifier', '_QiSelectable', 8 | 9 | 10 | class _QiNotifier(ABC): 11 | """An object responsible for the communication between a QiBaseEventLoop 12 | object and a _QiSelectable object.""" 13 | 14 | @abstractmethod 15 | def no_result(self) -> Any: 16 | """Called by the selectable object if no result is immediately 17 | available for a select() call. Its return value is returned 18 | to the caller.""" 19 | 20 | @abstractmethod 21 | def notify(self) -> None: 22 | """Called by the selectable object (in a separate thread) to 23 | notify that result is available from the last select() call.""" 24 | 25 | @abstractmethod 26 | def wakeup(self) -> None: 27 | """Called by the selectable object wake up an in-progress select() 28 | in the worker thread.""" 29 | 30 | @abstractmethod 31 | def close(self) -> None: 32 | """Called by the event loop to close the notifier object. After 33 | this call, no more notifications will be received.""" 34 | 35 | 36 | class _QiSelectable(ABC): 37 | """Protocol for a 'selector' that supports non-blocking select and 38 | notification. 39 | 40 | A selector may be in one of the following states: 41 | - IDLE : the selector is not in BUSY or CLOSED state 42 | - BUSY : the last call to select() raised QiYield, and 43 | a thread worker is waiting for IO or timeout 44 | - CLOSED : close() has been called 45 | 46 | State machine: 47 | - [start] --- __init__ --> IDLE 48 | - IDLE --- close() --> CLOSED 49 | IDLE --- select 50 | - (IO ready, timeout == 0, or notifier is None) --> IDLE 51 | - (IO not ready, timeout != 0, and notifier not None) --> BUSY 52 | IDLE --- set_notifier --> IDLE 53 | - BUSY --- (IO ready or timeout reached) --> IDLE 54 | BUSY --- set_notifier --> (wakes up selector) --> IDLE 55 | - CLOSED --- [end] 56 | """ 57 | 58 | @abstractmethod 59 | def set_notifier(self, notifier: Optional[_QiNotifier]) -> None: 60 | """Set the notifier. 61 | 62 | If the selector is in BUSY state, wake it up and wait for it 63 | to become IDLE before returning. In this case, the previous 64 | installed notifier (if any) is still signaled. 65 | """ 66 | 67 | @abstractmethod 68 | def select(self, timeout: Optional[float] = None): 69 | """ 70 | If timeout is zero or some IO is readily available, return the 71 | available IO immediately. 72 | 73 | If timeout is not zero, IO is not ready and notifier is not None, 74 | launch a thread worker to perform the real select() and return 75 | notifier.no_result(), which possibly throws. When the real select() 76 | completes, signal the notifier object. 77 | 78 | If timeout is not zero, IO is not ready and notifier is None, 79 | perform normal (blocking) select. 80 | """ 81 | -------------------------------------------------------------------------------- /src/qtinter/_selector_events.py: -------------------------------------------------------------------------------- 1 | """ _selector_events.py - Qi based on SelectorEventLoop """ 2 | 3 | import asyncio.selector_events 4 | import concurrent.futures 5 | import selectors 6 | import signal 7 | import threading 8 | import unittest.mock 9 | from typing import List, Optional, Tuple 10 | from ._base_events import * 11 | from ._selectable import _QiNotifier 12 | 13 | 14 | __all__ = 'QiBaseSelectorEventLoop', 15 | 16 | 17 | class _QiSelector(selectors.BaseSelector): 18 | 19 | def __init__(self, selector: selectors.BaseSelector): 20 | super().__init__() 21 | self._selector = selector 22 | self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) 23 | self._select_future: Optional[concurrent.futures.Future] = None 24 | self._idle = threading.Event() 25 | self._idle.set() 26 | self._notifier: Optional[_QiNotifier] = None 27 | self._closed = False 28 | 29 | def set_notifier(self, notifier: Optional[_QiNotifier]) -> None: 30 | self._unblock_if_blocked() 31 | self._notifier = notifier 32 | 33 | def _unblock_if_blocked(self): 34 | assert not self._closed, 'selector already closed' 35 | if not self._idle.is_set(): 36 | assert self._notifier is not None, 'notifier expected' 37 | self._notifier.wakeup() 38 | self._idle.wait() 39 | 40 | def register(self, fileobj, events, data=None): 41 | self._unblock_if_blocked() 42 | return self._selector.register(fileobj, events, data) 43 | 44 | def unregister(self, fileobj): 45 | self._unblock_if_blocked() 46 | return self._selector.unregister(fileobj) 47 | 48 | def modify(self, fileobj, events, data=None): 49 | self._unblock_if_blocked() 50 | return self._selector.modify(fileobj, events, data) 51 | 52 | def select(self, timeout: Optional[float] = None) \ 53 | -> List[Tuple[selectors.SelectorKey, int]]: 54 | assert not self._closed, 'selector already closed' 55 | 56 | # If the last call to select() raised _QiYield, the caller 57 | # (from _run_once) should only call us again after receiving a 58 | # notification from us, and we only send the notification after 59 | # entering IDLE state. 60 | assert self._idle.is_set(), 'unexpected select' 61 | 62 | # Return previous select() result (or exception) if there is one. 63 | if self._select_future is not None: 64 | try: 65 | return self._select_future.result() 66 | finally: 67 | self._select_future = None 68 | 69 | # Perform normal (blocking) select if no notifier is set. 70 | if self._notifier is None: 71 | return self._selector.select(timeout) 72 | 73 | # Try select with zero timeout, and return if any IO is ready or 74 | # timeout is zero. 75 | event_list = self._selector.select(0) 76 | if event_list or timeout == 0: 77 | return event_list 78 | 79 | # No IO is ready and caller wants to wait. select() in a separate 80 | # thread and tell the caller to yield. 81 | self._idle.clear() 82 | try: 83 | self._select_future = self._executor.submit(self._select, timeout) 84 | except BaseException: # pragma: no cover 85 | # Should submit() raise, we assume no task is spawned. 86 | self._idle.set() 87 | raise 88 | else: 89 | return self._notifier.no_result() # raises _QiYield 90 | 91 | def _select(self, timeout): 92 | try: 93 | return self._selector.select(timeout) 94 | finally: 95 | # Make a copy of self._notifier because it may be altered by 96 | # set_notifier immediately after self._idle is set. 97 | notifier = self._notifier 98 | self._idle.set() 99 | notifier.notify() 100 | 101 | def close(self) -> None: 102 | # close() is called when the loop is being closed, and the loop 103 | # can only be closed when it is in STOPPED state. In this state 104 | # the selector must be idle. In addition, the self pipe is 105 | # closed before closing the selector, so write_to_self cannot be 106 | # used at this point. 107 | if self._closed: # pragma: no cover 108 | return 109 | 110 | assert self._idle.is_set(), 'unexpected close' 111 | self._executor.shutdown() 112 | self._selector.close() 113 | self._select_future = None 114 | self._notifier = None 115 | self._closed = True 116 | 117 | def get_key(self, fileobj): 118 | self._unblock_if_blocked() 119 | return self._selector.get_key(fileobj) 120 | 121 | def get_map(self): 122 | self._unblock_if_blocked() 123 | return self._selector.get_map() 124 | 125 | 126 | class QiBaseSelectorEventLoop( 127 | QiBaseEventLoop, 128 | asyncio.selector_events.BaseSelectorEventLoop 129 | ): 130 | def __init__(self, selector=None): 131 | if selector is None: 132 | selector = selectors.DefaultSelector() 133 | if isinstance(selector, unittest.mock.Mock): # pragma: no cover 134 | # Pass through mock object for testing 135 | qi_selector = selector 136 | else: 137 | qi_selector = _QiSelector(selector) 138 | super().__init__(qi_selector) 139 | 140 | # Similar to asyncio.BaseProactorEventLoop, install wakeup fd 141 | # so that select() in a separate thread can be interrupted by 142 | # Ctrl+C. Only the main thread of the main interpreter may 143 | # install a wakeup fd, but other threads will never receive a 144 | # KeyboardInterrupt, so it's ok if set_wakeup_fd fails. 145 | self.__wakeup_fd_installed = False 146 | self._qi_install_wakeup_fd() 147 | 148 | def _qi_install_wakeup_fd(self): 149 | try: 150 | signal.set_wakeup_fd(self._csock.fileno()) 151 | except Exception: 152 | self.__wakeup_fd_installed = False 153 | else: 154 | self.__wakeup_fd_installed = True 155 | 156 | def close(self): 157 | if self.is_running(): 158 | raise RuntimeError("Cannot close a running event loop") 159 | # Uninstall the wakeup fd is one was installed. 160 | if self.__wakeup_fd_installed: 161 | try: 162 | signal.set_wakeup_fd(-1) 163 | finally: 164 | self.__wakeup_fd_installed = False 165 | super().close() 166 | -------------------------------------------------------------------------------- /src/qtinter/_signals.py: -------------------------------------------------------------------------------- 1 | """Helper function to make Qt signal awaitable.""" 2 | 3 | import asyncio 4 | import functools 5 | from ._helpers import transform_slot 6 | 7 | 8 | __all__ = 'asyncsignal', 'asyncsignalstream', 'multisignal', 9 | 10 | 11 | def copy_signal_arguments(args): 12 | """Return a value-copy of signal arguments where necessary. 13 | 14 | PyQt5/6 passes a temporary reference to signal arguments to slots. 15 | In order to use the arguments after the slot returns, call this 16 | function to make a copy of them (via QVariant). Failure to do so 17 | may crash the program with SIGSEGV when trying to access the 18 | objects later. 19 | 20 | PySide2/6 already passes a copy of the signal arguments to slots, 21 | with proper reference counting. There is no need to copy arguments. 22 | """ 23 | from .bindings import QtCore 24 | if hasattr(QtCore, 'QVariant'): 25 | # PyQt5/6 defines QVariant; PySide2/6 doesn't. 26 | return tuple(QtCore.QVariant(arg).value() for arg in args) 27 | else: 28 | return args 29 | 30 | 31 | async def asyncsignal(signal): 32 | # signal must be a bound pyqtSignal or Signal, or an object 33 | # with a `connect` method that provides equivalent semantics. 34 | # The connection must be automatically closed when the sender 35 | # or the receiver object is deleted. 36 | # 37 | # We do not call disconnect() explicitly because the sender 38 | # might be gone when we attempt to disconnect, e.g. if waiting 39 | # for the 'destroyed' signal. 40 | from .bindings import _QiSlotObject 41 | 42 | fut = asyncio.Future() 43 | 44 | def handler(*args): 45 | nonlocal slot 46 | if not fut.done(): 47 | fut.set_result(copy_signal_arguments(args)) 48 | slot = None 49 | 50 | slot = _QiSlotObject(handler) 51 | try: 52 | signal.connect(slot.slot) 53 | return await fut 54 | finally: 55 | # In case of exception, the current frame would be stored in 56 | # the exception object, which would keep `slot` alive and 57 | # consequently keep the connection. Set `slot` to None to 58 | # prevent this. 59 | slot = None 60 | 61 | 62 | def _asyncsignalstream_handle(queue: asyncio.Queue, *args): 63 | queue.put_nowait(copy_signal_arguments(args)) 64 | 65 | 66 | class asyncsignalstream: 67 | def __init__(self, signal): 68 | from .bindings import _QiSlotObject 69 | self._queue = asyncio.Queue() 70 | self._slot = _QiSlotObject( 71 | functools.partial(_asyncsignalstream_handle, self._queue)) 72 | signal.connect(self._slot.slot) 73 | 74 | def __aiter__(self): 75 | return self 76 | 77 | async def __anext__(self): 78 | return await self._queue.get() 79 | 80 | 81 | def _emit_multisignal(slot, args, value): 82 | slot(value, copy_signal_arguments(args)) 83 | 84 | 85 | class multisignal: 86 | def __init__(self, signal_map): 87 | self.signal_map = signal_map 88 | 89 | def connect(self, slot) -> None: 90 | for signal, value in self.signal_map.items(): 91 | wrapper = transform_slot(slot, _emit_multisignal, value) 92 | signal.connect(wrapper) 93 | -------------------------------------------------------------------------------- /src/qtinter/_slots.py: -------------------------------------------------------------------------------- 1 | """ _slot.py - definition of helper functions """ 2 | 3 | import asyncio 4 | from typing import Callable, Coroutine, Set 5 | from ._tasks import run_task 6 | from ._helpers import get_positional_parameter_count, transform_slot 7 | 8 | 9 | __all__ = 'asyncslot', 10 | 11 | 12 | # Global variable to store strong reference to tasks created by asyncslot() 13 | # so that they don't get garbage collected during execution. 14 | _running_tasks: Set[asyncio.Task] = set() 15 | 16 | CoroutineFunction = Callable[..., Coroutine] 17 | 18 | 19 | def _run_coroutine_function(fn, args, param_count, task_runner): 20 | """Call coroutine function fn with no more than param_count *args and 21 | return a task wrapping the returned coroutine using task_factory.""" 22 | 23 | # Truncate arguments if slot expects fewer than signal provides 24 | if 0 <= param_count < len(args): 25 | coro = fn(*args[:param_count]) 26 | else: 27 | coro = fn(*args) 28 | 29 | task = task_runner(coro) # TODO: set name and context 30 | _running_tasks.add(task) 31 | task.add_done_callback(_running_tasks.discard) 32 | return task 33 | 34 | 35 | def asyncslot(fn: CoroutineFunction, *, task_runner=run_task): 36 | """Wrap coroutine function to make it usable as a Qt slot. 37 | 38 | If fn is a bound method object, the returned wrapper will also be a 39 | bound method object. This wrapper satisfies two properties: 40 | 41 | 1. If a strong reference to the wrapper is held by external code, 42 | fn is kept alive (and so is the wrapper). 43 | 44 | 2. If no strong reference to the wrapper is held by external code, 45 | the wrapper is kept alive until fn is garbage collected. This 46 | will automatically disconnect any connection connected to the 47 | wrapper. 48 | """ 49 | if not callable(fn): 50 | raise TypeError(f'asyncslot expects a coroutine function, ' 51 | f'but got non-callable object {fn!r}') 52 | 53 | # Because the wrapper's signature is (*args), PySide/PyQt will 54 | # always call the wrapper with the signal's (full) parameters 55 | # list instead of the slot's parameter list if it is shorter. 56 | # Work around this by "truncating" input parameters if needed. 57 | param_count = get_positional_parameter_count(fn) 58 | 59 | return transform_slot(fn, _run_coroutine_function, param_count, task_runner) 60 | -------------------------------------------------------------------------------- /src/qtinter/_tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | __all__ = "run_task", 5 | 6 | 7 | def run_task(coro, *, allow_task_nesting=True, **kwargs): 8 | """Create a Task and eagerly executes the first step.""" 9 | 10 | # If allow_task_nesting is True, this function may be called from 11 | # a running task. The calling task is 'suspended' before executing 12 | # the first step of the created task and 'resumed' after the first 13 | # step completes. 14 | 15 | loop = asyncio.get_running_loop() 16 | 17 | current_task = asyncio.tasks.current_task(loop) 18 | if current_task is not None and not allow_task_nesting: 19 | raise RuntimeError("cannot call run_task from a running task " 20 | "when allow_task_nesting is False") 21 | 22 | # asyncio.create_task() schedules asyncio.Task.__step to the end of the 23 | # loop's _ready queue. 24 | ntodo = len(loop._ready) 25 | 26 | task = asyncio.create_task(coro, **kwargs) 27 | # if task._source_traceback: 28 | # del task._source_traceback[-1] 29 | 30 | assert len(loop._ready) == ntodo + 1 31 | handle = loop._ready.pop() 32 | 33 | if current_task is not None: 34 | asyncio.tasks._leave_task(loop, current_task) 35 | try: 36 | # The following call only propagates SystemExit and KeyboardInterrupt. 37 | handle._run() 38 | finally: 39 | if current_task is not None: 40 | asyncio.tasks._enter_task(loop, current_task) 41 | 42 | # Return the task object that encapsulates the remainder of the coroutine. 43 | return task 44 | -------------------------------------------------------------------------------- /src/qtinter/_unix_events.py: -------------------------------------------------------------------------------- 1 | """ _unix_events.py - define default loop and policy under unix """ 2 | 3 | import sys 4 | 5 | if sys.platform == 'win32': 6 | raise ImportError('unix only') 7 | 8 | 9 | import asyncio.unix_events 10 | from . import _selector_events 11 | 12 | 13 | __all__ = ( 14 | 'QiDefaultEventLoop', 15 | 'QiDefaultEventLoopPolicy', 16 | 'QiSelectorEventLoop', 17 | 'QiSelectorEventLoopPolicy', 18 | ) 19 | 20 | 21 | class QiSelectorEventLoop( 22 | _selector_events.QiBaseSelectorEventLoop, 23 | asyncio.unix_events.SelectorEventLoop 24 | ): 25 | def remove_signal_handler(self, sig): 26 | result = super().remove_signal_handler(sig) 27 | if not self._signal_handlers and not self._closed: 28 | # QiBaseSelectorEventLoop installs a wakeup fd, but 29 | # _UnixSelectorEventLoop.remove_signal_handler uninstalls 30 | # it if there are no signal handlers. This is not what we 31 | # want. Re-install the wakeup in this case. 32 | self._qi_install_wakeup_fd() 33 | return result 34 | 35 | 36 | class QiSelectorEventLoopPolicy(asyncio.unix_events.DefaultEventLoopPolicy): 37 | _loop_factory = QiSelectorEventLoop 38 | 39 | 40 | QiDefaultEventLoopPolicy = QiSelectorEventLoopPolicy 41 | QiDefaultEventLoop = QiSelectorEventLoop 42 | -------------------------------------------------------------------------------- /src/qtinter/_windows_events.py: -------------------------------------------------------------------------------- 1 | """ _windows_events.py - implements proactor event loop under Windows """ 2 | 3 | import sys 4 | 5 | if sys.platform != 'win32': 6 | raise ImportError('win32 only') 7 | 8 | import _overlapped 9 | import _winapi 10 | import asyncio.windows_events 11 | import concurrent.futures 12 | import math 13 | import sys 14 | import threading 15 | from typing import Optional 16 | from ._selectable import _QiNotifier 17 | from . import _proactor_events 18 | from . import _selector_events 19 | 20 | 21 | __all__ = ( 22 | 'QiDefaultEventLoop', 23 | 'QiDefaultEventLoopPolicy', 24 | 'QiProactorEventLoop', 25 | 'QiProactorEventLoopPolicy', 26 | 'QiSelectorEventLoop', 27 | 'QiSelectorEventLoopPolicy', 28 | ) 29 | 30 | 31 | INFINITE = 0xffffffff 32 | 33 | 34 | class _QiProactor(asyncio.IocpProactor): 35 | def __init__(self, concurrency=0xffffffff): 36 | super().__init__(concurrency) 37 | 38 | self.__executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) 39 | self.__dequeue_future: Optional[concurrent.futures.Future] = None 40 | self.__idle = threading.Event() 41 | self.__idle.set() 42 | self.__notifier: Optional[_QiNotifier] = None 43 | 44 | def __wakeup(self): 45 | self._check_closed() 46 | if not self.__idle.is_set(): 47 | assert self.__notifier is not None, 'notifier expected' 48 | self.__notifier.wakeup() 49 | self.__idle.wait() 50 | 51 | def set_notifier(self, notifier: Optional[_QiNotifier]) -> None: 52 | self.__wakeup() 53 | self.__notifier = notifier 54 | 55 | def _poll(self, timeout=None): # pragma: no cover 56 | # _poll is called by super().select() and super().close(). 57 | # 58 | # If the last call to select() raised QiYield, the caller 59 | # (from _run_once) should only call select() again after receiving 60 | # a notification from us, and we only send a notification after 61 | # entering IDLE state. 62 | # 63 | # If called from close(), we also require the proactor to have 64 | # been woken up (by a call to set_notifier) before closing. 65 | # 66 | # The code below is copied verbatim from asyncio.windows_events, 67 | # except that _overlapped is 'redirected' to this object's 68 | # non-blocking implementation. The code is unchanged from Python 69 | # 3.7 through Python 3.11. 70 | _overlapped = self 71 | 72 | # --- BEGIN COPIED FROM asyncio.windows_events.IocpProactor._poll 73 | if timeout is None: 74 | ms = INFINITE 75 | elif timeout < 0: 76 | raise ValueError("negative timeout") 77 | else: 78 | # GetQueuedCompletionStatus() has a resolution of 1 millisecond, 79 | # round away from zero to wait *at least* timeout seconds. 80 | ms = math.ceil(timeout * 1e3) 81 | if ms >= INFINITE: 82 | raise ValueError("timeout too big") 83 | 84 | while True: 85 | status = _overlapped.GetQueuedCompletionStatus(self._iocp, ms) 86 | if status is None: 87 | break 88 | ms = 0 89 | 90 | err, transferred, key, address = status 91 | try: 92 | f, ov, obj, callback = self._cache.pop(address) 93 | except KeyError: 94 | if self._loop.get_debug(): 95 | self._loop.call_exception_handler({ 96 | 'message': ('GetQueuedCompletionStatus() returned an ' 97 | 'unexpected event'), 98 | 'status': ('err=%s transferred=%s key=%#x address=%#x' 99 | % (err, transferred, key, address)), 100 | }) 101 | 102 | # key is either zero, or it is used to return a pipe 103 | # handle which should be closed to avoid a leak. 104 | if key not in (0, _overlapped.INVALID_HANDLE_VALUE): 105 | _winapi.CloseHandle(key) 106 | continue 107 | 108 | if obj in self._stopped_serving: 109 | f.cancel() 110 | # Don't call the callback if _register() already read the result or 111 | # if the overlapped has been cancelled 112 | elif not f.done(): 113 | try: 114 | value = callback(transferred, key, ov) 115 | except OSError as e: 116 | f.set_exception(e) 117 | self._results.append(f) 118 | else: 119 | f.set_result(value) 120 | self._results.append(f) 121 | 122 | # Remove unregistered futures 123 | for ov in self._unregistered: 124 | self._cache.pop(ov.address, None) 125 | self._unregistered.clear() 126 | # --- END COPIED FROM asyncio.windows_events.IocpProactor._poll 127 | 128 | def GetQueuedCompletionStatus(self, iocp, ms): 129 | assert iocp is self._iocp 130 | 131 | assert self.__idle.is_set(), 'unexpected _poll' 132 | 133 | # If any prior dequeue result is available, return that. 134 | if self.__dequeue_future is not None: 135 | try: 136 | return self.__dequeue_future.result() 137 | finally: 138 | self.__dequeue_future = None 139 | 140 | # Perform normal (blocking) polling if no notifier is set. 141 | # In particular, this is the case when called by close(). 142 | if self.__notifier is None: 143 | return _overlapped.GetQueuedCompletionStatus(self._iocp, ms) 144 | 145 | # Try non-blocking dequeue and return if any result is available 146 | # or timeout is zero. 147 | status = _overlapped.GetQueuedCompletionStatus(self._iocp, 0) 148 | if status is not None or ms == 0: 149 | return status 150 | 151 | # Launch a thread worker to wait for IO. 152 | self.__idle.clear() 153 | try: 154 | self.__dequeue_future = self.__executor.submit(self.__dequeue, ms) 155 | except BaseException: # pragma: no cover 156 | # Should submit() raise, we assume no task is spawned. 157 | self.__idle.set() 158 | raise 159 | else: 160 | return self.__notifier.no_result() # raises _QiYield 161 | 162 | def __dequeue(self, ms: int): 163 | try: 164 | # Note: any exception raised is propagated to the main thread 165 | # the next time select() is called, and will bring down the 166 | # QiEventLoop. 167 | return _overlapped.GetQueuedCompletionStatus(self._iocp, ms) 168 | finally: 169 | # Make a copy of self.__notifier because it may be altered by 170 | # set_notifier immediately after self.__idle is set. 171 | notifier = self.__notifier 172 | self.__idle.set() 173 | notifier.notify() 174 | 175 | def close(self): 176 | assert self.__idle.is_set(), 'unexpected close' 177 | assert self.__notifier is None, 'notifier must have been reset' 178 | 179 | # Note: super().close() calls self._poll() repeatedly to exhaust 180 | # IO events. The first call might be served by __dequeue_future; 181 | # the remaining calls are guaranteed to block because __notifier 182 | # is None. 183 | super().close() 184 | 185 | if self.__executor is not None: 186 | self.__executor.shutdown() 187 | self.__executor = None 188 | 189 | 190 | class QiProactorEventLoop( 191 | _proactor_events.QiBaseProactorEventLoop, 192 | asyncio.windows_events.ProactorEventLoop 193 | ): 194 | def __init__(self, proactor=None): 195 | # The proactor argument is defined only for signature compatibility 196 | # with ProactorEventLoop. It must be set to None. 197 | assert proactor is None, 'proactor must be None' 198 | proactor = _QiProactor() 199 | super().__init__(proactor) 200 | 201 | if sys.version_info >= (3, 8): 202 | # run_forever is overridden in Python 3.8 and above 203 | 204 | def _qi_loop_startup(self): 205 | # ---- BEGIN COPIED FROM ProactorEventLoop.run_forever 206 | assert self._self_reading_future is None 207 | self.call_soon(self._loop_self_reading) 208 | # ---- END COPIED FROM ProactorEventLoop.run_forever 209 | super()._qi_loop_startup() 210 | 211 | def _qi_loop_cleanup(self): 212 | super()._qi_loop_cleanup() 213 | # ---- BEGIN COPIED FROM ProactorEventLoop.run_forever 214 | if self._self_reading_future is not None: 215 | ov = self._self_reading_future._ov 216 | self._self_reading_future.cancel() 217 | # self_reading_future was just cancelled so if it hasn't been 218 | # finished yet, it never will be (it's possible that it has 219 | # already finished and its callback is waiting in the queue, 220 | # where it could still happen if the event loop is restarted). 221 | # Unregister it otherwise IocpProactor.close will wait for it 222 | # forever 223 | if ov is not None: 224 | self._proactor._unregister(ov) 225 | self._self_reading_future = None 226 | # ---- END COPIED FROM ProactorEventLoop.run_forever 227 | 228 | 229 | class QiProactorEventLoopPolicy(asyncio.events.BaseDefaultEventLoopPolicy): 230 | _loop_factory = QiProactorEventLoop 231 | 232 | 233 | class QiSelectorEventLoop( 234 | _selector_events.QiBaseSelectorEventLoop, 235 | asyncio.windows_events.SelectorEventLoop 236 | ): 237 | pass 238 | 239 | 240 | class QiSelectorEventLoopPolicy(asyncio.events.BaseDefaultEventLoopPolicy): 241 | _loop_factory = QiSelectorEventLoop 242 | 243 | 244 | if sys.version_info < (3, 8): 245 | QiDefaultEventLoop = QiSelectorEventLoop 246 | QiDefaultEventLoopPolicy = QiSelectorEventLoopPolicy 247 | else: 248 | QiDefaultEventLoop = QiProactorEventLoop 249 | QiDefaultEventLoopPolicy = QiProactorEventLoopPolicy 250 | -------------------------------------------------------------------------------- /src/qtinter/bindings.py: -------------------------------------------------------------------------------- 1 | """ bindings.py - resolve Python/Qt binding at run-time """ 2 | 3 | import importlib 4 | import os 5 | import sys 6 | 7 | 8 | __all__ = 'QtCore', 9 | 10 | 11 | imported = [] 12 | for binding in ('PyQt5', 'PyQt6', 'PySide2', 'PySide6'): 13 | if binding in sys.modules: 14 | imported.append(binding) 15 | 16 | 17 | if len(imported) == 0: 18 | binding = os.getenv("QTINTERBINDING", "") 19 | if not binding: 20 | raise ImportError( 21 | 'no Qt binding is imported and QTINTERBINDING is not set') 22 | 23 | elif len(imported) == 1: 24 | binding = imported[0] 25 | 26 | else: 27 | raise ImportError(f'more than one Qt bindings are imported: {imported}') 28 | 29 | 30 | # Explicitly list the branches for coverage testing. 31 | if binding == 'PyQt5': 32 | from PyQt5 import QtCore 33 | 34 | elif binding == 'PyQt6': 35 | from PyQt6 import QtCore 36 | 37 | elif binding == 'PySide2': 38 | from PySide2 import QtCore 39 | 40 | elif binding == 'PySide6': 41 | from PySide6 import QtCore 42 | 43 | else: 44 | raise ImportError(f"unsupported QTINTERBINDING value '{binding}'") 45 | 46 | 47 | def __getattr__(name: str): 48 | # Support e.g. from qtinter.bindings import QtWidgets 49 | if name.startswith('__'): 50 | raise AttributeError 51 | return importlib.import_module(f"{binding}.{name}") 52 | 53 | 54 | class _QiObjectImpl: 55 | """Helper object to invoke callbacks on the Qt event loop.""" 56 | 57 | def __init__(self): 58 | # "Reuse" QtCore.QTimer.timeout as a parameterless signal. 59 | # Previous attempts to create a custom QObject with a custom signal 60 | # caused weird error with test_application_exited_during_loop under 61 | # (Python 3.7, macOS, PySide6). 62 | self._timer = QtCore.QTimer() 63 | 64 | def add_callback(self, callback): 65 | # Make queued connection to avoid re-entrance. 66 | self._timer.timeout.connect( 67 | callback, QtCore.Qt.ConnectionType.QueuedConnection) 68 | 69 | def remove_callback(self, callback): 70 | self._timer.timeout.disconnect(callback) 71 | 72 | def invoke_callbacks(self): 73 | self._timer.timeout.emit() 74 | 75 | 76 | class _QiSlotObject(QtCore.QObject): 77 | """Object that relays a generic slot. 78 | 79 | The connection is automatically closed when this object is deleted. 80 | """ 81 | def __init__(self, callback): 82 | super().__init__() 83 | self._callback = callback 84 | 85 | def slot(self, *args): 86 | self._callback(*args) 87 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | # The following Qt bindings are recommended to be installed for developing 2 | # asyncslot itself, so that you can test the module with each of them. 3 | # For using asyncslot, installing (any) one of the bindings is enough. 4 | PyQt5 5 | PyQt6 6 | PySide2 7 | PySide6 8 | -------------------------------------------------------------------------------- /tests/asyncio_tests.py: -------------------------------------------------------------------------------- 1 | """asyncio_tests.py - run Python bundled asyncio test suite 2 | 3 | The test suite is located in Lib/test/test_asyncio. Available test modules 4 | under different Python versions are as follows: 5 | 6 | Module 3.7 3.8 3.9 3.10 3.11 7 | ------------------------------------------------------------- 8 | test_base_events.py ✓ ✓ ✓ ✓ ✓ 9 | test_buffered_proto.py ✓ ✓ ✓ ✓ ✓ 10 | test_context.py ✓ ✓ ✓ ✓ ✓ 11 | test_events.py ✓ ✓ ✓ ✓ ✓ 12 | test_futures.py ✓ ✓ ✓ ✓ ✓ 13 | test_locks.py ✓ ✓ ✓ ✓ ✓ 14 | test_pep492.py ✓ ✓ ✓ ✓ ✓ 15 | test_proactor_events.py ✓ ✓ ✓ ✓ ✓ 16 | test_queues.py ✓ ✓ ✓ ✓ ✓ 17 | test_runners.py ✓ ✓ ✓ ✓ ✓ 18 | test_selector_events.py ✓ ✓ ✓ ✓ ✓ 19 | test_server.py ✓ ✓ ✓ ✓ ✓ 20 | test_sslproto.py ✓ ✓ ✓ ✓ ✓ 21 | test_streams.py ✓ ✓ ✓ ✓ ✓ 22 | test_subprocess.py ✓ ✓ ✓ ✓ ✓ 23 | test_tasks.py ✓ ✓ ✓ ✓ ✓ 24 | test_transports.py ✓ ✓ ✓ ✓ ✓ 25 | test_unix_events.py ✓ ✓ ✓ ✓ ✓ 26 | test_windows_events.py ✓ ✓ ✓ ✓ ✓ 27 | test_windows_utils.py ✓ ✓ ✓ ✓ ✓ 28 | test_asyncio_waitfor.py ✓ 29 | test_futures2.py ✓ ✓ ✓ ✓ 30 | test_protocols.py ✓ ✓ ✓ ✓ 31 | test_sendfile.py ✓ ✓ ✓ ✓ 32 | test_sock_lowlevel.py ✓ ✓ ✓ ✓ 33 | test_threads.py ✓ ✓ ✓ 34 | test_waitfor.py ✓ ✓ ✓ 35 | test_ssl.py ✓ 36 | test_taskgroups.py ✓ 37 | test_timeouts.py ✓ 38 | 39 | """ 40 | import sys 41 | 42 | import asyncio 43 | import asyncio.base_events 44 | import asyncio.selector_events 45 | import asyncio.proactor_events 46 | if sys.platform == 'win32': 47 | import asyncio.windows_events 48 | else: 49 | import asyncio.unix_events 50 | 51 | import qtinter 52 | import unittest 53 | 54 | from qtinter.bindings import QtCore 55 | app = QtCore.QCoreApplication([]) 56 | 57 | # We now need to monkey-patch asyncio ... 58 | 59 | asyncio.BaseEventLoop = asyncio.base_events.BaseEventLoop = qtinter.QiBaseEventLoop 60 | asyncio.selector_events.BaseSelectorEventLoop = qtinter.QiBaseSelectorEventLoop 61 | asyncio.proactor_events.BaseProactorEventLoop = qtinter.QiBaseProactorEventLoop 62 | 63 | if sys.platform == 'win32': 64 | asyncio.SelectorEventLoop = asyncio.windows_events.SelectorEventLoop = asyncio.windows_events._WindowsSelectorEventLoop = qtinter.QiSelectorEventLoop 65 | asyncio.ProactorEventLoop = asyncio.windows_events.ProactorEventLoop = qtinter.QiProactorEventLoop 66 | asyncio.IocpProactor = asyncio.windows_events.IocpProactor = qtinter._windows_events._QiProactor 67 | asyncio.WindowsSelectorEventLoopPolicy = asyncio.windows_events.WindowsSelectorEventLoopPolicy = qtinter.QiSelectorEventLoopPolicy 68 | asyncio.WindowsProactorEventLoopPolicy = asyncio.windows_events.WindowsProactorEventLoopPolicy = qtinter.QiProactorEventLoopPolicy 69 | asyncio.DefaultEventLoopPolicy = asyncio.windows_events.DefaultEventLoopPolicy = qtinter.QiDefaultEventLoopPolicy 70 | else: 71 | asyncio.SelectorEventLoop = asyncio.unix_events.SelectorEventLoop = asyncio.unix_events._UnixSelectorEventLoop = qtinter.QiSelectorEventLoop 72 | asyncio.DefaultEventLoopPolicy = asyncio.unix_events.DefaultEventLoopPolicy = asyncio.unix_events._UnixDefaultEventLoopPolicy = qtinter.QiDefaultEventLoopPolicy 73 | 74 | 75 | # Now import the tests into __main__ 76 | from test.test_asyncio import load_tests 77 | 78 | # The following test is expected to fail because the call stack is 79 | # changed with a qtinter event loop implementation. If the test passes, 80 | # it means the monkey patching didn't work! 81 | import test.test_asyncio.test_events 82 | test.test_asyncio.test_events.HandleTests.test_handle_source_traceback = \ 83 | unittest.expectedFailure(test.test_asyncio.test_events.HandleTests.test_handle_source_traceback) 84 | 85 | # To run a particular test, import that test class, and specify 86 | # TestClassName.test_name on the command line. For example: 87 | # python asyncio_tests.py ProactorLoopCtrlC.test_ctrl_c 88 | 89 | # TODO: why do we display warnings to stderr, but not asyncio? 90 | 91 | if __name__ == "__main__": 92 | unittest.main() 93 | -------------------------------------------------------------------------------- /tests/binding_raise.py: -------------------------------------------------------------------------------- 1 | """Helper script used by binding_tests.py""" 2 | 3 | import importlib 4 | import sys 5 | 6 | binding_name = sys.argv[1] 7 | exception_name = sys.argv[2] 8 | QtCore = importlib.import_module(f"{binding_name}.QtCore") 9 | app = QtCore.QCoreApplication([]) 10 | 11 | 12 | def slot(): 13 | exc = eval(exception_name) 14 | raise exc 15 | 16 | 17 | QtCore.QTimer.singleShot(0, slot) 18 | QtCore.QTimer.singleShot(0, app.quit) 19 | 20 | if hasattr(app, "exec"): 21 | app.exec() 22 | else: 23 | app.exec_() 24 | 25 | print("post exec") 26 | -------------------------------------------------------------------------------- /tests/binding_tests.py: -------------------------------------------------------------------------------- 1 | """Test PyQt5/PyQt6/PySide2/PySide6 behavior""" 2 | 3 | import os 4 | import sys 5 | import unittest 6 | from shim import QtCore, Signal, Slot, is_pyqt, run_test_script 7 | 8 | 9 | qc = QtCore.Qt.ConnectionType.QueuedConnection 10 | 11 | 12 | class SenderObject(QtCore.QObject): 13 | signal = Signal(bool) 14 | 15 | 16 | called = [] 17 | 18 | 19 | def visit(s, tag=None): 20 | if tag is not None: 21 | msg = f'{s}({tag.secret})' 22 | else: 23 | msg = s 24 | # print(msg) 25 | called.append(msg) 26 | 27 | 28 | def _test_slot(slot): 29 | called.clear() 30 | sender = SenderObject() 31 | sender.signal.connect(slot) 32 | sender.signal.emit(True) 33 | return called.copy() 34 | 35 | 36 | # ----------------------------------------------------------------------------- 37 | # Tests on free function slots 38 | # ----------------------------------------------------------------------------- 39 | 40 | def func(): 41 | visit('func') 42 | 43 | 44 | @Slot() 45 | def slot_func(): 46 | visit('slot_func') 47 | 48 | 49 | class TestFreeFunction(unittest.TestCase): 50 | 51 | def test_func(self): 52 | result = _test_slot(func) 53 | self.assertEqual(result, ['func']) 54 | 55 | def test_slot_func(self): 56 | result = _test_slot(slot_func) 57 | self.assertEqual(result, ['slot_func']) 58 | 59 | 60 | # ----------------------------------------------------------------------------- 61 | # Tests on method slots 62 | # ----------------------------------------------------------------------------- 63 | 64 | class Receiver: 65 | secret = 'Cls' 66 | 67 | def __init__(self): 68 | super().__init__() 69 | self.secret = 'Self' 70 | 71 | def method(self): 72 | visit('method', self) 73 | 74 | @Slot() 75 | def slot_method(self): 76 | visit('slot_method', self) 77 | 78 | @classmethod 79 | def class_method(cls): 80 | visit('class_method', cls) 81 | 82 | try: 83 | @Slot() 84 | @classmethod 85 | def slot_class_method(cls): 86 | visit('slot_class_method', cls) 87 | except AttributeError: 88 | # This construct is not supported on PyQt below Python 3.10. 89 | pass 90 | 91 | @classmethod 92 | @Slot() 93 | def class_slot_method(cls): 94 | visit('class_slot_method', cls) 95 | 96 | @staticmethod 97 | def static_method(): 98 | visit('static_method') 99 | 100 | try: 101 | @Slot() 102 | @staticmethod 103 | def slot_static_method(): 104 | visit('slot_static_method') 105 | except AttributeError: 106 | # This construct is not supported on PyQt below Python 3.10. 107 | pass 108 | 109 | @staticmethod 110 | @Slot() 111 | def static_slot_method(): 112 | visit('static_slot_method') 113 | 114 | 115 | class ReceiverObject(Receiver, QtCore.QObject): 116 | pass 117 | 118 | 119 | class TestReceiverObject(unittest.TestCase): 120 | 121 | def setUp(self): 122 | self.receiver = ReceiverObject() 123 | 124 | def tearDown(self): 125 | self.receiver = None 126 | 127 | def test_method(self): 128 | result = _test_slot(self.receiver.method) 129 | self.assertEqual(result, ['method(Self)']) 130 | 131 | def test_slot_method(self): 132 | result = _test_slot(self.receiver.slot_method) 133 | self.assertEqual(result, ['slot_method(Self)']) 134 | 135 | def test_class_method(self): 136 | result = _test_slot(self.receiver.class_method) 137 | self.assertEqual(result, ['class_method(Cls)']) 138 | 139 | def test_slot_class_method(self): 140 | if is_pyqt and sys.version_info < (3, 10): 141 | # PyQt does not support such construct. 142 | self.assertFalse(hasattr(self.receiver, "slot_class_method")) 143 | else: 144 | result = _test_slot(self.receiver.slot_class_method) 145 | self.assertEqual(result, ['slot_class_method(Cls)']) 146 | 147 | def test_class_slot_method(self): 148 | if is_pyqt: 149 | # Not supported by PyQt 150 | with self.assertRaises(TypeError): 151 | _test_slot(self.receiver.class_slot_method) 152 | else: 153 | result = _test_slot(self.receiver.class_slot_method) 154 | self.assertEqual(result, ['class_slot_method(Cls)']) 155 | 156 | def test_static_method(self): 157 | result = _test_slot(self.receiver.static_method) 158 | self.assertEqual(result, ['static_method']) 159 | 160 | def test_slot_static_method(self): 161 | if is_pyqt and sys.version_info < (3, 10): 162 | # PyQt does not support such construct. 163 | self.assertFalse(hasattr(self.receiver, "slot_static_method")) 164 | else: 165 | result = _test_slot(self.receiver.slot_static_method) 166 | self.assertEqual(result, ['slot_static_method']) 167 | 168 | def test_static_slot_method(self): 169 | result = _test_slot(self.receiver.static_slot_method) 170 | self.assertEqual(result, ['static_slot_method']) 171 | 172 | 173 | class TestReceiver(TestReceiverObject): 174 | def setUp(self): 175 | super().setUp() 176 | self.receiver = Receiver() 177 | 178 | def test_slot_method(self): 179 | if is_pyqt: 180 | # Not supported by PyQt 181 | with self.assertRaises(TypeError): 182 | super().test_slot_method() 183 | else: 184 | super().test_slot_method() 185 | 186 | 187 | # ----------------------------------------------------------------------------- 188 | # Tests on receiver object without __weakref__ slot 189 | # ----------------------------------------------------------------------------- 190 | 191 | class StrongReceiver: 192 | __slots__ = () 193 | 194 | def method(self): 195 | called.append('special') 196 | 197 | 198 | class StrongReceiverObject(StrongReceiver, QtCore.QObject): 199 | __slots__ = () 200 | 201 | 202 | class TestStrongReceiverObject(unittest.TestCase): 203 | def setUp(self): 204 | self.receiver = StrongReceiverObject() 205 | 206 | def tearDown(self): 207 | self.receiver = None 208 | 209 | def test_method(self): 210 | result = _test_slot(self.receiver.method) 211 | self.assertEqual(result, ['special']) 212 | 213 | 214 | class TestStrongReceiver(TestStrongReceiverObject): 215 | def setUp(self): 216 | super().setUp() 217 | self.receiver = StrongReceiver() 218 | 219 | def test_method(self): 220 | with self.assertRaises(SystemError if is_pyqt else TypeError): 221 | _test_slot(self.receiver.method) 222 | 223 | 224 | # ----------------------------------------------------------------------------- 225 | # Tests on multiple signature for same signal name 226 | # ----------------------------------------------------------------------------- 227 | 228 | class Control(QtCore.QObject): 229 | valueChanged = Signal((int,), (str,)) 230 | 231 | 232 | class Widget(QtCore.QObject): 233 | def __init__(self): 234 | super().__init__() 235 | self.control1 = Control(self) 236 | self.control1.setObjectName("control1") 237 | self.control2 = Control(self) 238 | self.control2.setObjectName("control2") 239 | self.control3 = Control(self) 240 | self.control3.setObjectName("control3") 241 | self.control4 = Control(self) 242 | self.control4.setObjectName("control4") 243 | self.metaObject().connectSlotsByName(self) 244 | self.values = [] 245 | 246 | def on_control1_valueChanged(self, newValue): 247 | self.values.append("control1") 248 | self.values.append(newValue) 249 | 250 | @Slot(int) 251 | def on_control2_valueChanged(self, newValue): 252 | self.values.append("control2") 253 | self.values.append(newValue) 254 | 255 | @Slot(str) 256 | def on_control3_valueChanged(self, newValue): 257 | self.values.append("control3") 258 | self.values.append(newValue) 259 | 260 | @Slot(int) 261 | @Slot(str) 262 | def on_control4_valueChanged(self, newValue): 263 | self.values.append("control4") 264 | self.values.append(newValue) 265 | 266 | 267 | class TestSlotSelection(unittest.TestCase): 268 | def test_slot_selection(self): 269 | values1 = [] 270 | values2 = [] 271 | values3 = [] 272 | values4 = [] 273 | 274 | def callback(): 275 | w = Widget() 276 | 277 | w.values.clear() 278 | w.control1.valueChanged[int].emit(12) 279 | w.control1.valueChanged[str].emit('ha') 280 | values1[:] = w.values 281 | 282 | w.values.clear() 283 | w.control2.valueChanged[int].emit(12) 284 | w.control2.valueChanged[str].emit('ha') 285 | values2[:] = w.values 286 | 287 | w.values.clear() 288 | w.control3.valueChanged[int].emit(12) 289 | w.control3.valueChanged[str].emit('ha') 290 | values3[:] = w.values 291 | 292 | w.values.clear() 293 | w.control4.valueChanged[int].emit(12) 294 | w.control4.valueChanged[str].emit('ha') 295 | values4[:] = w.values 296 | 297 | callback() 298 | 299 | if is_pyqt: 300 | self.assertEqual(values1, ["control1", 12, "control1", "ha"]) 301 | else: 302 | self.assertEqual(values1, []) 303 | self.assertEqual(values2, ["control2", 12]) 304 | self.assertEqual(values3, ["control3", "ha"]) 305 | self.assertEqual(values4, ["control4", 12, "control4", "ha"]) 306 | 307 | 308 | class TestErrorHandling(unittest.TestCase): 309 | # PyQt aborts on unhandled exception. PySide just logs to stderr. 310 | 311 | def test_raise_RuntimeError_from_slot(self): 312 | rc, out, err = run_test_script( 313 | "binding_raise.py", os.getenv("TEST_QT_MODULE"), "RuntimeError") 314 | if is_pyqt: 315 | if sys.platform == 'win32': 316 | self.assertEqual(rc, 0xC0000409) 317 | self.assertEqual(out, "") 318 | self.assertEqual(err, "") 319 | else: 320 | self.assertEqual(rc, -6) # SIGABRT 321 | self.assertEqual(out, "") 322 | self.assertIn("Fatal Python error: Aborted", err) 323 | else: 324 | self.assertEqual(rc, 0) 325 | self.assertEqual(out.strip(), "post exec") 326 | self.assertIn("RuntimeError", err) 327 | 328 | def test_raise_SystemExit_from_slot(self): 329 | # SystemExit is handled. 330 | rc, out, err = run_test_script( 331 | "binding_raise.py", os.getenv("TEST_QT_MODULE"), "SystemExit") 332 | self.assertEqual(rc, 0) 333 | self.assertEqual(out, "") 334 | self.assertEqual(err, "") 335 | 336 | def test_raise_KeyboardInterrupt_from_slot(self): 337 | # SystemExit is handled. 338 | rc, out, err = run_test_script( 339 | "binding_raise.py", 340 | os.getenv("TEST_QT_MODULE"), 341 | "KeyboardInterrupt") 342 | if is_pyqt: 343 | if sys.platform == 'win32': 344 | self.assertEqual(rc, 0xC0000409) 345 | self.assertEqual(out, "") 346 | self.assertEqual(err, "") 347 | else: 348 | self.assertEqual(rc, -6) # SIGABRT 349 | self.assertEqual(out, "") 350 | self.assertIn("Fatal Python error: Aborted", err) 351 | else: 352 | self.assertEqual(rc, 0) 353 | self.assertEqual(out.strip(), "post exec") 354 | self.assertIn("KeyboardInterrupt", err) 355 | 356 | 357 | class Derived(Control): 358 | pass 359 | 360 | 361 | def get_PySide2_version(): 362 | from PySide2 import __version__ as ver 363 | return tuple(map(int, ver.split("."))) 364 | 365 | 366 | def get_PySide6_version(): 367 | from PySide6 import __version__ as ver 368 | return tuple(map(int, ver.split("."))) 369 | 370 | 371 | class TestBoundSignal(unittest.TestCase): 372 | # Tests related to a bound signal. 373 | def setUp(self): 374 | if QtCore.QCoreApplication.instance() is not None: 375 | self.app = QtCore.QCoreApplication.instance() 376 | else: 377 | self.app = QtCore.QCoreApplication([]) 378 | 379 | def tearDown(self): 380 | self.app = None 381 | 382 | def test_equality(self): 383 | # Two bound signal objects bound to the same sender and same signal 384 | # should compare equal. 385 | sender = Control() 386 | s = sender.valueChanged 387 | self.assertEqual(s, s) 388 | self.assertEqual(sender.valueChanged, sender.valueChanged) 389 | self.assertEqual(sender.valueChanged, sender.valueChanged[int]) 390 | self.assertEqual(sender.valueChanged[int], sender.valueChanged[int]) 391 | self.assertEqual(sender.valueChanged[str], sender.valueChanged[str]) 392 | self.assertNotEqual(sender.valueChanged[int], sender.valueChanged[str]) 393 | 394 | # When the signal is bound to an object of a derived class, some 395 | # versions of PySide2 has a bug that breaks equality. 396 | # See https://bugreports.qt.io/projects/PYSIDE/issues/PYSIDE-2140 397 | if QtCore.__name__.startswith('PySide2'): 398 | expect_broken = get_PySide2_version() >= (5, 15, 2) 399 | else: 400 | expect_broken = False 401 | 402 | sender = Derived() 403 | s = sender.valueChanged 404 | self.assertEqual(s, s) 405 | if expect_broken: 406 | self.assertNotEqual(sender.valueChanged, sender.valueChanged) 407 | self.assertNotEqual(sender.valueChanged, sender.valueChanged[int]) 408 | self.assertNotEqual(sender.valueChanged[int], sender.valueChanged[int]) 409 | self.assertNotEqual(sender.valueChanged[str], sender.valueChanged[str]) 410 | else: 411 | self.assertEqual(sender.valueChanged, sender.valueChanged) 412 | self.assertEqual(sender.valueChanged, sender.valueChanged[int]) 413 | self.assertEqual(sender.valueChanged[int], sender.valueChanged[int]) 414 | self.assertEqual(sender.valueChanged[str], sender.valueChanged[str]) 415 | self.assertNotEqual(sender.valueChanged[int], sender.valueChanged[str]) 416 | 417 | def test_identity(self): 418 | # Test the identity between two bound signal objects bound to the same 419 | # sender and same signal. This is for information only; we should not 420 | # rely on any assumption of identity other than self identity. 421 | sender = Control() 422 | s = sender.valueChanged 423 | self.assertIs(s, s) 424 | if is_pyqt: 425 | self.assertIsNot(sender.valueChanged, sender.valueChanged) 426 | self.assertIsNot(sender.valueChanged, sender.valueChanged[int]) 427 | self.assertIsNot(sender.valueChanged[int], sender.valueChanged[int]) 428 | self.assertIsNot(sender.valueChanged[str], sender.valueChanged[str]) 429 | else: 430 | self.assertIs(sender.valueChanged, sender.valueChanged) 431 | self.assertIs(sender.valueChanged, sender.valueChanged[int]) 432 | self.assertIs(sender.valueChanged[int], sender.valueChanged[int]) 433 | self.assertIs(sender.valueChanged[str], sender.valueChanged[str]) 434 | self.assertIsNot(sender.valueChanged[int], sender.valueChanged[str]) 435 | 436 | if QtCore.__name__.startswith('PySide2'): 437 | expect_broken = get_PySide2_version() >= (5, 15, 2) 438 | else: 439 | expect_broken = False 440 | 441 | sender = Derived() 442 | s = sender.valueChanged 443 | self.assertIs(s, s) 444 | if is_pyqt or expect_broken: 445 | self.assertIsNot(sender.valueChanged, sender.valueChanged) 446 | self.assertIsNot(sender.valueChanged, sender.valueChanged[int]) 447 | self.assertIsNot(sender.valueChanged[int], sender.valueChanged[int]) 448 | self.assertIsNot(sender.valueChanged[str], sender.valueChanged[str]) 449 | else: 450 | self.assertIs(sender.valueChanged, sender.valueChanged) 451 | self.assertIs(sender.valueChanged, sender.valueChanged[int]) 452 | self.assertIs(sender.valueChanged[int], sender.valueChanged[int]) 453 | self.assertIs(sender.valueChanged[str], sender.valueChanged[str]) 454 | self.assertIsNot(sender.valueChanged[int], sender.valueChanged[str]) 455 | 456 | def test_lifetime(self): 457 | # Test the lifetime of bound signal. 458 | # - If a queued signal is emitted but the sender is then deleted: 459 | # On PySide < 6.5: the queued callback IS NOT invoked 460 | # On PyQt or PySide >= 6.5: the queued callback IS invoked. 461 | sender = SenderObject() 462 | var1 = 3 463 | var2 = 2 464 | 465 | def handler1(v): 466 | nonlocal var1 467 | var1 += {False: 15, True: 23}[v] 468 | 469 | def handler2(v): 470 | nonlocal var2 471 | var2 *= {False: -7, True: 2}[not v] 472 | 473 | bound_signal = sender.signal 474 | bound_signal.connect(handler1) 475 | bound_signal.connect(handler2, qc) 476 | bound_signal.emit(True) 477 | sender = None 478 | 479 | qt_loop = QtCore.QEventLoop() 480 | QtCore.QTimer.singleShot(100, qt_loop.quit) 481 | if hasattr(qt_loop, "exec"): 482 | qt_loop.exec() 483 | else: 484 | qt_loop.exec_() 485 | 486 | self.assertEqual(var1, 26) 487 | 488 | expect_queued_callback = is_pyqt or ( 489 | QtCore.__name__.startswith('PySide6') and 490 | get_PySide6_version() >= (6, 5) 491 | ) 492 | if expect_queued_callback: 493 | self.assertEqual(var2, -14) 494 | else: 495 | self.assertEqual(var2, 2) 496 | 497 | # The following line would crash the process with SIGSEGV 498 | # under both PySide and PyQt. 499 | # bound_signal.emit(True) 500 | 501 | 502 | class TestThread(unittest.TestCase): 503 | def test_loop_in_python_thread(self): 504 | # It should be possible to use Qt objects from a Python thread. 505 | # We run this test in a child process because the process sometimes 506 | # crashes with SIGSEGV after the test finishes (successfully) and 507 | # before the process is about to exit. Likely a bug with PySide. 508 | rc, out, err = run_test_script( 509 | "binding_thread.py", os.getenv("TEST_QT_MODULE"), "MagicToken") 510 | if out.strip() != "MagicToken": 511 | print("binding_thread.py error:", file=sys.stderr) 512 | print(err, file=sys.stderr) 513 | self.assertEqual(out.strip(), "MagicToken") 514 | if rc != 0: 515 | print(f"binding_thread.py exited with code {rc}", file=sys.stderr) 516 | 517 | 518 | if __name__ == '__main__': 519 | unittest.main() 520 | -------------------------------------------------------------------------------- /tests/binding_thread.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | import threading 4 | 5 | binding_name = sys.argv[1] 6 | token = sys.argv[2] 7 | QtCore = importlib.import_module(f"{binding_name}.QtCore") 8 | app = QtCore.QCoreApplication([]) 9 | 10 | 11 | def f(): 12 | print(token) 13 | 14 | 15 | def run(): 16 | qt_loop = QtCore.QEventLoop() 17 | QtCore.QTimer.singleShot(0, f) 18 | QtCore.QTimer.singleShot(0, qt_loop.quit) 19 | if hasattr(qt_loop, "exec"): 20 | qt_loop.exec() 21 | else: 22 | qt_loop.exec_() 23 | 24 | 25 | thread = threading.Thread(target=run) 26 | thread.start() 27 | thread.join() 28 | -------------------------------------------------------------------------------- /tests/dep_test_del.py: -------------------------------------------------------------------------------- 1 | """ dep_test_del.py - demo a strange bug with PyQt6, PySide2 and PySide6 2 | 3 | With certain code (unrelated to qtinter), these bindings raise an 4 | exception in asyncio's event loop's __del__ method, complaining about 5 | invalid file handle when attempting to unregister a self-read socket. 6 | It appears that a reference cycle is created if a QtCore.QCoreApplication 7 | instance is created. 8 | """ 9 | 10 | import asyncio 11 | import concurrent.futures 12 | import importlib 13 | import selectors 14 | import os 15 | 16 | qt_binding_name = os.getenv("TEST_QT_BINDING", "") 17 | if not qt_binding_name: 18 | raise RuntimeError("TEST_QT_BINDING must be specified") 19 | 20 | QtCore = importlib.import_module(f"{qt_binding_name}.QtCore") 21 | 22 | 23 | async def noop(): 24 | pass 25 | 26 | 27 | class CustomSelector: 28 | def __init__(self): 29 | self._selector = selectors.DefaultSelector() 30 | executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) 31 | executor.shutdown(wait=True) 32 | executor = None 33 | 34 | def register(self, fileobj, events, data=None): 35 | return self._selector.register(fileobj, events, data) 36 | 37 | def unregister(self, fileobj): 38 | return self._selector.unregister(fileobj) 39 | 40 | def modify(self, fileobj, events, data=None): 41 | return self._selector.modify(fileobj, events, data) 42 | 43 | def select(self, timeout): 44 | return self._selector.select(timeout) 45 | 46 | def close(self): 47 | self._selector.close() 48 | 49 | def get_key(self, fileobj): 50 | return self._selector.get_key(fileobj) 51 | 52 | def get_map(self): 53 | return self._selector.get_map() 54 | 55 | 56 | class CustomEventLoop(asyncio.SelectorEventLoop): 57 | def __init__(self): 58 | whatever = CustomSelector() 59 | whatever = None 60 | selector = selectors.DefaultSelector() 61 | super().__init__(selector) 62 | 63 | 64 | def main(): 65 | app = QtCore.QCoreApplication([]) 66 | loop = CustomEventLoop() 67 | loop.run_until_complete(noop()) 68 | 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /tests/example_tests.py: -------------------------------------------------------------------------------- 1 | """Run the examples""" 2 | 3 | import os 4 | import unittest 5 | from test.support.script_helper import run_python_until_end 6 | 7 | 8 | folder = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 9 | 10 | 11 | class TestExamples(unittest.TestCase): 12 | 13 | def test_where_am_i(self): 14 | result, cmd = run_python_until_end( 15 | os.path.join("examples", "where_am_i.py"), 16 | __cwd=folder, 17 | PYTHONPATH="src") 18 | if result.rc != 0: 19 | result.fail(cmd) 20 | else: 21 | print(str(result.out, encoding="utf-8")) 22 | 23 | 24 | if __name__ == "__main__": 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /tests/gui_test_clicked.py: -------------------------------------------------------------------------------- 1 | """ gui_test_clicked.py - test qtinter with QAbstractButton """ 2 | 3 | import asyncio 4 | import qtinter 5 | from shim import QtCore, QtWidgets 6 | 7 | 8 | async def quit_later(): 9 | await asyncio.sleep(0) 10 | QtWidgets.QApplication.quit() 11 | 12 | 13 | if __name__ == '__main__': 14 | app = QtWidgets.QApplication([]) 15 | 16 | button = QtWidgets.QPushButton() 17 | button.setText('Quit') 18 | button.clicked.connect(qtinter.asyncslot(quit_later)) 19 | button.show() 20 | 21 | timer = QtCore.QTimer() 22 | timer.timeout.connect(button.click) 23 | timer.start() 24 | 25 | with qtinter.using_asyncio_from_qt(): 26 | if hasattr(app, 'exec'): 27 | app.exec() 28 | else: 29 | app.exec_() 30 | -------------------------------------------------------------------------------- /tests/import1.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_import.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import asyncio 7 | import sys 8 | import importlib 9 | import qtinter 10 | 11 | binding_name = sys.argv[1] 12 | mod = importlib.import_module(f"{binding_name}.QtCore") 13 | app = mod.QCoreApplication([]) 14 | 15 | 16 | async def coro(): 17 | from qtinter.bindings import QtCore 18 | print(QtCore.__name__) 19 | 20 | with qtinter.using_qt_from_asyncio(): 21 | asyncio.run(coro()) 22 | -------------------------------------------------------------------------------- /tests/import2.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_import.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | from qtinter.bindings import QtCore 7 | print(QtCore.__name__) 8 | -------------------------------------------------------------------------------- /tests/import3.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_import.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import importlib 7 | import os 8 | import sys 9 | 10 | binding_name = os.getenv("QTINTERBINDING") 11 | 12 | mod = importlib.import_module(binding_name) 13 | 14 | sys.modules["PySide2"] = mod 15 | sys.modules["PySide6"] = mod 16 | sys.modules["PyQt5"] = mod 17 | sys.modules["PyQt6"] = mod 18 | 19 | from qtinter.bindings import QtCore 20 | -------------------------------------------------------------------------------- /tests/import4.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_import.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import sys 7 | 8 | if sys.platform == 'win32': 9 | import qtinter._unix_events 10 | else: 11 | import qtinter._windows_events 12 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | # The following packages are needed for running tests. 2 | coverage 3 | -------------------------------------------------------------------------------- /tests/runner1.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_events.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import asyncio 7 | import qtinter 8 | 9 | 10 | async def coro(): 11 | pass 12 | 13 | with qtinter.using_qt_from_asyncio(): 14 | asyncio.run(coro()) 15 | -------------------------------------------------------------------------------- /tests/runner2.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_events.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import asyncio 7 | import qtinter 8 | from qtinter.bindings import QtCore 9 | 10 | 11 | app = QtCore.QCoreApplication([]) 12 | QtCore.QCoreApplication.exit(0) 13 | 14 | 15 | async def coro(): 16 | pass 17 | 18 | with qtinter.using_qt_from_asyncio(): 19 | asyncio.run(coro()) 20 | -------------------------------------------------------------------------------- /tests/runner3.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_events.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import asyncio 7 | import qtinter 8 | from qtinter.bindings import QtCore 9 | 10 | 11 | app = QtCore.QCoreApplication([]) 12 | 13 | 14 | async def coro(): 15 | QtCore.QCoreApplication.exit(0) 16 | 17 | with qtinter.using_qt_from_asyncio(): 18 | asyncio.run(coro()) 19 | -------------------------------------------------------------------------------- /tests/runner4.py: -------------------------------------------------------------------------------- 1 | """Helper script used by test_events.py""" 2 | 3 | import coverage 4 | coverage.process_startup() 5 | 6 | import asyncio 7 | import qtinter 8 | import sys 9 | from qtinter.bindings import QtCore 10 | 11 | 12 | def f(): 13 | exception_name = sys.argv[1] 14 | raise eval(exception_name) 15 | 16 | 17 | app = QtCore.QCoreApplication([]) 18 | QtCore.QTimer.singleShot(100, app.quit) 19 | with qtinter.using_asyncio_from_qt(): 20 | asyncio.get_running_loop().call_soon(f) 21 | if hasattr(app, "exec"): 22 | app.exec() 23 | else: 24 | app.exec_() 25 | 26 | print("post exec") 27 | -------------------------------------------------------------------------------- /tests/shim.py: -------------------------------------------------------------------------------- 1 | """Test helper to import Qt binding defined by TEST_QT_MODULE environment 2 | variable. This script does not use qtinter functionality.""" 3 | 4 | import os 5 | 6 | 7 | __all__ = () 8 | 9 | 10 | qt_module_name = os.getenv("TEST_QT_MODULE", "") 11 | if qt_module_name == "": 12 | raise ImportError("environment variable TEST_QT_MODULE must be set") 13 | 14 | 15 | if qt_module_name == "PyQt5": 16 | from PyQt5 import QtCore, QtWidgets 17 | 18 | elif qt_module_name == "PyQt6": 19 | from PyQt6 import QtCore, QtWidgets 20 | 21 | elif qt_module_name == "PySide2": 22 | from PySide2 import QtCore, QtWidgets 23 | 24 | elif qt_module_name == "PySide6": 25 | from PySide6 import QtCore, QtWidgets 26 | 27 | else: 28 | raise ImportError(f"unsupported TEST_QT_MODULE value: '{qt_module_name}'") 29 | 30 | 31 | is_pyqt = QtCore.__name__.startswith('PyQt') 32 | 33 | if is_pyqt: 34 | Signal = QtCore.pyqtSignal 35 | Slot = QtCore.pyqtSlot 36 | else: 37 | Signal = QtCore.Signal 38 | Slot = QtCore.Slot 39 | 40 | 41 | def run_test_script(filename, *args, **env): 42 | from test.support.script_helper import run_python_until_end 43 | folder = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 44 | 45 | env = { 46 | "__cwd": folder, 47 | "PYTHONPATH": "src", 48 | "COVERAGE_PROCESS_START": ".coveragerc", 49 | **env 50 | } 51 | 52 | result, cmd = run_python_until_end( 53 | os.path.join("tests", filename), *args, **env) 54 | return ( 55 | result.rc, 56 | str(result.out, encoding="utf-8"), 57 | str(result.err, encoding="utf-8"), 58 | ) 59 | 60 | 61 | def exec_qt_loop(loop): 62 | if hasattr(loop, 'exec'): 63 | loop.exec() 64 | else: 65 | loop.exec_() 66 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | """Test ways to import qtinter""" 2 | 3 | import os 4 | import unittest 5 | from shim import run_test_script 6 | 7 | 8 | class TestImport(unittest.TestCase): 9 | def test_no_package(self): 10 | # If no binding is imported and QTINTERBINDING is not defined, 11 | # raise import error. 12 | rc, out, err = run_test_script( 13 | "import2.py", 14 | QTINTERBINDING="") 15 | self.assertEqual(rc, 1) 16 | self.assertIn("ImportError: no Qt binding is imported " 17 | "and QTINTERBINDING is not set", err) 18 | 19 | def test_unique_package(self): 20 | # When a unique binding is imported, that binding is used and 21 | # QTINTERBINDING is ignored. It's also OK to import qtinter 22 | # before importing the binding (i.e. binding resolution is lazy). 23 | rc, out, err = run_test_script( 24 | "import1.py", 25 | os.getenv("TEST_QT_MODULE"), 26 | QTINTERBINDING="Whatever") 27 | self.assertEqual(rc, 0) 28 | self.assertEqual(out.rstrip(), f"{os.getenv('TEST_QT_MODULE')}.QtCore") 29 | 30 | def test_multiple_package(self): 31 | # When two or more bindings are imported, raise ImportError. 32 | rc, out, err = run_test_script( 33 | "import3.py", 34 | QTINTERBINDING=os.getenv("TEST_QT_MODULE")) 35 | self.assertEqual(rc, 1) 36 | self.assertIn("ImportError: more than one Qt bindings are imported", 37 | err) 38 | 39 | def test_good_env_variable(self): 40 | # When QTINTERBINDING is set to a good value, it should be used. 41 | rc, out, err = run_test_script( 42 | "import2.py", 43 | QTINTERBINDING=os.getenv("TEST_QT_MODULE")) 44 | self.assertEqual(rc, 0) 45 | self.assertEqual(out.rstrip(), f"{os.getenv('TEST_QT_MODULE')}.QtCore") 46 | 47 | def test_bad_env_variable(self): 48 | # Invalid QTINTERBINDING should raise ImportError. 49 | rc, out, err = run_test_script( 50 | "import2.py", 51 | QTINTERBINDING="Whatever") 52 | self.assertEqual(rc, 1) 53 | self.assertIn( 54 | "ImportError: unsupported QTINTERBINDING value 'Whatever'", err) 55 | 56 | def test_wrong_platform_import(self): 57 | # Importing the submodule of wrong platform raises ImportError. 58 | rc, out, err = run_test_script( 59 | "import4.py", 60 | QTINTERBINDING=os.getenv("TEST_QT_MODULE")) 61 | self.assertEqual(rc, 1) 62 | self.assertIn("ImportError", err) 63 | 64 | 65 | if __name__ == "__main__": 66 | unittest.main() 67 | -------------------------------------------------------------------------------- /tests/test_signal.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import qtinter 3 | import unittest 4 | from shim import QtCore, Signal, exec_qt_loop 5 | 6 | 7 | class SenderObject(QtCore.QObject): 8 | signal0 = Signal() 9 | signal1 = Signal(object) 10 | signal2 = Signal(str, object) 11 | 12 | 13 | class TestSignal(unittest.TestCase): 14 | 15 | def setUp(self) -> None: 16 | if QtCore.QCoreApplication.instance() is not None: 17 | self.app = QtCore.QCoreApplication.instance() 18 | else: 19 | self.app = QtCore.QCoreApplication([]) 20 | 21 | def tearDown(self) -> None: 22 | self.app = None 23 | 24 | def test_builtin_signal(self): 25 | # Test asyncsignal() with built-in signal QTimer.timeout 26 | timer = QtCore.QTimer() 27 | timer.setInterval(100) 28 | timer.start() 29 | 30 | async def coro(): 31 | await qtinter.asyncsignal(timer.timeout) 32 | return 123 33 | 34 | with qtinter.using_qt_from_asyncio(): 35 | self.assertEqual(asyncio.run(coro()), 123) 36 | 37 | def test_signal_with_no_argument(self): 38 | sender = SenderObject() 39 | 40 | async def coro(): 41 | asyncio.get_running_loop().call_soon(sender.signal0.emit) 42 | return await qtinter.asyncsignal(sender.signal0) 43 | 44 | with qtinter.using_qt_from_asyncio(): 45 | self.assertEqual(asyncio.run(coro()), ()) 46 | 47 | def test_signal_with_one_argument(self): 48 | sender = SenderObject() 49 | arg = object() 50 | 51 | async def coro(): 52 | asyncio.get_running_loop().call_soon(sender.signal1.emit, arg) 53 | return await qtinter.asyncsignal(sender.signal1) 54 | 55 | with qtinter.using_qt_from_asyncio(): 56 | self.assertEqual(asyncio.run(coro()), (arg,)) 57 | 58 | def test_signal_with_two_arguments(self): 59 | sender = SenderObject() 60 | 61 | async def coro(): 62 | asyncio.get_running_loop().call_soon( 63 | sender.signal2.emit, "Hello", (1.5, "metre")) 64 | return await qtinter.asyncsignal(sender.signal2) 65 | 66 | with qtinter.using_qt_from_asyncio(): 67 | self.assertEqual(asyncio.run(coro()), ("Hello", (1.5, "metre"))) 68 | 69 | def test_cancellation(self): 70 | # asyncsignal should be able to be cancelled 71 | timer = QtCore.QTimer() 72 | timer.setInterval(0) 73 | 74 | async def coro(): 75 | await qtinter.asyncsignal(timer.timeout) 76 | 77 | async def main(): 78 | task = asyncio.create_task(coro()) 79 | await asyncio.sleep(0) 80 | task.cancel() 81 | timer.start() 82 | await task 83 | 84 | with qtinter.using_qt_from_asyncio(): 85 | with self.assertRaises(asyncio.CancelledError): 86 | asyncio.run(main()) 87 | 88 | def test_sender_gone(self): 89 | # If the sender is garbage collected, asyncsignal should hang forever. 90 | timer = QtCore.QTimer() 91 | timer.setInterval(100) 92 | timer.start() 93 | 94 | def delete_timer(): 95 | nonlocal timer 96 | timer = None 97 | 98 | async def coro(): 99 | asyncio.get_running_loop().call_soon(delete_timer) 100 | await qtinter.asyncsignal(timer.timeout) 101 | 102 | with qtinter.using_qt_from_asyncio(): 103 | with self.assertRaises(asyncio.TimeoutError): 104 | asyncio.run(asyncio.wait_for(coro(), 0.5)) 105 | 106 | def test_destroyed(self): 107 | # Should be able to catch destroyed signal 108 | timer = QtCore.QTimer() 109 | timer.setInterval(100) 110 | timer.start() 111 | 112 | def delete_timer(): 113 | nonlocal timer 114 | timer = None 115 | 116 | async def coro(): 117 | asyncio.get_running_loop().call_soon(delete_timer) 118 | await qtinter.asyncsignal(timer.destroyed) 119 | return 123 120 | 121 | with qtinter.using_qt_from_asyncio(): 122 | self.assertEqual(asyncio.run(coro()), 123) 123 | 124 | def test_copy_args(self): 125 | # asyncsignal must copy the signal arguments, because some arguments 126 | # are temporary objects that go out of scope when the slot returns. 127 | # If not copied, SIGSEGV will be raised. 128 | from qtinter.bindings import QtPositioning 129 | 130 | source = QtPositioning.QGeoPositionInfoSource.createDefaultSource( 131 | self.app) 132 | 133 | async def emit(): 134 | # Emit signal from a different thread to make Qt send a temporary 135 | # copy of the argument via queued connection. 136 | await asyncio.get_running_loop().run_in_executor( 137 | None, 138 | source.positionUpdated.emit, 139 | QtPositioning.QGeoPositionInfo()) 140 | 141 | async def coro(): 142 | asyncio.get_running_loop().call_soon(asyncio.create_task, emit()) 143 | position: QtPositioning.QGeoPositionInfo = \ 144 | (await qtinter.asyncsignal(source.positionUpdated))[0] 145 | return position.coordinate().toString() 146 | 147 | with qtinter.using_qt_from_asyncio(): 148 | self.assertEqual(asyncio.run(coro()), "") 149 | 150 | 151 | class TestAsyncSignalStream(unittest.TestCase): 152 | def setUp(self): 153 | if QtCore.QCoreApplication.instance() is not None: 154 | self.app = QtCore.QCoreApplication.instance() 155 | else: 156 | self.app = QtCore.QCoreApplication([]) 157 | 158 | def tearDown(self): 159 | self.app = None 160 | 161 | def test_timer(self): 162 | timer = QtCore.QTimer() 163 | timer.setInterval(100) 164 | timer.start() 165 | 166 | async def coro(): 167 | n = 0 168 | async for _ in qtinter.asyncsignalstream(timer.timeout): 169 | n += 1 170 | if n == 10: 171 | break 172 | 173 | import time 174 | t1 = time.time() 175 | with qtinter.using_qt_from_asyncio(): 176 | asyncio.run(coro()) 177 | t2 = time.time() 178 | 179 | self.assertTrue(0.9 < t2 - t1 < 1.5, t2 - t1) 180 | 181 | 182 | class TestMultiSignal(unittest.TestCase): 183 | def setUp(self): 184 | if QtCore.QCoreApplication.instance() is not None: 185 | self.app = QtCore.QCoreApplication.instance() 186 | else: 187 | self.app = QtCore.QCoreApplication([]) 188 | 189 | def tearDown(self): 190 | self.app = None 191 | 192 | def test_empty_map(self): 193 | # Passing an empty signal map is a no-op 194 | ms = qtinter.multisignal(dict()) 195 | ms.connect(lambda: None) 196 | 197 | def test_multiple_senders(self): 198 | # Test multisignal usage with multiple senders. 199 | 200 | timer1 = QtCore.QTimer() 201 | timer1.setInterval(100) 202 | timer1.start() 203 | 204 | timer2 = QtCore.QTimer() 205 | timer2.setInterval(250) 206 | timer2.start() 207 | 208 | ms = qtinter.multisignal({timer1.timeout: 'A', timer2.timeout: 4}) 209 | result = [] 210 | ms.connect(lambda s, a: result.append((s, a))) 211 | 212 | qt_loop = QtCore.QEventLoop() 213 | timer2.timeout.connect(qt_loop.quit) 214 | exec_qt_loop(qt_loop) 215 | 216 | self.assertEqual(result, [('A', ()), ('A', ()), (4, ())]) 217 | 218 | 219 | if __name__ == "__main__": 220 | unittest.main() 221 | -------------------------------------------------------------------------------- /tests/test_sleep.py: -------------------------------------------------------------------------------- 1 | from shim import QtCore 2 | import asyncio 3 | import qtinter 4 | import time 5 | import unittest 6 | 7 | 8 | class TestSleep(unittest.TestCase): 9 | 10 | def setUp(self) -> None: 11 | if QtCore.QCoreApplication.instance() is not None: 12 | self.app = QtCore.QCoreApplication.instance() 13 | else: 14 | self.app = QtCore.QCoreApplication([]) 15 | self.loop = qtinter.QiDefaultEventLoop() 16 | 17 | def tearDown(self) -> None: 18 | self.loop.close() 19 | self.app = None 20 | 21 | def test_sleep(self): 22 | total_duration = 2.0 23 | 24 | async def sleep_for(times): 25 | for _ in range(times): 26 | await asyncio.sleep(total_duration / times) 27 | 28 | async def entry(): 29 | t1 = time.time() 30 | tasks = [] 31 | for _ in range(100): 32 | tasks.append(sleep_for(8)) 33 | tasks.append(sleep_for(4)) 34 | tasks.append(sleep_for(2)) 35 | tasks.append(sleep_for(1)) 36 | await asyncio.gather(*tasks) 37 | t2 = time.time() 38 | return t2 - t1 39 | 40 | total_time = self.loop.run_until_complete(entry()) 41 | self.assertGreater(total_time, total_duration - 0.1) 42 | self.assertLess(total_time, total_duration + 1.5) 43 | 44 | 45 | if __name__ == '__main__': 46 | unittest.main() 47 | -------------------------------------------------------------------------------- /tests/test_slot.py: -------------------------------------------------------------------------------- 1 | """ test_slot.py - test the asyncslot() function """ 2 | 3 | import asyncio 4 | import sys 5 | import unittest 6 | from shim import QtCore, Signal, Slot, is_pyqt 7 | from qtinter import asyncslot, using_asyncio_from_qt 8 | 9 | 10 | class SenderObject(QtCore.QObject): 11 | signal = Signal(bool) 12 | 13 | 14 | called = [] 15 | 16 | 17 | def visit(s, tag=None): 18 | if tag is not None: 19 | msg = f'{s}({tag.secret})' 20 | else: 21 | msg = s 22 | # print(msg) 23 | called.append(msg) 24 | 25 | qc = QtCore.Qt.ConnectionType.QueuedConnection 26 | 27 | qt_slot_supports_descriptor = not QtCore.__name__.startswith('PyQt') 28 | 29 | 30 | class TestMixin: 31 | def setUp(self): 32 | if QtCore.QCoreApplication.instance() is not None: 33 | self.app = QtCore.QCoreApplication.instance() 34 | else: 35 | self.app = QtCore.QCoreApplication([]) 36 | 37 | def tearDown(self): 38 | self.app = None 39 | 40 | def _test_slot(self, slot): 41 | 42 | loop = QtCore.QEventLoop() 43 | QtCore.QTimer.singleShot(0, loop.quit) 44 | 45 | sender = SenderObject() 46 | sender.signal.connect(slot, QtCore.Qt.ConnectionType.QueuedConnection) 47 | sender.signal.emit(True) 48 | 49 | called.clear() 50 | with using_asyncio_from_qt(): 51 | if hasattr(loop, 'exec'): 52 | loop.exec() 53 | else: 54 | loop.exec_() 55 | return called.copy() 56 | 57 | 58 | # ============================================================================= 59 | # Tests on free function as slot 60 | # ============================================================================= 61 | 62 | async def afunc(): 63 | visit('afunc.1') 64 | await asyncio.sleep(0) 65 | visit('afunc.2') 66 | 67 | 68 | @Slot() 69 | async def slot_afunc(): 70 | visit('slot_afunc.1') 71 | await asyncio.sleep(0) 72 | visit('slot_afunc.2') 73 | 74 | 75 | @asyncslot 76 | async def decorated_afunc(): 77 | visit('decorated_afunc.1') 78 | await asyncio.sleep(0) 79 | visit('decorated_afunc.2') 80 | 81 | 82 | @Slot() 83 | @asyncslot 84 | async def slot_decorated_afunc(): 85 | visit('slot_decorated_afunc.1') 86 | await asyncio.sleep(0) 87 | visit('slot_decorated_afunc.2') 88 | 89 | 90 | @asyncslot 91 | @Slot() 92 | async def decorated_slot_afunc(): 93 | visit('decorated_slot_afunc.1') 94 | await asyncio.sleep(0) 95 | visit('decorated_slot_afunc.2') 96 | 97 | 98 | class TestFreeFunction(TestMixin, unittest.TestCase): 99 | 100 | # ------------------------------------------------------------------------- 101 | # Test async free function without Slot decoration 102 | # ------------------------------------------------------------------------- 103 | 104 | def test_wrapped_afunc(self): 105 | result = self._test_slot(asyncslot(afunc)) 106 | self.assertEqual(result, ['afunc.1', 'afunc.2']) 107 | 108 | def test_decorated_afunc(self): 109 | result = self._test_slot(decorated_afunc) 110 | self.assertEqual(result, ['decorated_afunc.1', 'decorated_afunc.2']) 111 | 112 | # ------------------------------------------------------------------------- 113 | # Test async free function with Slot decoration 114 | # ------------------------------------------------------------------------- 115 | 116 | def test_wrapped_slot_afunc(self): 117 | self.assertEqual(self._test_slot(asyncslot(slot_afunc)), 118 | ['slot_afunc.1', 'slot_afunc.2']) 119 | 120 | def test_decorated_slot_afunc(self): 121 | self.assertEqual(self._test_slot(decorated_slot_afunc), 122 | ['decorated_slot_afunc.1', 'decorated_slot_afunc.2']) 123 | 124 | def test_slot_decorated_afunc(self): 125 | self.assertEqual(self._test_slot(slot_decorated_afunc), 126 | ['slot_decorated_afunc.1', 'slot_decorated_afunc.2']) 127 | 128 | # ------------------------------------------------------------------------- 129 | # Test wrapped free function that's not apparently a coroutine function 130 | # ------------------------------------------------------------------------- 131 | 132 | def test_wrapped_afunc_indirect(self): 133 | self.assertEqual(self._test_slot(asyncslot(lambda: afunc())), 134 | ['afunc.1', 'afunc.2']) 135 | 136 | # ------------------------------------------------------------------------- 137 | # Test invalid arguments to asyncslot 138 | # ------------------------------------------------------------------------- 139 | 140 | def test_invalid_argument_type(self): 141 | with self.assertRaises(TypeError): 142 | asyncslot(afunc()) 143 | 144 | def _test_excess_arguments(self, f): 145 | # Slot requires more arguments than signal provides 146 | error = None 147 | 148 | def g(*args, **kwargs): 149 | nonlocal error 150 | try: 151 | asyncslot(f)(*args, **kwargs) 152 | except BaseException as exc: 153 | error = exc 154 | 155 | self._test_slot(g) 156 | self.assertIsInstance(error, TypeError) 157 | 158 | def test_excess_regular_arguments(self): 159 | async def f(a, b): pass 160 | self._test_excess_arguments(f) 161 | 162 | @unittest.skipIf(sys.version_info < (3, 8), "requires Python >= 3.8") 163 | def test_excess_positional_arguments(self): 164 | local_vars = dict() 165 | exec("async def f(a, b, /): pass", globals(), local_vars) 166 | self._test_excess_arguments(local_vars["f"]) 167 | 168 | def test_keyword_only_arguments_without_default(self): 169 | async def f(*, a): pass 170 | async def g(*args, a): pass 171 | with self.assertRaises(TypeError): 172 | asyncslot(f) 173 | with self.assertRaises(TypeError): 174 | asyncslot(g) 175 | 176 | def test_keyword_only_arguments_with_default(self): 177 | async def f(*, a=10): 178 | called.append(a) 179 | 180 | async def g(*args, a=20): 181 | called.append(len(args)) 182 | called.append(a) 183 | 184 | self.assertEqual(self._test_slot(asyncslot(f)), [10]) 185 | self.assertEqual(self._test_slot(asyncslot(g)), [1, 20]) 186 | 187 | def test_var_keyword_arguments(self): 188 | async def f(**kwargs): 189 | called.append(len(kwargs)) 190 | 191 | self.assertEqual(self._test_slot(asyncslot(f)), [0]) 192 | 193 | # ------------------------------------------------------------------------- 194 | # Test asyncslot without a loop 195 | # ------------------------------------------------------------------------- 196 | 197 | def test_no_loop(self): 198 | async def f(): pass 199 | with self.assertRaisesRegex(RuntimeError, 'no running event loop'): 200 | asyncslot(f)() 201 | 202 | # ------------------------------------------------------------------------- 203 | # Test asyncslot with a native asyncio loop (which works) 204 | # ------------------------------------------------------------------------- 205 | 206 | def test_native_loop(self): 207 | var = 1 208 | 209 | async def g(): 210 | nonlocal var 211 | var = 2 212 | await asyncio.sleep(1) 213 | var = 3 214 | 215 | async def f(): 216 | asyncslot(g)() 217 | assert var == 2 218 | 219 | asyncio.run(f()) 220 | self.assertEqual(var, 2) 221 | 222 | 223 | # ============================================================================= 224 | # Tests on methods as slot 225 | # ============================================================================= 226 | 227 | class Receiver: 228 | secret = 'Cls' 229 | 230 | def __init__(self): 231 | super().__init__() # needed for cooperative multiple inheritance 232 | self.secret = 'Self' 233 | 234 | # ------------------------------------------------------------------------- 235 | # Instance method 236 | # ------------------------------------------------------------------------- 237 | 238 | async def amethod(self): 239 | visit('amethod.1', self) 240 | await asyncio.sleep(0) 241 | visit('amethod.2', self) 242 | 243 | @asyncslot 244 | async def decorated_amethod(self): 245 | visit('decorated_amethod.1', self) 246 | await asyncio.sleep(0) 247 | visit('decorated_amethod.2', self) 248 | 249 | @Slot() 250 | async def slot_amethod(self): 251 | visit('slot_amethod.1', self) 252 | await asyncio.sleep(0) 253 | visit('slot_amethod.2', self) 254 | 255 | @Slot() 256 | @asyncslot 257 | async def slot_decorated_amethod(self): 258 | visit('slot_decorated_amethod.1', self) 259 | await asyncio.sleep(0) 260 | visit('slot_decorated_amethod.2', self) 261 | 262 | @asyncslot 263 | @Slot() 264 | async def decorated_slot_amethod(self): 265 | visit('decorated_slot_amethod.1', self) 266 | await asyncio.sleep(0) 267 | visit('decorated_slot_amethod.2', self) 268 | 269 | # ------------------------------------------------------------------------- 270 | # Class method 271 | # ------------------------------------------------------------------------- 272 | 273 | @classmethod 274 | async def class_amethod(cls): 275 | visit('class_amethod.1', cls) 276 | await asyncio.sleep(0) 277 | visit('class_amethod.2', cls) 278 | 279 | @classmethod 280 | @asyncslot 281 | async def class_decorated_amethod(cls): 282 | visit('class_decorated_amethod.1', cls) 283 | await asyncio.sleep(0) 284 | visit('class_decorated_amethod.2', cls) 285 | 286 | # TODO: slot_class_amethod, class_slot_amethod, 287 | # TODO: slot_class_decorated_amethod, class_slot_decorated_amethod, 288 | # TODO: class_decorated_slot_amethod 289 | 290 | # ------------------------------------------------------------------------- 291 | # Static method 292 | # ------------------------------------------------------------------------- 293 | 294 | @staticmethod 295 | async def static_amethod(): 296 | visit('static_amethod.1') 297 | await asyncio.sleep(0) 298 | visit('static_amethod.2') 299 | 300 | @staticmethod 301 | @asyncslot 302 | async def static_decorated_amethod(): 303 | visit('static_decorated_amethod.1') 304 | await asyncio.sleep(0) 305 | visit('static_decorated_amethod.2') 306 | 307 | # TODO: slot_static_amethod, static_slot_amethod, 308 | # TODO: slot_static_decorated_amethod, static_slot_decorated_amethod, 309 | # TODO: static_decorated_slot_amethod 310 | 311 | 312 | class ReceiverObject(Receiver, QtCore.QObject): 313 | pass 314 | 315 | 316 | class TestReceiverObject(TestMixin, unittest.TestCase): 317 | 318 | def setUp(self): 319 | super().setUp() 320 | self.receiver = ReceiverObject() 321 | 322 | def tearDown(self): 323 | self.receiver = None 324 | super().tearDown() 325 | 326 | # ------------------------------------------------------------------------- 327 | # Test instance method 328 | # ------------------------------------------------------------------------- 329 | 330 | def test_wrapped_amethod(self): 331 | self.assertEqual(self._test_slot(asyncslot(self.receiver.amethod)), 332 | ['amethod.1(Self)', 'amethod.2(Self)']) 333 | 334 | def test_decorated_amethod(self): 335 | self.assertEqual( 336 | self._test_slot(self.receiver.decorated_amethod), 337 | ['decorated_amethod.1(Self)', 'decorated_amethod.2(Self)']) 338 | 339 | def test_wrapped_slot_amethod(self): 340 | self.assertEqual( 341 | self._test_slot(asyncslot(self.receiver.slot_amethod)), 342 | ['slot_amethod.1(Self)', 'slot_amethod.2(Self)']) 343 | 344 | def test_decorated_slot_amethod(self): 345 | self.assertEqual( 346 | self._test_slot(self.receiver.decorated_slot_amethod), 347 | ['decorated_slot_amethod.1(Self)', 'decorated_slot_amethod.2(Self)']) 348 | 349 | def test_slot_decorated_amethod(self): 350 | self.assertEqual( 351 | self._test_slot(self.receiver.slot_decorated_amethod), 352 | ['slot_decorated_amethod.1(Self)', 'slot_decorated_amethod.2(Self)']) 353 | 354 | # ------------------------------------------------------------------------- 355 | # Test class method 356 | # ------------------------------------------------------------------------- 357 | 358 | def test_wrapped_class_amethod(self): 359 | self.assertEqual( 360 | self._test_slot(asyncslot(self.receiver.class_amethod)), 361 | ['class_amethod.1(Cls)', 'class_amethod.2(Cls)']) 362 | 363 | def test_class_decorated_amethod(self): 364 | self.assertEqual( 365 | self._test_slot(self.receiver.class_decorated_amethod), 366 | ['class_decorated_amethod.1(Cls)', 'class_decorated_amethod.2(Cls)']) 367 | 368 | # ------------------------------------------------------------------------- 369 | # Test static method 370 | # ------------------------------------------------------------------------- 371 | 372 | def test_wrapped_static_amethod(self): 373 | self.assertEqual( 374 | self._test_slot(asyncslot(self.receiver.static_amethod)), 375 | ['static_amethod.1', 'static_amethod.2']) 376 | 377 | def test_static_decorated_amethod(self): 378 | self.assertEqual( 379 | self._test_slot(self.receiver.static_decorated_amethod), 380 | ['static_decorated_amethod.1', 'static_decorated_amethod.2']) 381 | 382 | 383 | class TestReceiver(TestReceiverObject): 384 | def setUp(self): 385 | super().setUp() 386 | self.receiver = Receiver() 387 | 388 | @unittest.skipIf(is_pyqt, "not supported by PyQt") 389 | def test_slot_decorated_amethod(self): 390 | super().test_slot_decorated_amethod() 391 | 392 | @unittest.skipIf(is_pyqt, "not supported by PyQt") 393 | def test_decorated_slot_amethod(self): 394 | super().test_decorated_slot_amethod() 395 | 396 | 397 | # ============================================================================= 398 | # Test misc slot behavior 399 | # ============================================================================= 400 | 401 | 402 | class IntSender(QtCore.QObject): 403 | signal = Signal(int) 404 | 405 | 406 | class IntReceiver: 407 | def __init__(self, output): 408 | self.output = output 409 | 410 | async def original_slot(self, v): 411 | self.output[0] += v 412 | 413 | @asyncslot 414 | async def decorated_slot(self, v): 415 | self.output[0] *= v 416 | 417 | 418 | class StrongReceiver: 419 | __slots__ = 'output', 420 | 421 | def __init__(self, output): 422 | self.output = output 423 | 424 | def method(self, v): 425 | self.output[0] -= v 426 | 427 | async def amethod(self, v): 428 | self.output[0] += v 429 | 430 | @asyncslot 431 | async def decorated_amethod(self, v): 432 | self.output[0] *= v 433 | 434 | 435 | class TestSlotBehavior(unittest.TestCase): 436 | 437 | def setUp(self): 438 | if QtCore.QCoreApplication.instance() is not None: 439 | self.app = QtCore.QCoreApplication.instance() 440 | else: 441 | self.app = QtCore.QCoreApplication([]) 442 | 443 | def tearDown(self): 444 | self.app = None 445 | 446 | def test_weak_reference_decorated(self): 447 | # Connection with bounded decorated method holds weak reference. 448 | output = [1] 449 | sender = IntSender() 450 | receiver = IntReceiver(output) 451 | with using_asyncio_from_qt(): 452 | sender.signal.connect(receiver.decorated_slot) 453 | sender.signal.emit(3) 454 | self.assertEqual(output[0], 3) 455 | receiver = None 456 | sender.signal.emit(5) 457 | # expecting no change, because connection should have been deleted 458 | self.assertEqual(output[0], 3) 459 | 460 | def test_weak_reference_wrapped(self): 461 | # Wrapping a bounded method holds strong reference to the receiver 462 | # object. 463 | output = [1] 464 | sender = IntSender() 465 | receiver = IntReceiver(output) 466 | with using_asyncio_from_qt(): 467 | sender.signal.connect(asyncslot(receiver.original_slot)) 468 | sender.signal.emit(3) 469 | self.assertEqual(output[0], 4) 470 | receiver = None 471 | sender.signal.emit(5) 472 | # expecting change, because connection is still alive 473 | self.assertEqual(output[0], 4) 474 | 475 | def test_weak_reference_wrapped_2(self): 476 | # Keeping a (strong) reference to wrapped asyncslot keeps the 477 | # underlying method alive (similar to keeping a strong reference 478 | # to the underlying method). 479 | output = [1] 480 | sender = IntSender() 481 | receiver = IntReceiver(output) 482 | with using_asyncio_from_qt(): 483 | the_slot = asyncslot(receiver.original_slot) 484 | sender.signal.connect(the_slot) 485 | # TODO: test disconnect(the_slot) 486 | sender.signal.emit(3) 487 | self.assertEqual(output[0], 4) 488 | receiver = None 489 | sender.signal.emit(5) 490 | # The slot should still be invoked because the_slot keeps it alive. 491 | self.assertEqual(output[0], 9) 492 | the_slot = None 493 | sender.signal.emit(6) 494 | # The slot should no longer be called 495 | self.assertEqual(output[0], 9) 496 | 497 | def test_strong_reference(self): 498 | # Wrapping a method in partial keeps the receiver object alive. 499 | # This test also tests that functools.partial() is supported. 500 | import functools 501 | 502 | output = [1] 503 | sender = IntSender() 504 | receiver = IntReceiver(output) 505 | with using_asyncio_from_qt(): 506 | sender.signal.connect( 507 | asyncslot(functools.partial(receiver.original_slot))) 508 | sender.signal.emit(3) 509 | self.assertEqual(output[0], 4) 510 | receiver = None 511 | sender.signal.emit(5) 512 | # expecting change, because connection is still alive 513 | self.assertEqual(output[0], 9) 514 | 515 | def test_await(self): 516 | # asyncslot returns a Task object and so can be awaited. 517 | 518 | counter = 0 519 | 520 | @asyncslot 521 | async def work(): 522 | await asyncio.sleep(0.1) 523 | return 1 524 | 525 | async def entry(): 526 | nonlocal counter 527 | for _ in range(5): 528 | await work() 529 | counter += 1 530 | loop.quit() 531 | 532 | QtCore.QTimer.singleShot(0, asyncslot(entry)) 533 | 534 | with using_asyncio_from_qt(): 535 | loop = QtCore.QEventLoop() 536 | if hasattr(loop, 'exec'): 537 | loop.exec() 538 | else: 539 | loop.exec_() 540 | 541 | self.assertEqual(counter, 5) 542 | 543 | def test_strong_receiver(self): 544 | # Test connecting to a bounded method of an object that does not 545 | # support weak reference. 546 | output = [1] 547 | sender = IntSender() 548 | receiver = StrongReceiver(output) 549 | with using_asyncio_from_qt(): 550 | with self.assertRaises(SystemError if is_pyqt else TypeError): 551 | sender.signal.connect(receiver.method) 552 | with self.assertRaises(SystemError if is_pyqt else TypeError): 553 | sender.signal.connect(receiver.decorated_amethod) 554 | with self.assertRaises(TypeError): 555 | sender.signal.connect(asyncslot(receiver.amethod)) 556 | 557 | 558 | # ============================================================================= 559 | # Test signal override by parameter type 560 | # ============================================================================= 561 | 562 | class Control(QtCore.QObject): 563 | valueChanged = Signal((int,), (str,)) 564 | 565 | 566 | class Widget(QtCore.QObject): 567 | def __init__(self): 568 | super().__init__() 569 | self.control1 = Control(self) 570 | self.control1.setObjectName("control1") 571 | self.control2 = Control(self) 572 | self.control2.setObjectName("control2") 573 | self.control3 = Control(self) 574 | self.control3.setObjectName("control3") 575 | self.control4 = Control(self) 576 | self.control4.setObjectName("control4") 577 | self.metaObject().connectSlotsByName(self) 578 | self.values = [] 579 | 580 | @asyncslot 581 | @Slot(int) 582 | async def on_control1_valueChanged(self, newValue): 583 | self.values.append("control1") 584 | self.values.append(newValue) 585 | 586 | @Slot(str) 587 | @asyncslot 588 | async def on_control2_valueChanged(self, newValue): 589 | self.values.append("control2") 590 | self.values.append(newValue) 591 | 592 | @asyncslot 593 | async def on_control3_valueChanged(self, newValue): 594 | self.values.append("control3") 595 | self.values.append(newValue) 596 | 597 | @Slot(int) 598 | @asyncslot 599 | @Slot(str) 600 | async def on_control4_valueChanged(self, newValue): 601 | self.values.append("control4") 602 | self.values.append(newValue) 603 | 604 | 605 | class TestSlotSelection(unittest.TestCase): 606 | def setUp(self): 607 | if QtCore.QCoreApplication.instance() is not None: 608 | self.app = QtCore.QCoreApplication.instance() 609 | else: 610 | self.app = QtCore.QCoreApplication([]) 611 | 612 | def tearDown(self): 613 | self.app = None 614 | 615 | def test_decorated(self): 616 | values1 = [] 617 | values2 = [] 618 | values3 = [] 619 | values4 = [] 620 | 621 | def callback(): 622 | w = Widget() 623 | 624 | w.values.clear() 625 | w.control1.valueChanged[int].emit(12) 626 | w.control1.valueChanged[str].emit('ha') 627 | values1[:] = w.values 628 | 629 | w.values.clear() 630 | w.control2.valueChanged[int].emit(12) 631 | w.control2.valueChanged[str].emit('ha') 632 | values2[:] = w.values 633 | 634 | w.values.clear() 635 | w.control3.valueChanged[int].emit(12) 636 | w.control3.valueChanged[str].emit('ha') 637 | values3[:] = w.values 638 | 639 | w.values.clear() 640 | w.control4.valueChanged[int].emit(12) 641 | w.control4.valueChanged[str].emit('ha') 642 | values4[:] = w.values 643 | 644 | self.app.quit() 645 | 646 | with using_asyncio_from_qt(): 647 | QtCore.QTimer.singleShot(0, callback) 648 | if hasattr(self.app, "exec"): 649 | self.app.exec() 650 | else: 651 | self.app.exec_() 652 | 653 | self.assertEqual(values1, ["control1", 12]) 654 | self.assertEqual(values2, ["control2", "ha"]) 655 | if is_pyqt: 656 | self.assertEqual(values3, ["control3", 12, "control3", "ha"]) 657 | else: 658 | self.assertEqual(values3, []) 659 | self.assertEqual(values4, ["control4", 12, "control4", "ha"]) 660 | 661 | 662 | if __name__ == '__main__': 663 | # TODO: insert sync callback to check invocation order 664 | unittest.main() 665 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | from shim import QtCore 2 | import asyncio 3 | import inspect 4 | import qtinter 5 | import sys 6 | import unittest 7 | 8 | 9 | def print_stack(): 10 | for i, o in enumerate(inspect.stack(0)): 11 | if i > 0: 12 | print(o) 13 | 14 | 15 | def get_call_stack(): 16 | return [o.function for o in inspect.stack(0)[1:]] 17 | 18 | 19 | class TestRunTask(unittest.TestCase): 20 | # run_task should execute the task immediately until the first yield. 21 | 22 | def setUp(self) -> None: 23 | if QtCore.QCoreApplication.instance() is not None: 24 | self.app = QtCore.QCoreApplication.instance() 25 | else: 26 | self.app = QtCore.QCoreApplication([]) 27 | self.loop = qtinter.QiDefaultEventLoop() 28 | 29 | def tearDown(self) -> None: 30 | self.loop.close() 31 | self.app = None 32 | 33 | def test_no_yield_benchmark(self): 34 | # create_task with coroutine with no yield is not executed eagerly 35 | async def coro(output): 36 | output.append(1) 37 | return 'finished' 38 | 39 | async def qi_test_entry(): 40 | output = [] 41 | task = self.loop.create_task(coro(output)) 42 | self.assertFalse(task.done()) 43 | self.assertEqual(output, []) 44 | value = await task 45 | self.assertEqual(output, [1]) 46 | return value 47 | 48 | result = self.loop.run_until_complete(qi_test_entry()) 49 | self.assertEqual(result, 'finished') 50 | 51 | def test_no_yield(self): 52 | # coroutine with no yield should be eagerly executed to completion 53 | async def coro(): 54 | return get_call_stack() 55 | 56 | async def qi_test_entry(): 57 | task = qtinter.run_task(coro()) 58 | self.assertTrue(task.done()) 59 | return task.result() 60 | 61 | result = self.loop.run_until_complete(qi_test_entry()) 62 | self.assertIn('qi_test_entry', result) 63 | 64 | def test_one_yield(self): 65 | # coroutine with one yield should be eagerly executed 66 | async def coro(output): 67 | output.append(1) 68 | await asyncio.sleep(0) 69 | return get_call_stack() 70 | 71 | async def qi_test_entry(): 72 | output = [] 73 | task = qtinter.run_task(coro(output)) 74 | self.assertEqual(output, [1]) 75 | self.assertFalse(task.done()) 76 | return await task 77 | 78 | result = self.loop.run_until_complete(qi_test_entry()) 79 | self.assertNotIn('qi_test_entry', result) 80 | 81 | def test_interleaved(self): 82 | # run_task interleaved with create_task should work correctly 83 | var = 10 84 | 85 | async def coro1(): 86 | nonlocal var 87 | var += 6 88 | await asyncio.sleep(0) 89 | var += 7 90 | 91 | async def coro2(): 92 | nonlocal var 93 | var /= 8 94 | 95 | async def qi_test_entry(): 96 | task2 = asyncio.create_task(coro2()) 97 | task1 = qtinter.run_task(coro1()) 98 | await asyncio.gather(task1, task2) 99 | 100 | self.loop.run_until_complete(qi_test_entry()) 101 | self.assertEqual(var, 9) 102 | 103 | def test_current_task_before_yield(self): 104 | pass 105 | 106 | def test_current_task_after_yield(self): 107 | pass 108 | 109 | def test_current_task_before_yield_no_loop(self): 110 | pass 111 | 112 | def test_run_task_3(self): 113 | # running an async generator should raise an error (or not?) 114 | pass 115 | 116 | def test_raise_before_yield(self): 117 | # An exception raised before yield should be propagated to the caller 118 | pass 119 | 120 | def test_raise_after_yield(self): 121 | # Exceptions raised after yield should be treated normally 122 | pass 123 | 124 | def test_cancellation(self): 125 | # The returned task may be cancelled 126 | pass 127 | 128 | def test_nested(self): 129 | # run_task may run_task again, and still be immediate execution 130 | pass 131 | 132 | def test_recursive_ok(self): 133 | # run_task recursively 134 | pass 135 | 136 | def test_recursive_error(self): 137 | # run_task recursively too deeply should raise StackOverflowError 138 | pass 139 | 140 | @unittest.skipIf(sys.version_info < (3, 8), "requires Python >= 3.8") 141 | def test_task_name(self): 142 | # run_task should support task name. 143 | async def coro(output): 144 | output.append(1) 145 | await asyncio.sleep(0) 146 | 147 | async def entry(): 148 | output = [] 149 | task = qtinter.run_task(coro(output), name="MyTask!") 150 | self.assertEqual(task.get_name(), "MyTask!") 151 | self.assertEqual(output, [1]) 152 | self.assertFalse(task.done()) 153 | return await task 154 | 155 | self.loop.run_until_complete(entry()) 156 | 157 | def test_disallow_nesting(self): 158 | # If task nesting is disabled, raise RuntimeError 159 | # run_task should support task name. 160 | async def coro(): 161 | pass 162 | 163 | async def entry(): 164 | qtinter.run_task(coro(), allow_task_nesting=False) 165 | 166 | with self.assertRaisesRegex( 167 | RuntimeError, "cannot call run_task from a running task"): 168 | self.loop.run_until_complete(entry()) 169 | 170 | 171 | class TestRunTaskWithoutRunningLoop(unittest.TestCase): 172 | 173 | def setUp(self) -> None: 174 | if QtCore.QCoreApplication.instance() is None: 175 | self.app = QtCore.QCoreApplication([]) 176 | self.loop = qtinter.QiDefaultEventLoop() 177 | 178 | def tearDown(self) -> None: 179 | self.loop.close() 180 | 181 | # def test_no_yield_no_loop(self): 182 | # # run_task with no yield and no running loop should be ok 183 | # async def coro(): 184 | # return 'magic' 185 | # 186 | # task = self.loop.run_task(coro()) 187 | # self.assertTrue(task.done()) 188 | # self.assertEqual(task.result(), 'magic') 189 | 190 | # def test_one_yield_no_loop(self): 191 | # # run_task with no running loop should still execute the first step. 192 | # state = 'initial' 193 | # 194 | # async def coro(): 195 | # nonlocal state 196 | # state = 'executed' 197 | # await asyncio.sleep(0) 198 | # state = 'finished' 199 | # 200 | # task = self.loop.run_task(coro()) 201 | # self.assertEqual(state, 'executed') 202 | # self.assertFalse(task.done()) 203 | # # Finish the suspended task 204 | # self.loop.run_until_complete(task) 205 | # self.assertTrue(task.done()) 206 | # self.assertEqual(state, 'finished') 207 | 208 | 209 | if __name__ == '__main__': 210 | unittest.main() 211 | --------------------------------------------------------------------------------