Search code examples
pyqt5

PyQt5 use of QDeadlineTimer


Can anyone clarify why the following code is not behaving as expected. All of the calls to remainingTime() are returning 0 and hasExpired() is returning True. I would have expected first remainingTime() to have returned something close to 1000ms and the second remainingTime() to have returned around 500ms

deadline = QDeadlineTimer(1000)
print(f"deadline.remainingTime(): {deadline.remainingTime()}")
print(f"has expired: {deadline.hasExpired()}")
sleep(0.5)
print(f"deadline.remainingTime(): {deadline.remainingTime()}")

This code is being called from a PyQt5 button press so is operating in the main thread, not good practice, however I'm just trying to learn how to use QDeadlineTimer.


Solution

  • That looks like a bug in the QDeadlineTimer constructor that only happens in PyQt5 (at least until 5.15.9[1]).
    It seems to work fine in PySide2 and even PyQt6 (probably due to the more strict enum system it introduced).

    Using an integer as argument will result in calling the constructor that accepts a Qt.TimerType as argument:

    >>> timeout = 10
    >>> QDeadlineTimer(timeout).timerType() == timeout
    True
    

    The problem might be in the sip file, as it seems that trying to use the explicit signature (int, type=Qt.TimerType) is not accepted, even if help(QDeadlineTimer) lists it:

    >>> help(QDeadlineTimer)
    ...
     | QDeadlineTimer(int, type: Qt.TimerType = Qt.CoarseTimer)
    

    Still, trying to use the full signature, will raise an exception:

    >>> QDeadlineTimer(2000, type=Qt.CoarseTimer)
    ...
    TypeError: 'type' is an unknown keyword argument
    

    The simple solution is to explicitly set the remaining time:

    >>> deadline = QDeadlineTimer()
    >>> deadline.setRemainingTime(1000)
    

    Temporary fix

    WARNING: Please consider that the following "hack" should not be done lightly, as complex object structures (as foreign language bindings often are) may rely on rigid and delicate mechanisms that could be fatally broken. The fact that it works doesn't mean that it's completely safe to do it. If we're writing code for a software that requires absolute reliability, we should just do what explained above (or eventually use a basic subclass), unless we really know what we're doing and what that code does.

    As a temporary workaround, we could create a monkey patch for the __init__, until we know that the bug has been fixed in PyQt5 (see below).

    The trick is to create a function that virtually overrides the __init__ of the class and checks if the first given argument actually is an integer.

    def deadlineTimerInitFix(self, *args, **kwargs):
        if len(args) and type(args[0]) is int:
            remaining, *args = args
            timerType = kwargs.pop('type', None) # fix the "type" argument issue
        else:
            remaining = 0
            timerType = None
    
        QDeadlineTimer.__origInit__(self, *args, **kwargs)
    
        if remaining > 0:
            self.setRemainingTime(remaining)
        if timerType is not None:
            self.setTimerType(timerType)
    
    QDeadlineTimer.__origInit__ = QDeadlineTimer.__init__
    QDeadlineTimer.__init__ = deadlineTimerInitFix
    

    Which will result in the expected behavior:

    >>> t = QDeadlineTimer(1000); t.remainingTime()
    1000
    

    Note that the type checking is done with if type() is, not isinstance(). In PyQt5 (as in PySide2 and, in "forgiveness mode" in PySide6 too) normal integers can be used for enums and isinstance(SomeQtEnumOrFlag, int) will always be true.

    For instance, setTimerType(0) would be accepted as equivalent to setTimerType(Qt.PreciseTimer).
    And if we used isinstance(args[0], int), it would use that first argument as remaining time even if using a valid Qt.TimerType enum or flag.

    This means that, with the fix above, in the case we wanted to create an arbitrary QDeadlineTimer that just specifies the timer type in the constructor, we must use the Qt.TimerType enum.

    # correct:
    >>> t = QDeadlineTimer(Qt.VeryCoarseTimer)
    >>> t.timerType() == Qt.VeryCoarseTimer
    True
    
    >>> t.timerType()
    2
    
    
    # not valid, as the value would be used for the duration:
    >>> t = QDeadlineTimer(2)
    >>> t.timerType() == Qt.VeryCoarseTimer
    False
    
    >>> t.timerType()
    1   # as in Qt.CoarseTimer, the default type
    

    Be aware that the above MUST only be done once (otherwise we'll get recursion), possibly as soon as the first PyQt import is done in the whole application.

    Patch only when necessary

    Since we may need to use QDeadlineTimer in more than one script, and (hopefully) the bug may be fixed in the future, we should add some checks to avoid unnecessary calls.

    I normally create a separate file that does this sort of patches (in order to be sure that they will be applied only when necessary).

    The following assumes that the bug has been fixed since PyQt 5.15.10 (remember that only major and minor version numbers of PyQt and Qt match: the build/revision number never does).

    This the content of a possible qtcompatfixes.py file:

    from PyQt5 import QtCore
    
    if (
        not hasattr(QtCore.QDeadlineTimer, '__origInit__')
        and QtCore.PYQT_VERSION >> 8 & 255 < 15 # PyQt older than 5.15
        or QtCore.PYQT_VERSION & 255 < 10       # 5.15.* but older than 5.15.10
    ):
        def deadlineTimerInitFix(self, *args, **kwargs):
            ...
        QtCore.QDeadlineTimer.__origInit__ = QtCore.QDeadlineTimer.__init__
        QtCore.QDeadlineTimer.__init__ = deadlineTimerInitFix
    

    Then, for any script that requires a QDeadlineTimer constructor with the remaining time:

    from PyQt5 import whatever # or any PyQt5 related import statement
    import qtcompatfixes
    
    ...
    

    [1] According to this update from the PyQt maintainer, the issue has been fixed on the latest snapshot; if you're using local builds (or you're using a system that relies on latest official builds), just upgrade it; otherwise, if you're using PIP, wait for the next official release. In any case, the fix above should work properly and be consistent with new updates.