From a8d0f7fcaf4f1626fad6185f8c4581de3bd5de40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Valur=20J=C3=B3nsson?= Date: Wed, 23 Jul 2025 15:46:15 +0000 Subject: [PATCH 1/5] add call_sync helper --- qasync/__init__.py | 18 +++++++++++++++++- tests/test_qeventloop.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/qasync/__init__.py b/qasync/__init__.py index 732f7a2..fdd1503 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -8,7 +8,7 @@ BSD License """ -__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose"] +__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose", "call_sync"] import asyncio import contextlib @@ -833,6 +833,22 @@ def wrapper(*args, **kwargs): return outer_decorator +async def call_sync(fn, *args, **kwargs): + """run a blocking call from the Qt event loop.""" + future = asyncio.Future() + + @functools.wraps(fn) + def helper(): + try: + result = fn(*args, **kwargs) + except Exception as e: + future.set_exception(e) + else: + future.set_result(result) + + # Schedule the helper to run in the next event loop iteration + QtCore.QTimer.singleShot(0, helper) + return await future class QEventLoopPolicyMixin: def new_event_loop(self): diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index 9eefc72..466c858 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -792,6 +792,23 @@ async def mycoro(): assert not loop.is_running() +def test_call_sync(loop): + """Verify that call_sync works as expected.""" + called = False + + def sync_callback(): + nonlocal called + called = True + return 1 + + async def main(): + res = await qasync.call_sync(sync_callback) + assert res == 1 + + loop.run_until_complete(main()) + assert called, "The sync callback should have been called" + + def test_slow_callback_duration_logging(loop, caplog): async def mycoro(): time.sleep(1) From c64522d72c6c2f2d1412d0a74b66043bb32d479d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Valur=20J=C3=B3nsson?= Date: Wed, 23 Jul 2025 16:26:39 +0000 Subject: [PATCH 2/5] add tests for Task reentrancy and call_sync --- tests/test_qeventloop.py | 65 +++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index 466c858..ccb6323 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -791,22 +791,73 @@ async def mycoro(): loop.run_until_complete(mycoro()) assert not loop.is_running() +def test_task_recursion_fails(loop, application): + """Re-entering the event loop from a Task will fail if there is another + runnable task.""" + async_called = False + main_called = False + + async def async_job(): + nonlocal async_called + async_called = True -def test_call_sync(loop): - """Verify that call_sync works as expected.""" - called = False + def sync_callback(): + asyncio.create_task(async_job()) + assert not async_called + application.processEvents() + assert not async_called + return 1 + + async def main(): + nonlocal main_called + res = sync_callback() + assert res == 1 + main_called = True + + exceptions= [] + loop.set_exception_handler(lambda loop, context: exceptions.append(context)) + + loop.run_until_complete(main()) + assert main_called, "The main function should have been called" + + # We will now have an error in there, because the task 'async_job' could not + # be entered, because the task 'main' was still being executed by the event loop. + assert len(exceptions) == 1 + assert isinstance(exceptions[0]["exception"], RuntimeError) + + +def test_call_sync(loop, application): + """Re-entering the event loop from a Task will fail if there is another + runnable task.""" + async_called = False + main_called = False + + async def async_job(): + nonlocal async_called + async_called = True def sync_callback(): - nonlocal called - called = True + asyncio.create_task(async_job()) + assert not async_called + application.processEvents() + assert async_called return 1 async def main(): + nonlocal main_called res = await qasync.call_sync(sync_callback) assert res == 1 - + main_called = True + + exceptions= [] + loop.set_exception_handler(lambda loop, context: exceptions.append(context)) + loop.run_until_complete(main()) - assert called, "The sync callback should have been called" + assert main_called, "The main function should have been called" + + # We will now have an error in there, because the task 'async_job' could not + # be entered, because the task 'main' was still being executed by the event loop. + assert len(exceptions) == 0 def test_slow_callback_duration_logging(loop, caplog): From f8bdd5882497ebaf5fc353e1370d30500dd51e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Valur=20J=C3=B3nsson?= Date: Wed, 23 Jul 2025 16:44:44 +0000 Subject: [PATCH 3/5] add example for call_sync --- examples/modal_example.py | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 examples/modal_example.py diff --git a/examples/modal_example.py b/examples/modal_example.py new file mode 100644 index 0000000..59fe028 --- /dev/null +++ b/examples/modal_example.py @@ -0,0 +1,48 @@ +import asyncio +import sys + +# from PyQt6.QtWidgets import +from PySide6.QtWidgets import QApplication, QProgressBar, QMessageBox +from qasync import QEventLoop, call_sync + + +async def master(): + progress = QProgressBar() + progress.setRange(0, 99) + progress.show() + await first_50(progress) + + +async def first_50(progress): + for i in range(50): + progress.setValue(i) + await asyncio.sleep(0.1) + + # Schedule the last 50% to run asynchronously + asyncio.create_task( + last_50(progress) + ) + + # create a notification box, use helper to make entering event loop safe. + result = await call_sync( + lambda: QMessageBox.information( + None, "Task Completed", "The first 50% of the task is completed." + ) + ) + assert result == QMessageBox.StandardButton.Ok + + +async def last_50(progress): + for i in range(50, 100): + progress.setValue(i) + await asyncio.sleep(0.1) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + + event_loop = QEventLoop(app) + asyncio.set_event_loop(event_loop) + + event_loop.run_until_complete(master()) + event_loop.close() From b2632185bc6fdba38aee60380f49ff0335cb3f7b Mon Sep 17 00:00:00 2001 From: Alex March Date: Mon, 28 Jul 2025 17:35:13 +0900 Subject: [PATCH 4/5] rename to asyncWrap, parametrize the test and format the code --- examples/modal_example.py | 17 +++++----- qasync/__init__.py | 27 +++++++++++++-- tests/test_qeventloop.py | 71 ++++++++++++++------------------------- 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/examples/modal_example.py b/examples/modal_example.py index 59fe028..f30e20e 100644 --- a/examples/modal_example.py +++ b/examples/modal_example.py @@ -2,8 +2,9 @@ import sys # from PyQt6.QtWidgets import -from PySide6.QtWidgets import QApplication, QProgressBar, QMessageBox -from qasync import QEventLoop, call_sync +from PySide6.QtWidgets import QApplication, QMessageBox, QProgressBar + +from qasync import QEventLoop, asyncWrap async def master(): @@ -11,20 +12,18 @@ async def master(): progress.setRange(0, 99) progress.show() await first_50(progress) - + async def first_50(progress): for i in range(50): progress.setValue(i) await asyncio.sleep(0.1) - + # Schedule the last 50% to run asynchronously - asyncio.create_task( - last_50(progress) - ) - + asyncio.create_task(last_50(progress)) + # create a notification box, use helper to make entering event loop safe. - result = await call_sync( + result = await asyncWrap( lambda: QMessageBox.information( None, "Task Completed", "The first 50% of the task is completed." ) diff --git a/qasync/__init__.py b/qasync/__init__.py index fdd1503..64fcac8 100644 --- a/qasync/__init__.py +++ b/qasync/__init__.py @@ -8,7 +8,7 @@ BSD License """ -__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose", "call_sync"] +__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose", "asyncWrap"] import asyncio import contextlib @@ -833,8 +833,29 @@ def wrapper(*args, **kwargs): return outer_decorator -async def call_sync(fn, *args, **kwargs): - """run a blocking call from the Qt event loop.""" + +async def asyncWrap(fn, *args, **kwargs): + """ + Wrap a blocking function as an asynchronous and run it on the native Qt event loop. + The function will be scheduled using a one shot QTimer which prevents blocking the + QEventLoop. An example usage of this is raising a modal dialogue inside an asyncSlot. + ```python + async def before_shutdown(self): + await asyncio.sleep(2) + + @asyncSlot() + async def shutdown_clicked(self): + # do some work async + asyncio.create_task(self.before_shutdown()) + + # run on the native Qt loop, not blocking the QEventLoop + result = await asyncWrap( + lambda: QMessageBox.information(None, "Done", "It is now safe to shutdown.") + ) + if result == QMessageBox.StandardButton.Ok: + app.exit(0) + ``` + """ future = asyncio.Future() @functools.wraps(fn) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index ccb6323..55cef56 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -791,47 +791,21 @@ async def mycoro(): loop.run_until_complete(mycoro()) assert not loop.is_running() -def test_task_recursion_fails(loop, application): - """Re-entering the event loop from a Task will fail if there is another - runnable task.""" - async_called = False - main_called = False - - async def async_job(): - nonlocal async_called - async_called = True - - def sync_callback(): - asyncio.create_task(async_job()) - assert not async_called - application.processEvents() - assert not async_called - return 1 - - async def main(): - nonlocal main_called - res = sync_callback() - assert res == 1 - main_called = True - - exceptions= [] - loop.set_exception_handler(lambda loop, context: exceptions.append(context)) - - loop.run_until_complete(main()) - assert main_called, "The main function should have been called" - - # We will now have an error in there, because the task 'async_job' could not - # be entered, because the task 'main' was still being executed by the event loop. - assert len(exceptions) == 1 - assert isinstance(exceptions[0]["exception"], RuntimeError) - -def test_call_sync(loop, application): - """Re-entering the event loop from a Task will fail if there is another - runnable task.""" +@pytest.mark.parametrize( + "async_wrap, expect_async_called, expect_exception", + [(False, False, True), (True, True, False)], +) +def test_async_wrap( + loop, application, async_wrap, expect_async_called, expect_exception +): + """ + Re-entering the event loop from a Task will fail if there is another + runnable task. + """ async_called = False main_called = False - + async def async_job(): nonlocal async_called async_called = True @@ -840,24 +814,31 @@ def sync_callback(): asyncio.create_task(async_job()) assert not async_called application.processEvents() - assert async_called + assert async_called if expect_async_called else not async_called return 1 async def main(): nonlocal main_called - res = await qasync.call_sync(sync_callback) + if async_wrap: + res = await qasync.asyncWrap(sync_callback) + else: + res = sync_callback() assert res == 1 main_called = True - exceptions= [] + exceptions = [] loop.set_exception_handler(lambda loop, context: exceptions.append(context)) loop.run_until_complete(main()) assert main_called, "The main function should have been called" - - # We will now have an error in there, because the task 'async_job' could not - # be entered, because the task 'main' was still being executed by the event loop. - assert len(exceptions) == 0 + + if expect_exception: + # We will now have an error in there, because the task 'async_job' could not + # be entered, because the task 'main' was still being executed by the event loop. + assert len(exceptions) == 1 + assert isinstance(exceptions[0]["exception"], RuntimeError) + else: + assert len(exceptions) == 0 def test_slow_callback_duration_logging(loop, caplog): From 0b87daf2e14c03bdf0cb52e06a74bc325a7f0d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Valur=20J=C3=B3nsson?= Date: Wed, 30 Jul 2025 07:42:34 +0000 Subject: [PATCH 5/5] await the coroutine if it isn't successfully run as a task, to avoid warnings. --- tests/test_qeventloop.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_qeventloop.py b/tests/test_qeventloop.py index bc27609..c4365ec 100644 --- a/tests/test_qeventloop.py +++ b/tests/test_qeventloop.py @@ -812,20 +812,24 @@ async def async_job(): async_called = True def sync_callback(): - asyncio.create_task(async_job()) + coro = async_job() + asyncio.create_task(coro) assert not async_called application.processEvents() assert async_called if expect_async_called else not async_called - return 1 + return 1, coro async def main(): nonlocal main_called if async_wrap: - res = await qasync.asyncWrap(sync_callback) + res, coro = await qasync.asyncWrap(sync_callback) else: - res = sync_callback() + res, coro = sync_callback() + if expect_exception: + await coro # avoid warnings about unawaited coroutines assert res == 1 main_called = True + exceptions = [] loop.set_exception_handler(lambda loop, context: exceptions.append(context))