Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AttributeError: 'NoneType' object has no attribute 'start' #586

Open
mmerlo opened this issue Dec 24, 2024 · 3 comments
Open

AttributeError: 'NoneType' object has no attribute 'start' #586

mmerlo opened this issue Dec 24, 2024 · 3 comments

Comments

@mmerlo
Copy link

mmerlo commented Dec 24, 2024

I'm seeing an issue with pytest-qt (4.4.0) and I am not sure if it is a thread issue on my end or an issue with pytest-qt. Most of the time, the test will pass, but this issue will cause the test to fail about 10% to 20% of the time. Below is code that reproduces the issue:

from PySide6.QtCore import QThread, QObject, Signal
import traceback

class MyRelay(QObject):
    sig_to_relay = Signal()

class MyWorker(QObject):
    my_signal = Signal()

    def __init__(self, relay):
        super().__init__()
        self._relay = relay
        self._relay.setParent(self)
        self._relay.sig_to_relay.connect(self.my_signal)

    
class MyThread(QObject):
    def __init__(self):
        self._workers = None
        self._thread = QThread()

    def add_worker(self, worker):
        self._workers  = worker

    def start(self):
        self._workers.moveToThread(self._thread)
        self._thread.start()


class TestThread:
    def test_error_1(self, qtbot):
        relay = MyRelay()
        mw = MyWorker(relay)
        my_thread = MyThread()
        my_thread.add_worker(mw)
        my_thread.start()

        try:
            with qtbot.waitSignal(
                mw.my_signal, timeout=500) as blocker:
                relay.sig_to_relay.emit()
                print(blocker)
        except Exception as e:
            traceback.print_exc()
            raise e
        my_thread._thread.quit()
        my_thread._thread.wait()

In this example, the try block is to catch the exception before it fails the test so that I can check the traceback. During failures, the traceback is:

Traceback (most recent call last):
  File "~python_work/pytest_qt_qthread/test_pytest_qt.py", line 41, in test_warning_message
    with qtbot.waitSignal(
  File "~python_work/pytest_qt_qthread/.venv/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 151, in __exit__
    self.wait()
  File "~python_work/pytest_qt_qthread/.venv/lib/python3.12/site-packages/pytestqt/wait_signal.py", line 48, in wait
    self._timer.start()
    ^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'start'

It appears that the a timer in pytest-qt is getting through the timer is None check but then is None when self._timer.start() is called a few lines later.

Also, on every run of the test (in debug mode), the following message is reported QObject::killTimer: Timers cannot be stopped from another thread, which may be related.

Any idea on what may be wrong here?

Thanks.


(edited by @The-Compiler to add syntax highlighting)

@mmerlo
Copy link
Author

mmerlo commented Dec 31, 2024

I've reviewed my previous example and found some errors. Here is updated code that addresses these errors in my previous example.

The issue is still present despite fixing these issues.

The following was corrected:

  • MyThread now calls super().__init__()
  • MyRelay uses QMetaObject.invokeMethod to emit the signal from its thread instead of the main thread.
  • Added finallyblock to always quit thread.

Despite these corrections, the error AttributeError: 'NoneType' object has no attribute 'start' is still intermittently triggered. Any suggestions?

Corrected code:

from PySide6.QtCore import QThread, QObject, Signal, QMetaObject, Qt, Slot
import traceback

class MyRelay(QObject):
    sig_to_relay = Signal()

    def remote_emit_relay_signal(self):
        QMetaObject.invokeMethod(self, "emit_relay_signal", Qt.QueuedConnection)

    @Slot()
    def emit_relay_signal(self):
        self.sig_to_relay.emit()


class MyWorker(QObject):
    my_signal = Signal()

    def __init__(self, relay):
        super().__init__()
        self._relay = relay
        self._relay.setParent(self)
        self._relay.sig_to_relay.connect(self.my_signal)

    
class MyThread(QObject):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._workers = None
        self._thread = QThread(parent=self)

    def add_worker(self, worker):
        self._workers  = worker

    def start(self):
        self._workers.moveToThread(self._thread)
        self._thread.start()


class TestThread:
    def test_error_1(self, qtbot):
        relay = MyRelay()
        mw = MyWorker(relay)
        my_thread = MyThread()
        my_thread.add_worker(mw)
        my_thread.start()

        try:
            with qtbot.waitSignal(mw.my_signal, timeout=500) as blocker:
                relay.remote_emit_relay_signal()
        except Exception as e:
            traceback.print_exc()
            raise e
        finally:
            my_thread._thread.quit()
            my_thread._thread.wait()

(edited by @The-Compiler to add syntax highlighting)

@The-Compiler
Copy link
Member

The-Compiler commented Jan 3, 2025

Reproduced by installing pytest-repeat and running your latest example with --count=500 -x (fails maybe once every 50-300 times for me).

I added some debugging prints to pytest-qt:

diff --git i/src/pytestqt/wait_signal.py w/src/pytestqt/wait_signal.py
index 8da3836..f559168 100644
--- i/src/pytestqt/wait_signal.py
+++ w/src/pytestqt/wait_signal.py
@@ -1,3 +1,4 @@
+import threading
 import functools
 
 from pytestqt.exceptions import TimeoutError
@@ -25,8 +26,10 @@ class _AbstractSignalBlocker:
         self._signals = None  # will be initialized by inheriting implementations
         self._timeout_message = ""
         if timeout is None or timeout == 0:
+            print("INIT NONE")
             self._timer = None
         else:
+            print(f"\n{threading.get_ident():x} INIT QTIMER")
             self._timer = qt_api.QtCore.QTimer(self._loop)
             self._timer.setSingleShot(True)
             self._timer.setInterval(timeout)
@@ -44,6 +47,7 @@ class _AbstractSignalBlocker:
         if self.timeout is None and not self._signals:
             raise ValueError("No signals or timeout specified.")
         if self._timer is not None:
+            print(f"{threading.get_ident():x} WAIT {self._timer}")
             self._timer.timeout.connect(self._quit_loop_by_timeout)
             self._timer.start()
 
@@ -63,6 +67,7 @@ class _AbstractSignalBlocker:
         # store timeout message before the data to construct it is lost
         self._timeout_message = self._get_timeout_error_message()
         if self._timer is not None:
+            print(f"{threading.get_ident():x} CLEANUP")
             _silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout)
             self._timer.stop()
             self._timer = None

which reveals:

test.py::TestThread::test_error_1[151-500] 
70582b03c740 INIT QTIMER
70582b03c740 WAIT <PySide6.QtCore.QTimer(0x632b10e78ca0) at 0x70582206b4c0>
705820ffd6c0 CLEANUP
QObject::killTimer: Timers cannot be stopped from another thread
PASSED

test.py::TestThread::test_error_1[152-500] 
70582b03c740 INIT QTIMER
70582b03c740 WAIT <PySide6.QtCore.QTimer(0x632b10e7a2e0) at 0x705822069a80>
705820ffd6c0 CLEANUP
Traceback (most recent call last):
  File ".../test.py", line 48, in test_error_1
    with qtbot.waitSignal(mw.my_signal, timeout=500) as blocker:
         ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../pytestqt/wait_signal.py", line 156, in __exit__
    self.wait()
    ~~~~~~~~~^^
  File ".../pytestqt/wait_signal.py", line 52, in wait
    self._timer.start()
    ^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'start'
FAILED

So what happens is that _AbstractSignalBlocker._cleanup gets triggered from a different thread (which also explains the Qt error message), and it fails if that just happens to happen between the None-check and calling .start() in wait():

if self._timer is not None:
self._timer.timeout.connect(self._quit_loop_by_timeout)
self._timer.start()

We use:

actual_signal.connect(self._quit_loop_by_signal)

which by default is supposed to use a QueuedConnection if the signal gets emitted in a different thread:

(Default) If the receiver lives in the thread that emits the signal, Qt::DirectConnection is used. Otherwise, Qt::QueuedConnection is used. The connection type is determined when the signal is emitted.

though then that page says:

Note: If a QObject has no thread affinity (that is, if thread() returns zero), or if it lives in a thread that has no running event loop, then it cannot receive queued signals or posted events.

So I believe this can be fixed simply by making _AbstractSignalBlocker a QObject.

Unfortunately, the obvious thing doesn't work:

diff --git c/src/pytestqt/wait_signal.py i/src/pytestqt/wait_signal.py
index 8da3836..5a5e361 100644
--- c/src/pytestqt/wait_signal.py
+++ i/src/pytestqt/wait_signal.py
@@ -4,7 +4,7 @@ from pytestqt.exceptions import TimeoutError
 from pytestqt.qt_compat import qt_api
 
 
-class _AbstractSignalBlocker:
+class _AbstractSignalBlocker(qt_api.QtCore.QObject):
     """
     Base class for :class:`SignalBlocker` and :class:`MultiSignalBlocker`.
 
@@ -18,6 +18,7 @@ class _AbstractSignalBlocker:
     """
 
     def __init__(self, timeout=5000, raising=True):
+        super().__init__()
         self._loop = qt_api.QtCore.QEventLoop()
         self.timeout = timeout
         self.signal_triggered = False

as the qt_api compat layer isn't initialized at import time yet (only in pytest_configure):

Traceback (most recent call last):
  [...]
  File ".../pluggy/_manager.py", line 421, in load_setuptools_entrypoints
    plugin = ep.load()
  [...]
  File ".../pytestqt/plugin.py", line 11, in <module>
    from pytestqt.qtbot import QtBot, _close_widgets
  [...]
  File ".../pytestqt/qtbot.py", line 7, in <module>
    from pytestqt.wait_signal import (
    ...<6 lines>...
    )
  [...]
  File ".../pytestqt/wait_signal.py", line 8, in <module>
    class _AbstractSignalBlocker(qt_api.QtCore.QObject):
                                 ^^^^^^^^^^^^^
AttributeError: '_QtApi' object has no attribute 'QtCore'

From a quick try with a hardcoded from PySide6.QtCore import QObject, that indeed seems to fix things though!

@mmerlo
Copy link
Author

mmerlo commented Jan 13, 2025

Thanks for looking into this issue and identifying the cause.

Do you think this will be fixed in future versions on pytest-qt?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants