0

I am trying to port an application I wrote in Tkinter to PyQt. The application has several threads which query external information (via APIs) and use it to update text labels in the application. There is no user interaction.

In Tkinter I was using the after() method. In PyQT, as far as I understand, I should use QtCore.QThread().

The following example program is supposed to update two labels (hour and minute). The UI is generated from Qt Designer and is basically the two text labels above. I see in the app window that the hours are updated (they change in the GUI and "update hour" is printed on the console). The minutes are not. My (failed) understanding was that started.connect() was there to fire up a thread with the indicated method. It looks like it works only once.

How should I change the code so that a thread is started for each of the methods in UpdateTime?

import sys
import time

from PyQt4 import QtGui, QtCore
from infoscren_ui import Ui_MainWindow

class Infoscreen(QtGui.QMainWindow, Ui_MainWindow):
    def __init__(self):
        QtGui.QMainWindow.__init__(self)
        self.setupUi(self)


class UpdateTime(QtCore.QObject):
    def __init__(self, root):
        QtCore.QObject.__init__(self)
        self.root = root

    def UpdateHour(self):
        hour = 0
        while True:
            hour += 1
            print("update hour")
            self.root.hour.setText(str(hour))
            time.sleep(1)

    def UpdateMinute(self):
        minute = 0
        while True:
            minute += 2
            print("update minute")
            self.root.minute.setText(str(minute))
            time.sleep(1)


if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    infoscreen = Infoscreen()
    thread = QtCore.QThread()

    obj = UpdateTime(infoscreen)
    obj.moveToThread(thread)
    thread.started.connect(obj.UpdateHour)
    thread.started.connect(obj.UpdateMinute)
    thread.finished.connect(app.exit)
    thread.start()

    infoscreen.show()
    sys.exit(app.exec_())
WoJ
  • 27,165
  • 48
  • 180
  • 345

2 Answers2

2

You cannot run both UpdateHour and UpdateMinute in the same thread, because the first one to start will just block the other one. If you want to run two blocking loops like that, each one will require its own thread.

Also, it is not thread-safe to directly call GUI methods from outside the main GUI thread (i.e. the one in which the application first starts). Instead, you should define custom signals to be emitted from the worker threads:

class UpdateHours(QtCore.QObject):
    hourChanged = QtCore.pyqtSignal(object)

    @QtCore.pyqtSlot()
    def updateHour(self):
        ...
        self.hourChanged.emit(hour)

Objects in the main thread can then connect to these signals and update the GUI from there:

    self.hourObject.hourChanged.connect(self.handleHourChanged)
    ...

    def handleHourChanged(self, hour):
       # do something with hour value...

By default, when the sender and receiver are in different threads, Qt will automatically ensure that the signals are processed asynchronously (they are added to the application's event queue), which is enough to guarantee thread-safety.

ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • Thank you. There is still one thing I do not understand: how are the methods of the object (`updateHour` in your example) moved to the thread via `.moveToThread(thread)` started? Are all of the methods in this object started by default? I do not see any mention of them in neither your code, nor Schollii's one? – WoJ Jan 01 '15 at 21:16
  • @WoJ. Using the threads' `started` signal as in your original example seems most natural. It would probably be wise to decorate those methods as slots, though (updated my answer in view of that). – ekhumoro Jan 01 '15 at 21:26
1

The GUI runs in the main thread; that's where app.exec() is called. QThread instances you create represent "secondary" threads. Never call a method of a QObject (like QLabel.setText) from a non-main thread. Instead, define custom signals on the class that you move to the secondary thread, and connect them to slots of your GUI objects. Then PyQt takes care of calling the slot in the main thread, even if the signal is emitted from a secondary thread. Naively implemented, this would look something like this:

class UpdateTime(QtCore.QObject):
    sig_minute = pyqtSignal(str)
    sig_hour = pyqtSignal(str)

    def __init__(self):
        QtCore.QObject.__init__(self)

    def UpdateHour(self):
        hour = 0
        while True:
            hour += 1
            print("update hour")
            self.sig_hour.emit(str(hour))
            time.sleep(1)

    def UpdateMinute(self):
        minute = 0
        while True:
            minute += 2
            print("update minute")
            self.sig_minute.emit(str(minute))
            time.sleep(1)

You could establish the connections in main:

if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    infoscreen = Infoscreen()
    thread = QtCore.QThread()

    obj = UpdateTime()
    obj.moveToThread(thread)
    obj.sig_hour.connect(infoscreen.hour.setText)
    obj.sig_minute.connect(infoscreen.minute.setText)
    thread.finished.connect(app.exit)
    thread.start()

    infoscreen.show()
    sys.exit(app.exec_())

Note how this has decoupled InfosSreen from UpdateTime. One could also establish the connections in InfoScreen.__init__ instead: better encapsulation since the hour and minute labels are members of InfoScreen. Or one could establish the connections in UpdateTime.__init__, but in Qt it seems the philosophy is for the visuals to know about the workers (logic, controllers, etc) rather than the other way around.

In any case, it is important for the connections to be established after the moveToThread: although the default connection type is AUTO, this just means that at the time the connection is made, Qt will determine which type of connection to establish (blocking or queued). So if you connect InfoScreen to UpdateTime before moveToThread, then Qt thinks they "live" in the same thread and makes the connection "blocking". Even after moveToThread is called, the connection remains blocking, so a signal emitted by a function executing in secondary thread causes the connected slot from InfosScreen to be called in the same thread rather than in the main thread. If on the other hand the UpdateTime is first moved to thread, then connected, Qt knows it is in a non-main thread, so makes the connection queued. In that case, emitting a signal in secondary thread will cause the connected slot to be called in the main thread.

However the above won't do anything because moving an object to a thread does not "run" the object. What runs is the thread's event loop, once QThread.start() is called. So to have the object do some work, you have to call a slot on your object, such that the slot executes in the secondary thread. One way is via the start signal of the thread, connected after moveToThread:

thread.started.connect(obj.run)

Then eventhough the QThread instance lives in the main thread, its started signal will be handled in the secondary thread, asynchronously. The obj.run method can call the UpdateHour and UpdateMinute as appropriate. However, your UpdateHour and UpdateMinute cannot both be looping indefinitely (using while True) in the same thread. You would either have to create two separate objects living in two separate threads, one for hour and one for minute, or make the two updates cooperate by running for only small chunk of time. The details depend on what functionality you are porting from TK, but for example of latter approach, it could look like this:

class UpdateTime(QtCore.QObject):
    sig_minute = pyqtSignal(str)
    sig_hour = pyqtSignal(str)

    def __init__(self):
        super().__init__(self)
        self.hour = 0
        self.minute = 0
        self.seconds = 0

    def run(self):
        while True:
           time.sleep(1)
           self.seconds += 1
           self.UpdateMinute()
           self.UpdateHour()

    def UpdateHour(self):
        if self.minute == 60: 
            self.hour += 1
            print("update hour")
            self.sig_minute.emit(str(self.hour))
            self.minute = 0

    def UpdateMinute(self):
        if self.seconds == 60: 
            self.minute += 1
            print("update minute")
            self.sig_minute.emit(str(self.minute))
            self.seconds = 0

Instead of connecting the started signal to call the obj.run, you could connect a QTimer's timeout signal instead, in single shot mode, so that obj.run gets called once, at some later time. Or connect a button's clicked signal, to start it only when a button clicked. In your case the UpdateTime could also be implemented by calling QObject.startTimer and overriding eventTimer to increase minute by 1. See Qt timers for more info on timers.

Not tested the above for bugs, but you get the idea.

Oliver
  • 27,510
  • 9
  • 72
  • 103
  • Thanks for the comment - would you mind expanding a bit? What is the "main thread"? Is it the one launched via `app.exec_()`? How should I launch the worker threads so that they can send appropriate signals (to the main thread - I assume), the way I tried in my code does not work (only the first one is launched)? – WoJ Dec 31 '14 at 14:09
  • With auto-connection, the connection type is determined when the signal is emitted, not when it is connected. So long as the receiver is either a Qt slot, or a python callable that has been decorated with `@QtCore.pyqtSlot`, the connection can be made at any time. – ekhumoro Jan 01 '15 at 01:24
  • @Schollii: Thank you for expanding your initial answer, this clarifies a lot. I have the same question as to ekhumoro: how are the methods of the object (`updateHour` and `updateMinute`) moved to the thread via `.moveToThread(thread)` started? Are all of the methods in this object started by default? – WoJ Jan 01 '15 at 21:18
  • OK, i got it - I need to start all the threads for the methods via `thread.started.connect(obj.)` – WoJ Jan 02 '15 at 09:24
  • Also; shouldn't the signal be defined as `pyqtSignal(str)`? – WoJ Jan 02 '15 at 09:46
  • Yes. I added some info although it looks like you figured it out in the meantime, good job! Yes the pyqtSignal data is of type str, fixed. – Oliver Jan 02 '15 at 14:31
  • @ekhumoro What I have observed is that if I establish connections before `moveToThread`, the connections are synchronous even after the `moveToThread`, whereas if I establish connections after the `moveToThread`, they are automatically asynchronous. However, I don't use `@pyqtSlot`, maybe that's why. – Oliver Jan 06 '15 at 03:59
  • @Schollii. Yes, it's because of the `@pyqtSlot`: see [here](http://stackoverflow.com/a/20818401/984421). – ekhumoro Jan 06 '15 at 05:00