Minsky | 2021-04-08 19:11:24 UTC | #1
I'm having trouble with sending a signal from a thread within an app that I'm writing.
To keep things simple, let's assume that the app consists of a main window which contains just a push button and a status bar, and has four classes - the MainWindow class, a Signals class, a Model class - which holds the logic functions, and a Worker class which is used to run separate threads.
When the push button is pressed, it connects to the 'call_do_this' function which starts a thread that calls the 'do_this' function in the separate Model class. The 'do_this function' emits a signal containing a message to be displayed in the main window status bar, and then continues with a long running process (around 14 seconds).
The simplistic code is displayed below:
[code] class Worker(QRunnable): def init(self, fn=None, args, *kwargs): super().init()
self.fn = fn
self.args = args
self.kwargs = kwargs
def run(self):
if self.fn is not None:
self.fn(*self.args, **self.kwargs)
class Signals(QObject):
status = pyqtSignal(str)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.threadpool = QThreadPool()
self.model = Model()
self.model.signals.status.connect(self.update_status_bar)
self.button = QPushButton()
self.button.pressed.connect(self.call_do_this)
def call_do_this(self):
self.worker = Worker(self.model.do_this)
self.threadpool.start(self.worker)
def update_status_bar(self, msg):
self.statusBar().showMessage(msg)
class Model(QObject):
def __init__(self):
super().__init__()
self.signals = Signals()
def do_this(self):
self.signals.status.emit('This is a message')
# continue with long running function
[/code]
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PySide2 Edition) The hands-on guide to making apps with Python — Over 10,000 copies sold!
The error that occurs, is that the status bar message should be displayed as soon as the 'do_this' function starts, but is displayed only after the function ends. What am I doing wrong?
PedanticHacker | 2021-04-08 23:55:19 UTC | #2
Hello, Minsky, welcome to the forum! :)
If you're using PyQt6 (or the latest version of PyQt5, at least version 5.15), the QThreadPool
class has the method start
that can also call any function, and QThreadPool
reserves a thread and uses it to run the function that you pass to that start
method. More about it HERE.
You don't need to have a runnable at all, just pass your function to start
, like in your case the do_this
function of your Model
class: self.threadpool.start(self.model.do_this)
. Also, always instanciate a QThreadPool
class after all the signals have been connected to slots.
So, in __init__
of MainWindow
:
self.model = Model()
self.model.signals.status.connect(self.update_status_bar)
self.button = QPushButton()
self.button.pressed.connect(self.call_do_this)
self.threadpool = QThreadPool()
The QThreadPool.start
method of PySide doesn't provide calling a regular function, just of PyQt5 from version 5.15 onward and, of course, of PyQt6.
I hope this helps in any way.
Minsky | 2021-04-08 23:59:17 UTC | #3
Thanks PedanticHacker, that did the trick. However, if I change the code so that arguments are passed into the 'do_this' function, then this freezes the main event loop, making the GUI unresponsive until the function ends. I can't work out why this happens or how to solve it.
[code] class MainWindow(QMainWindow): def init(self): super().init()
self.model = Model()
self.model.signals.status.connect(self.update_status_bar)
button = QPushButton()
button.pressed.connect(self.call_do_this)
self.threadpool = QThreadPool()
def call_do_this(self):
worker = Worker(self.model.do_this('This is a message'))
self.threadpool.start(worker)
def update_status_bar(self, msg):
self.statusBar().showMessage(msg)
class Model(QObject):
def __init__(self):
super().__init__()
self.signals = Signals()
def do_this(self, msg):
self.signals.status.emit(msg)
# continue with long running function
[/code]
This has got me totally stumped. Any help would be very welcome. I'm using PyQt5 5.13.1, so unfortunately, I end up with a segmentation fault if I don't use a QRunnable.
PedanticHacker | 2021-04-09 07:46:04 UTC | #4
Can you try updating PyQt to version 5.15.3 and trying self.threadpool.start(do_this)
?
passing a regular function to start
(as opposed to passing a runnable) of QThreadPool
class was introduced in PyQt in version 5.15. Your version of PyQt doesn't have that ability yet. That's why you get a SegmentationFault.
Minsky | 2021-04-09 11:27:15 UTC | #5
I've tried updating PyQt but I'm getting conflict messages regarding dependencies. I prefer to stick with the current version until I upgrade the OS in June. Can you think of a reason why passing the function with arguments locks the main event loop, while passing the same function without arguments works fine?
PedanticHacker | 2021-04-09 12:21:05 UTC | #6
class Worker(QRunnable):
'''
Worker thread
Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
:param callback: The function callback to run on this worker thread. Supplied args and
kwargs will be passed through to the runner.
:type callback: function
:param args: Arguments to pass to the callback function
:param kwargs: Keywords to pass to the callback function
'''
def __init__(self, fn, *args, **kwargs):
super().__init__()
# Store constructor arguments (re-used for processing)
self.fn = fn
self.args = args
self.kwargs = kwargs
@pyqtSlot()
def run(self):
'''
Initialise the runner function with passed args, kwargs.
'''
self.fn(*self.args, **self.kwargs)
The code above was taken from THIS article written by our fellow Martin Fitzpatrick (@martin).
A possible culprit in your code is that you don't decorate the run
method of your Worker
class with @pyqtSlot()
. I think I read a post on some other forum that this is needed when subclassing QRunnable. Try it out and tell me if it works.
class Worker(QRunnable):
...
@pyqtSlot() # A cruical part!
def run(self):
...
Minsky | 2021-04-09 14:34:36 UTC | #7
I've discovered the cause of the error, but not the reason for it. If the 'do_this' function is passed without arguments to the Worker, it is passed as a
[code] @pyqtSlot() def run(self): print(type(self.fn)) if self.fn is not None: self.fn(self.args, *self.kwargs) [/code]
I haven't a clue why this is happening. Any ideas?
PedanticHacker | 2021-04-09 16:25:56 UTC | #8
Try removing the default None
value of the fn
parameter in the __init__
method signature in the Worker
class.
So, instead of def __init__(self, fn=None, *args, **kwargs):
, have that as def __init__(self, fn, *args, **kwargs):
.
Then in the body of the run
method in the Worker
class, remove the if
statement. So, let it be like this:
@pyqtSlot()
def run(self):
self.fn(*self.args, **self.kwargs)
Maybe this helps?
PedanticHacker | 2021-04-09 16:45:34 UTC | #9
If you still don't succeed, study this code, provided by our good friend Martin Fitzpatrick (@martin):
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
import time
import traceback, sys
class WorkerSignals(QObject):
'''
Defines the signals available from a running worker thread.
Supported signals are:
finished
No data
error
tuple (exctype, value, traceback.format_exc() )
result
object data returned from processing, anything
progress
int indicating % progress
'''
finished = pyqtSignal()
error = pyqtSignal(tuple)
result = pyqtSignal(object)
progress = pyqtSignal(int)
class Worker(QRunnable):
'''
Worker thread
Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
:param callback: The function callback to run on this worker thread. Supplied args and
kwargs will be passed through to the runner.
:type callback: function
:param args: Arguments to pass to the callback function
:param kwargs: Keywords to pass to the callback function
'''
def __init__(self, fn, *args, **kwargs):
super().__init__()
# Store constructor arguments (re-used for processing)
self.fn = fn
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
# Add the callback to our kwargs
self.kwargs['progress_callback'] = self.signals.progress
@pyqtSlot()
def run(self):
'''
Initialise the runner function with passed args, kwargs.
'''
# Retrieve args/kwargs here; and fire processing using them
try:
result = self.fn(*self.args, **self.kwargs)
except:
traceback.print_exc()
exctype, value = sys.exc_info()[:2]
self.signals.error.emit((exctype, value, traceback.format_exc()))
else:
self.signals.result.emit(result) # Return the result of the processing
finally:
self.signals.finished.emit() # Done
class MainWindow(QMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.counter = 0
layout = QVBoxLayout()
self.l = QLabel("Start")
b = QPushButton("DANGER!")
b.pressed.connect(self.oh_no)
layout.addWidget(self.l)
layout.addWidget(b)
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)
self.show()
self.threadpool = QThreadPool()
print("Multithreading with maximum %d threads" % self.threadpool.maxThreadCount())
self.timer = QTimer()
self.timer.setInterval(1000)
self.timer.timeout.connect(self.recurring_timer)
self.timer.start()
def progress_fn(self, n):
print("%d%% done" % n)
def execute_this_fn(self, progress_callback):
for n in range(0, 5):
time.sleep(1)
progress_callback.emit(n*100/4)
return "Done."
def print_output(self, s):
print(s)
def thread_complete(self):
print("THREAD COMPLETE!")
def oh_no(self):
# Pass the function to execute
worker = Worker(self.execute_this_fn) # Any other args, kwargs are passed to the run function
worker.signals.result.connect(self.print_output)
worker.signals.finished.connect(self.thread_complete)
worker.signals.progress.connect(self.progress_fn)
# Execute
self.threadpool.start(worker)
def recurring_timer(self):
self.counter +=1
self.l.setText("Counter: %d" % self.counter)
app = QApplication([])
window = MainWindow()
app.exec_()
Minsky | 2021-04-09 17:11:56 UTC | #10
Solved it! The Worker class 'init' function expects a function name with the arguments separated by commas, whereas I was passing a function call with arguments. The code below fixed the problem,
Instead of this:
[code] def call_do_this(self): worker = Worker(self.model.do_this('This is a message')) # Passing function call self.threadpool.start(worker) [/code]
Do this:
[code] def call_do_this(self): worker = Worker(self.model.do_this, 'This is a message') # Passing function name with self.threadpool.start(worker) # comma separated argument [/code]
@ PedanticHacker: Thank you for your help in getting this problem solved, it was greatly appreciated. Hopefully this thread :grinning: may help prevent others from making a similar mistake.
PedanticHacker | 2021-04-09 17:14:48 UTC | #11
Wow, congratz, the beast has been slayed! 👍 Good job! We all learn as we go, don't we? 😉
I'm proud of you, keep up the good work. 🙂
Create GUI Applications with Python & Qt6 by Martin Fitzpatrick — (PySide6 Edition) The hands-on guide to making apps with Python — Over 10,000 copies sold!