Skip to content

Commit 95ee15c

Browse files
committed
Allow passing of custom qtparent to QEventLoop. Adjust tests.
1 parent f0a5ec9 commit 95ee15c

3 files changed

Lines changed: 82 additions & 18 deletions

File tree

src/qasync/__init__.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,9 @@ class _QEventLoop:
354354
The set_running_loop parameter is there for backwards compatibility and does nothing.
355355
"""
356356

357-
def __init__(self, app=None, set_running_loop=False, already_running=False):
357+
def __init__(
358+
self, app=None, set_running_loop=False, already_running=False, qtparent=None
359+
):
358360
self.__app = app or QApplication.instance()
359361
assert self.__app is not None, "No QApplication has been instantiated"
360362
self.__is_running = False
@@ -364,11 +366,9 @@ def __init__(self, app=None, set_running_loop=False, already_running=False):
364366
self._read_notifiers = {}
365367
self._write_notifiers = {}
366368
self._timer = _SimpleTimer()
369+
self.qtparent = qtparent or self.__app
367370

368371
self.__call_soon_signaller = signaller = _make_signaller(QtCore, object, tuple)
369-
# Parent helper QObjects to the application for safe lifetime management
370-
self._timer.setParent(self.__app)
371-
signaller.setParent(self.__app)
372372

373373
self.__call_soon_signal = signaller.signal
374374
self.__call_soon_signal.connect(
@@ -378,6 +378,16 @@ def __init__(self, app=None, set_running_loop=False, already_running=False):
378378
assert self.__app is not None
379379
super().__init__()
380380

381+
# Parent helper objects, such as timers, to this Qt parent for safe
382+
# lifetime management.
383+
if (
384+
self.qtparent is not None
385+
and self.qtparent.thread() is not QtCore.QThread.currentThread()
386+
):
387+
raise RuntimeError("qt_parent must belong to the same QThread as the event loop")
388+
self._timer.setParent(self.qtparent)
389+
signaller.setParent(self.qtparent)
390+
381391
# We have to set __is_running to True after calling
382392
# super().__init__() because of a bug in BaseEventLoop.
383393
if already_running:
@@ -391,8 +401,8 @@ def __init__(self, app=None, set_running_loop=False, already_running=False):
391401
# for asyncio to recognize the already running loop
392402
asyncio.events._set_running_loop(self)
393403

394-
def get_app(self):
395-
return self.__app
404+
def get_qtparent(self):
405+
return self.qtparent
396406

397407
def run_forever(self):
398408
"""Run eventloop forever."""
@@ -491,11 +501,18 @@ def close(self):
491501
self.__call_soon_signal.disconnect()
492502
except Exception:
493503
pass # pragma: no cover
494-
self.__call_soon_signaller.deleteLater()
504+
try:
505+
# may raise if already deleted
506+
self.__call_soon_signaller.deleteLater()
507+
except Exception:
508+
pass # pragma: no cover
495509

496510
# Stop timers first to avoid late invocations during teardown
497511
self._timer.stop()
498-
self._timer.deleteLater()
512+
try:
513+
self._timer.deleteLater()
514+
except Exception:
515+
pass # pragma: no cover
499516

500517
# Disable and disconnect any remaining notifiers before closing
501518
for notifier in itertools.chain(
@@ -668,7 +685,10 @@ def _delete_notifier(notifier):
668685
notifier.activated["int"].disconnect()
669686
except Exception:
670687
pass # pragma: no cover
671-
notifier.deleteLater()
688+
try:
689+
notifier.deleteLater()
690+
except Exception:
691+
pass # pragma: no cover
672692

673693
# Methods for interacting with threads.
674694

src/qasync/_unix.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,18 +188,21 @@ def _delete_notifier(notifier):
188188
notifier.activated["int"].disconnect()
189189
except Exception:
190190
pass # pragma: no cover
191-
notifier.deleteLater()
191+
try:
192+
notifier.deleteLater()
193+
except Exception:
194+
pass # pragma: no cover
192195

193196

194197
class _SelectorEventLoop(asyncio.SelectorEventLoop):
195198
def __init__(self):
196199
self._signal_safe_callbacks = []
197200

198201
try:
199-
app = self.get_app()
202+
qtparent = self.get_qtparent()
200203
except AttributeError:
201-
app = None # pragma: no cover
202-
self._qtselector = _Selector(self, qtparent=app)
204+
qtparent = None # pragma: no cover
205+
self._qtselector = _Selector(self, qtparent=qtparent)
203206
asyncio.SelectorEventLoop.__init__(self, self._qtselector)
204207

205208
def close(self):

tests/test_qeventloop.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@
1919

2020
import qasync
2121

22+
from qasync import QtCore
23+
import traceback, logging
24+
25+
_orig_setParent = QtCore.QObject.setParent
26+
27+
28+
def _dbg_setParent(self, parent):
29+
if parent is not None:
30+
try:
31+
same = parent.thread() is self.thread()
32+
except Exception:
33+
same = False
34+
if not same:
35+
logging.error(
36+
"QObject.setParent across threads: obj=%r obj.thread=%r parent=%r parent.thread=%r",
37+
self,
38+
self.thread(),
39+
parent,
40+
getattr(parent, "thread", lambda: None)(),
41+
)
42+
traceback.print_stack() # prints Python stack where setParent was called
43+
# Optionally raise so test fails and you can inspect the stack in the debugger:
44+
# raise RuntimeError("setParent called from wrong thread")
45+
return _orig_setParent(self, parent)
46+
47+
48+
QtCore.QObject.setParent = _dbg_setParent
49+
2250

2351
@pytest.fixture
2452
def loop(request, application):
@@ -931,21 +959,33 @@ def test_run_forever_custom_exit_code(loop, application):
931959
application.exec_ = orig_exec
932960

933961

934-
def test_qeventloop_in_qthread():
962+
@pytest.mark.parametrize("qtparent", [False, True])
963+
def test_qeventloop_in_qthread(qtparent):
935964
class CoroutineExecutorThread(qasync.QtCore.QThread):
936965
def __init__(self, coro):
937966
super().__init__()
938967
self.coro = coro
939968
self.loop = None
969+
self.owner = None
940970

941971
def run(self):
942-
self.loop = qasync.QEventLoop(self)
972+
# provide a parent object for temporary objects that belongs
973+
# to the thread
974+
self.owner = QtCore.QObject()
975+
parent = self.owner if qtparent else None
976+
if not qtparent:
977+
with pytest.raises(RuntimeError):
978+
self.loop = qasync.QEventLoop(self, qtparent=parent)
979+
return
980+
else:
981+
self.loop = qasync.QEventLoop(self, qtparent=parent)
943982
asyncio.set_event_loop(self.loop)
944983
asyncio.run(self.coro)
945984

946985
def join(self):
947-
self.loop.stop()
948-
self.loop.close()
986+
if self.loop:
987+
self.loop.stop()
988+
self.loop.close()
949989
self.wait()
950990

951991
event = threading.Event()
@@ -957,7 +997,8 @@ async def coro():
957997
thread = CoroutineExecutorThread(coro())
958998
thread.start()
959999

960-
assert event.wait(timeout=1), "Coroutine did not execute successfully"
1000+
if qtparent:
1001+
assert event.wait(timeout=1), "Coroutine did not execute successfully"
9611002

9621003
thread.join() # Ensure thread cleanup
9631004

0 commit comments

Comments
 (0)