From 9ee34a41d5278f4dbec494d619087924319333a4 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Wed, 23 Jul 2025 13:52:53 -0500 Subject: [PATCH 01/15] Support Python 3.14. --- .github/workflows/main.yml | 4 +++- qasync/__init__.py | 45 +++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f4c524e..68e0bf8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: os: [ubuntu, windows, macos-x86_64, macos-arm64] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14-dev"] qt-version: ["pyside2", "pyside6", "pyqt5", "pyqt6"] include: - os: ubuntu @@ -42,6 +42,8 @@ jobs: qt-version: pyside2 - python-version: "3.13" qt-version: pyside2 + - python-version: "3.14-dev" + qt-version: pyside2 # pyside6 and pyqt6 require python >=3.9 - python-version: "3.8" qt-version: pyside6 diff --git a/qasync/__init__.py b/qasync/__init__.py index f6bbfcb..c22bfd5 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -830,29 +830,30 @@ def wrapper(*args, **kwargs): return outer_decorator - -class QEventLoopPolicyMixin: - def new_event_loop(self): - return QEventLoop(QApplication.instance() or QApplication(sys.argv)) - - -class DefaultQEventLoopPolicy( - QEventLoopPolicyMixin, - asyncio.DefaultEventLoopPolicy, -): - pass - - @contextlib.contextmanager -def _set_event_loop_policy(policy): - old_policy = asyncio.get_event_loop_policy() - asyncio.set_event_loop_policy(policy) +def _use_qeventloop(loop_factory): + app = QApplication.instance() or QApplication([sys.argv]) + if loop_factory is None: + loop = QEventLoop(app) + else: + loop = loop_factory(app) + try: + old_loop = asyncio.get_event_loop() + except RuntimeError: # No current event loop + old_loop = None + asyncio.set_event_loop(loop) try: - yield + yield loop finally: - asyncio.set_event_loop_policy(old_policy) - + loop.close() + asyncio.set_event_loop(None) -def run(*args, **kwargs): - with _set_event_loop_policy(DefaultQEventLoopPolicy()): - return asyncio.run(*args, **kwargs) +# A run function matching the signature of asyncio.run +def run(main_coro, *, debug=None, loop_factory=None): + """ + Run the given coroutine using a QEventLoop. + """ + with _use_qeventloop(loop_factory) as loop: + if debug is not None: + loop.set_debug(debug) + return loop.run_until_complete(main_coro) From d764bbed8ca8ce1829d7e15df1f69b8d1b0b64fa Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 22:45:32 -0500 Subject: [PATCH 02/15] Update main.yml --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 68e0bf8..51e9293 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,6 +44,9 @@ jobs: qt-version: pyside2 - python-version: "3.14-dev" qt-version: pyside2 + # To be removed when pyqt6 supports this + - python-version: "3.14-dev" + qt-version: pyqt6 # pyside6 and pyqt6 require python >=3.9 - python-version: "3.8" qt-version: pyside6 From 68fb689b57231a9b99c887600445984025afb43a Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 22:49:41 -0500 Subject: [PATCH 03/15] Update main.yml --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 51e9293..d1d940d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,9 +44,6 @@ jobs: qt-version: pyside2 - python-version: "3.14-dev" qt-version: pyside2 - # To be removed when pyqt6 supports this - - python-version: "3.14-dev" - qt-version: pyqt6 # pyside6 and pyqt6 require python >=3.9 - python-version: "3.8" qt-version: pyside6 @@ -114,6 +111,9 @@ jobs: # - name: Run mypy # run: poetry run mypy + - name: Delete any stray venvs + run: poetry env info -p >/dev/null 2>&1 && poetry env remove python + - name: Install Qt run: poetry run pip install --ignore-installed ${{ matrix.qt-version }} From ac88e0d088cfc5a538cc3cd7ff90b8bf77beb845 Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 22:53:10 -0500 Subject: [PATCH 04/15] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1d940d..0b5956e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -112,7 +112,7 @@ jobs: # run: poetry run mypy - name: Delete any stray venvs - run: poetry env info -p >/dev/null 2>&1 && poetry env remove python + run: poetry env remove --all - name: Install Qt run: poetry run pip install --ignore-installed ${{ matrix.qt-version }} From 0d5b534da3a6be04231c937a0164d8935b2d3ee3 Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 22:57:48 -0500 Subject: [PATCH 05/15] Update main.yml --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0b5956e..a553b5c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -105,15 +105,15 @@ jobs: - name: Check lock file run: poetry check --lock + - name: Delete any stray venvs + run: poetry env remove --all + - name: Install dependencies run: poetry install --with github-actions # - name: Run mypy # run: poetry run mypy - - name: Delete any stray venvs - run: poetry env remove --all - - name: Install Qt run: poetry run pip install --ignore-installed ${{ matrix.qt-version }} From 3cb059d3ba400c5efa30b0631f7216c34efe5d9d Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 23:22:26 -0500 Subject: [PATCH 06/15] Update __init__.py --- qasync/__init__.py | 57 +++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/qasync/__init__.py b/qasync/__init__.py index c22bfd5..ad32b1c 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -830,30 +830,35 @@ def wrapper(*args, **kwargs): return outer_decorator -@contextlib.contextmanager -def _use_qeventloop(loop_factory): - app = QApplication.instance() or QApplication([sys.argv]) - if loop_factory is None: - loop = QEventLoop(app) - else: - loop = loop_factory(app) - try: - old_loop = asyncio.get_event_loop() - except RuntimeError: # No current event loop - old_loop = None - asyncio.set_event_loop(loop) - try: - yield loop - finally: - loop.close() - asyncio.set_event_loop(None) -# A run function matching the signature of asyncio.run -def run(main_coro, *, debug=None, loop_factory=None): - """ - Run the given coroutine using a QEventLoop. - """ - with _use_qeventloop(loop_factory) as loop: - if debug is not None: - loop.set_debug(debug) - return loop.run_until_complete(main_coro) +if sys.version_info < (3, 14): # Backwards compatibility with the policy, since there are classes without _ to begin with. + class QEventLoopPolicyMixin: + def new_event_loop(self): + return QEventLoop(QApplication.instance() or QApplication(sys.argv)) + + + class DefaultQEventLoopPolicy( + QEventLoopPolicyMixin, + asyncio.DefaultEventLoopPolicy, + ): + pass + + + @contextlib.contextmanager + def _set_event_loop_policy(policy): + old_policy = asyncio.get_event_loop_policy() + asyncio.set_event_loop_policy(policy) + try: + yield + finally: + asyncio.set_event_loop_policy(old_policy) + + + def run(*args, **kwargs): + with _set_event_loop_policy(DefaultQEventLoopPolicy()): + return asyncio.run(*args, **kwargs)) + +else: + + def run(*args, **kwargs): + return asyncio.run(*args, **kwargs, loop_factory=QEventLoop(QApplication.instance() or QApplication(sys.argv)) From 9f7900d13fa87aaa7aa7bca3ccd500e26fd90a6a Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 23:22:52 -0500 Subject: [PATCH 07/15] Update main.yml --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a553b5c..5c01d34 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,6 +44,8 @@ jobs: qt-version: pyside2 - python-version: "3.14-dev" qt-version: pyside2 + - python-version: "3.14-dev" + os: windows # pyside6 and pyqt6 require python >=3.9 - python-version: "3.8" qt-version: pyside6 From dc96ec52748ff7bb5426cdd349d827ad015b32bc Mon Sep 17 00:00:00 2001 From: John Date: Wed, 23 Jul 2025 23:24:27 -0500 Subject: [PATCH 08/15] Update __init__.py --- qasync/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qasync/__init__.py b/qasync/__init__.py index ad32b1c..77d919f 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -856,7 +856,7 @@ def _set_event_loop_policy(policy): def run(*args, **kwargs): with _set_event_loop_policy(DefaultQEventLoopPolicy()): - return asyncio.run(*args, **kwargs)) + return asyncio.run(*args, **kwargs) else: From 712c55071490efa469ba84129486c36d38850433 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 24 Jul 2025 00:12:42 -0500 Subject: [PATCH 09/15] Update __init__.py --- qasync/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qasync/__init__.py b/qasync/__init__.py index 77d919f..b6ac398 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -861,4 +861,4 @@ def run(*args, **kwargs): else: def run(*args, **kwargs): - return asyncio.run(*args, **kwargs, loop_factory=QEventLoop(QApplication.instance() or QApplication(sys.argv)) + return asyncio.run(*args, **kwargs, loop_factory=QEventLoop(QApplication.instance() or QApplication(sys.argv))) From 87dc33533d4238932c9adde2fc561608b3ac3896 Mon Sep 17 00:00:00 2001 From: Alex March Date: Fri, 25 Jul 2025 12:59:42 +0900 Subject: [PATCH 10/15] Tidy up init.py --- qasync/__init__.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/qasync/__init__.py b/qasync/__init__.py index b6ac398..cf8a6fe 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -831,19 +831,24 @@ def wrapper(*args, **kwargs): return outer_decorator -if sys.version_info < (3, 14): # Backwards compatibility with the policy, since there are classes without _ to begin with. - class QEventLoopPolicyMixin: +def _get_qevent_loop(): + return QEventLoop(QApplication.instance() or QApplication(sys.argv)) + + +if sys.version_info >= (3, 12): + + def run(*args, **kwargs): + return asyncio.run( + *args, + **kwargs, + loop_factory=_get_qevent_loop, + ) +else: + # backwards compatibility with event loop policies + class DefaultQEventLoopPolicy(asyncio.DefaultEventLoopPolicy): def new_event_loop(self): - return QEventLoop(QApplication.instance() or QApplication(sys.argv)) - - - class DefaultQEventLoopPolicy( - QEventLoopPolicyMixin, - asyncio.DefaultEventLoopPolicy, - ): - pass - - + return _get_qevent_loop() + @contextlib.contextmanager def _set_event_loop_policy(policy): old_policy = asyncio.get_event_loop_policy() @@ -852,13 +857,7 @@ def _set_event_loop_policy(policy): yield finally: asyncio.set_event_loop_policy(old_policy) - - + def run(*args, **kwargs): with _set_event_loop_policy(DefaultQEventLoopPolicy()): return asyncio.run(*args, **kwargs) - -else: - - def run(*args, **kwargs): - return asyncio.run(*args, **kwargs, loop_factory=QEventLoop(QApplication.instance() or QApplication(sys.argv))) From 8a668f884e095dd3d22d62da77a93a1bb1780d6e Mon Sep 17 00:00:00 2001 From: Alex March Date: Fri, 25 Jul 2025 13:01:07 +0900 Subject: [PATCH 11/15] Move run related tests to test_run.py --- tests/test_qeventloop.py | 66 --------------------------------------- tests/test_run.py | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 66 deletions(-) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index d2feb96..9eefc72 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -808,72 +808,6 @@ async def mycoro(): assert "seconds" in msg -@pytest.mark.skipif(sys.version_info < (3, 12), reason="Requires Python 3.12+") -def test_asyncio_run(application): - """Test that QEventLoop is compatible with asyncio.run()""" - done = False - loop = None - - async def main(): - nonlocal done, loop - assert loop.is_running() - assert asyncio.get_running_loop() is loop - await asyncio.sleep(0.01) - done = True - - def factory(): - nonlocal loop - loop = qasync.QEventLoop(application) - return loop - - asyncio.run(main(), loop_factory=factory) - assert done - assert loop.is_closed() - assert not loop.is_running() - - -@pytest.mark.skipif(sys.version_info < (3, 12), reason="Requires Python 3.12+") -def test_asyncio_run_cleanup(application): - """Test that running tasks are cleaned up""" - task = None - cancelled = False - - async def main(): - nonlocal task, cancelled - - async def long_task(): - nonlocal cancelled - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - cancelled = True - - task = asyncio.create_task(long_task()) - await asyncio.sleep(0.01) - - asyncio.run(main(), loop_factory=lambda: qasync.QEventLoop(application)) - assert cancelled - - -def test_qasync_run(application): - """Test running with qasync.run()""" - done = False - loop = None - - async def main(): - nonlocal done, loop - loop = asyncio.get_running_loop() - assert loop.is_running() - await asyncio.sleep(0.01) - done = True - - # qasync.run uses an EventLoopPolicy to create the loop - qasync.run(main()) - assert done - assert loop.is_closed() - assert not loop.is_running() - - def teardown_module(module): """ Remove handlers from all loggers diff --git a/tests/test_run.py b/tests/test_run.py index 9c4a22a..564f685 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,4 +1,5 @@ import asyncio +import sys from unittest.mock import ANY import pytest @@ -35,3 +36,69 @@ def test_qasync_run_restores_policy(get_event_loop_coro): def test_qasync_run_with_debug_args(get_event_loop_coro): qasync.run(get_event_loop_coro(True), debug=True) qasync.run(get_event_loop_coro(False), debug=False) + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Requires Python 3.12+") +def test_asyncio_run(application): + """Test that QEventLoop is compatible with asyncio.run()""" + done = False + loop = None + + async def main(): + nonlocal done, loop + assert loop.is_running() + assert asyncio.get_running_loop() is loop + await asyncio.sleep(0.01) + done = True + + def factory(): + nonlocal loop + loop = qasync.QEventLoop(application) + return loop + + asyncio.run(main(), loop_factory=factory) + assert done + assert loop.is_closed() + assert not loop.is_running() + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="Requires Python 3.12+") +def test_asyncio_run_cleanup(application): + """Test that running tasks are cleaned up""" + task = None + cancelled = False + + async def main(): + nonlocal task, cancelled + + async def long_task(): + nonlocal cancelled + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled = True + + task = asyncio.create_task(long_task()) + await asyncio.sleep(0.01) + + asyncio.run(main(), loop_factory=lambda: qasync.QEventLoop(application)) + assert cancelled + + +def test_qasync_run(application): + """Test running with qasync.run()""" + done = False + loop = None + + async def main(): + nonlocal done, loop + loop = asyncio.get_running_loop() + assert loop.is_running() + await asyncio.sleep(0.01) + done = True + + # qasync.run uses an EventLoopPolicy to create the loop + qasync.run(main()) + assert done + assert loop.is_closed() + assert not loop.is_running() From 19f2001c2aa1722d99d921ecaa1a80adb0c78539 Mon Sep 17 00:00:00 2001 From: Alex March Date: Fri, 25 Jul 2025 13:01:43 +0900 Subject: [PATCH 12/15] Skip policy related test --- tests/test_run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_run.py b/tests/test_run.py index 564f685..47e709f 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -26,6 +26,7 @@ def test_qasync_run_restores_loop(get_event_loop_coro): _ = asyncio.get_event_loop() +@pytest.mark.skipif(sys.version_info >= (3, 14), reason="Deprecated since Python 3.14") def test_qasync_run_restores_policy(get_event_loop_coro): old_policy = asyncio.get_event_loop_policy() qasync.run(get_event_loop_coro(ANY)) From a6d808dd05c57a310f77d49b8f22663297977052 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 25 Jul 2025 09:49:44 -0500 Subject: [PATCH 13/15] Update main.yml --- .github/workflows/main.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5c01d34..384b1a9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: os: [ubuntu, windows, macos-x86_64, macos-arm64] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14-dev"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] qt-version: ["pyside2", "pyside6", "pyqt5", "pyqt6"] include: - os: ubuntu @@ -42,10 +42,6 @@ jobs: qt-version: pyside2 - python-version: "3.13" qt-version: pyside2 - - python-version: "3.14-dev" - qt-version: pyside2 - - python-version: "3.14-dev" - os: windows # pyside6 and pyqt6 require python >=3.9 - python-version: "3.8" qt-version: pyside6 From cec0e559d20ef93e343355bf88b35afff953c2e5 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 25 Jul 2025 09:52:03 -0500 Subject: [PATCH 14/15] Increase some timeouts --- tests/test_qeventloop.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index e5cee7d..be4d216 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -105,7 +105,7 @@ def test_can_handle_exception_in_executor(self, loop, executor): loop.run_until_complete( asyncio.wait_for( loop.run_in_executor(executor, self.blocking_failure), - timeout=3.0, + timeout=10.0, ) ) @@ -126,7 +126,7 @@ def blocking_func(self, was_invoked): async def blocking_task(self, loop, executor, was_invoked): logging.debug("start blocking task()") fut = loop.run_in_executor(executor, self.blocking_func, was_invoked) - await asyncio.wait_for(fut, timeout=5.0) + await asyncio.wait_for(fut, timeout=10.0) logging.debug("start blocking task()") @@ -140,7 +140,7 @@ async def mycoro(): await process.wait() assert process.returncode == 5 - loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=3)) + loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=10.0)) def test_can_read_subprocess(loop): @@ -160,7 +160,7 @@ async def mycoro(): assert process.returncode == 0 assert received_stdout.strip() == b"Hello async world!" - loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=3)) + loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=10.0)) def test_can_communicate_subprocess(loop): @@ -181,7 +181,7 @@ async def mycoro(): assert process.returncode == 0 assert received_stdout.strip() == b"Hello async world!" - loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=3)) + loop.run_until_complete(asyncio.wait_for(mycoro(), timeout=10.0)) def test_can_terminate_subprocess(loop): From 18ea02692a06ed534fe4da41e22da9d1d43fd91c Mon Sep 17 00:00:00 2001 From: John Date: Fri, 25 Jul 2025 09:53:23 -0500 Subject: [PATCH 15/15] Update main.yml --- .github/workflows/main.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 384b1a9..f4c524e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -103,9 +103,6 @@ jobs: - name: Check lock file run: poetry check --lock - - name: Delete any stray venvs - run: poetry env remove --all - - name: Install dependencies run: poetry install --with github-actions