Skip to content

Commit c0cd32f

Browse files
New asyncWrap helper to execute blocking calls on the native Qt event loop (#139)
Co-authored-by: Alex March <[email protected]>
1 parent e48ed41 commit c0cd32f

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-1
lines changed

examples/modal_example.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import asyncio
2+
import sys
3+
4+
# from PyQt6.QtWidgets import
5+
from PySide6.QtWidgets import QApplication, QMessageBox, QProgressBar
6+
7+
from qasync import QEventLoop, asyncWrap
8+
9+
10+
async def master():
11+
progress = QProgressBar()
12+
progress.setRange(0, 99)
13+
progress.show()
14+
await first_50(progress)
15+
16+
17+
async def first_50(progress):
18+
for i in range(50):
19+
progress.setValue(i)
20+
await asyncio.sleep(0.1)
21+
22+
# Schedule the last 50% to run asynchronously
23+
asyncio.create_task(last_50(progress))
24+
25+
# create a notification box, use helper to make entering event loop safe.
26+
result = await asyncWrap(
27+
lambda: QMessageBox.information(
28+
None, "Task Completed", "The first 50% of the task is completed."
29+
)
30+
)
31+
assert result == QMessageBox.StandardButton.Ok
32+
33+
34+
async def last_50(progress):
35+
for i in range(50, 100):
36+
progress.setValue(i)
37+
await asyncio.sleep(0.1)
38+
39+
40+
if __name__ == "__main__":
41+
app = QApplication(sys.argv)
42+
43+
event_loop = QEventLoop(app)
44+
asyncio.set_event_loop(event_loop)
45+
46+
event_loop.run_until_complete(master())
47+
event_loop.close()

qasync/__init__.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
BSD License
99
"""
1010

11-
__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose"]
11+
__all__ = ["QEventLoop", "QThreadExecutor", "asyncSlot", "asyncClose", "asyncWrap"]
1212

1313
import asyncio
1414
import contextlib
@@ -849,6 +849,44 @@ def wrapper(*args, **kwargs):
849849
return outer_decorator
850850

851851

852+
async def asyncWrap(fn, *args, **kwargs):
853+
"""
854+
Wrap a blocking function as an asynchronous and run it on the native Qt event loop.
855+
The function will be scheduled using a one shot QTimer which prevents blocking the
856+
QEventLoop. An example usage of this is raising a modal dialogue inside an asyncSlot.
857+
```python
858+
async def before_shutdown(self):
859+
await asyncio.sleep(2)
860+
861+
@asyncSlot()
862+
async def shutdown_clicked(self):
863+
# do some work async
864+
asyncio.create_task(self.before_shutdown())
865+
866+
# run on the native Qt loop, not blocking the QEventLoop
867+
result = await asyncWrap(
868+
lambda: QMessageBox.information(None, "Done", "It is now safe to shutdown.")
869+
)
870+
if result == QMessageBox.StandardButton.Ok:
871+
app.exit(0)
872+
```
873+
"""
874+
future = asyncio.Future()
875+
876+
@functools.wraps(fn)
877+
def helper():
878+
try:
879+
result = fn(*args, **kwargs)
880+
except Exception as e:
881+
future.set_exception(e)
882+
else:
883+
future.set_result(result)
884+
885+
# Schedule the helper to run in the next event loop iteration
886+
QtCore.QTimer.singleShot(0, helper)
887+
return await future
888+
889+
852890
def _get_qevent_loop():
853891
return QEventLoop(QApplication.instance() or QApplication(sys.argv))
854892

tests/test_qeventloop.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,59 @@ async def mycoro():
793793
assert not loop.is_running()
794794

795795

796+
@pytest.mark.parametrize(
797+
"async_wrap, expect_async_called, expect_exception",
798+
[(False, False, True), (True, True, False)],
799+
)
800+
def test_async_wrap(
801+
loop, application, async_wrap, expect_async_called, expect_exception
802+
):
803+
"""
804+
Re-entering the event loop from a Task will fail if there is another
805+
runnable task.
806+
"""
807+
async_called = False
808+
main_called = False
809+
810+
async def async_job():
811+
nonlocal async_called
812+
async_called = True
813+
814+
def sync_callback():
815+
coro = async_job()
816+
asyncio.create_task(coro)
817+
assert not async_called
818+
application.processEvents()
819+
assert async_called if expect_async_called else not async_called
820+
return 1, coro
821+
822+
async def main():
823+
nonlocal main_called
824+
if async_wrap:
825+
res, coro = await qasync.asyncWrap(sync_callback)
826+
else:
827+
res, coro = sync_callback()
828+
if expect_exception:
829+
await coro # avoid warnings about unawaited coroutines
830+
assert res == 1
831+
main_called = True
832+
833+
834+
exceptions = []
835+
loop.set_exception_handler(lambda loop, context: exceptions.append(context))
836+
837+
loop.run_until_complete(main())
838+
assert main_called, "The main function should have been called"
839+
840+
if expect_exception:
841+
# We will now have an error in there, because the task 'async_job' could not
842+
# be entered, because the task 'main' was still being executed by the event loop.
843+
assert len(exceptions) == 1
844+
assert isinstance(exceptions[0]["exception"], RuntimeError)
845+
else:
846+
assert len(exceptions) == 0
847+
848+
796849
def test_slow_callback_duration_logging(loop, caplog):
797850
async def mycoro():
798851
time.sleep(1)

0 commit comments

Comments
 (0)