This question was deleted, but I updated the code to an MRE. I have run it on my terminal and it does not have any compilation/runtime errors, but behaves as I explain below. Since the moderators have not responded to my original request to reopen my question after I have corrected it, I have deleted the old question and am placing this new one here.
My signals update the progress value, but the progress bar itself never appears. Is there an error in my code?
(To recreate, please place the code for each file listed below in the project structure shown below. You will only need to install PyQt5
. I am on Windows 10 and using a Python 3.8 virtual environment with poetry. The virtual environment and poetry are optional)
# main.py
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
from app.controller.controller import Controller
from app.model.model import Model
from app.view.view import View
class MainApp:
def __init__(self) -> None:
self.controller = Controller()
self.model: Model = self.controller.model
self.view: View = self.controller.view
def show(self) -> None:
self.view.showMaximized()
if __name__ == "__main__":
app: QApplication = QApplication([])
app.setStyle("fusion")
app.setAttribute(Qt.AA_DontShowIconsInMenus, True)
root: MainApp = MainApp()
root.show()
app.exec_()
# view.py
from typing import Any, Optional
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt, pyqtSignal
class ProgressDialog(QtWidgets.QDialog):
def __init__(
self,
parent_: Optional[QtWidgets.QWidget] = None,
title: Optional[str] = None,
):
super().__init__(parent_)
self._title = title
self.pbar = QtWidgets.QProgressBar(self)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.pbar)
self.setLayout(layout)
self.resize(500, 50)
def on_start(self):
self.setModal(True)
self.show()
def on_finish(self):
self.hide()
self.setModal(False)
self.pbar.reset()
self.title = None
def on_update(self, value: int):
self.pbar.setValue(value)
print(self.pbar.value()) # For debugging...
@property
def title(self):
return self._title
@title.setter
def title(self, title_):
self._title = title_
self.setWindowTitle(title_)
class View(QtWidgets.QMainWindow):
def __init__(
self, controller, parent_: QtWidgets.QWidget = None, *args: Any, **kwargs: Any
) -> None:
super().__init__(parent_, *args, **kwargs)
self.controller: Controller = controller
self.setWindowTitle("App")
self.container = QtWidgets.QFrame()
self.container_layout = QtWidgets.QVBoxLayout()
self.container.setLayout(self.container_layout)
self.setCentralWidget(self.container)
# Create and position widgets
self.open_icon = self.style().standardIcon(QtWidgets.QStyle.SP_DirOpenIcon)
self.open_action = QtWidgets.QAction(self.open_icon, "&Open file...", self)
self.open_action.triggered.connect(self.controller.on_press_open_button)
self.toolbar = QtWidgets.QToolBar("Main ToolBar")
self.toolbar.setIconSize(QtCore.QSize(16, 16))
self.addToolBar(self.toolbar)
self.toolbar.addAction(self.open_action)
self.file_dialog = self._create_open_file_dialog()
self.progress_dialog = ProgressDialog(self)
def _create_open_file_dialog(self) -> QtWidgets.QFileDialog:
file_dialog = QtWidgets.QFileDialog(self)
filters = [
"Excel Documents (*.xlsx)",
]
file_dialog.setWindowTitle("Open File...")
file_dialog.setNameFilters(filters)
file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles)
return file_dialog
# model.py
import time
from typing import Any
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QObject, pyqtSignal
class Model(QObject):
start_task: pyqtSignal = pyqtSignal()
finish_task: pyqtSignal = pyqtSignal()
update_task: pyqtSignal = pyqtSignal(int)
def __init__(
self,
controller,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__()
self.controller = controller
def open_file(self, files: str) -> None:
self.start_task.emit()
for ndx, file_ in enumerate(files):
print(file_) # In truth, here, I'm actually performing processing
time.sleep(1) # Only here for simulating a long-running task
self.update_task.emit(int((ndx + 1) / len(files) * 100))
self.finish_task.emit()
# controller.py
from typing import Any
from app.model.model import Model
from app.view.view import View
from PyQt5 import QtCore, QtGui, QtWidgets
class Controller:
def __init__(
self,
*args: Any,
**kwargs: Any,
) -> None:
self.model = Model(controller=self, *args, **kwargs)
self.view = View(controller=self, *args, **kwargs)
def on_press_open_button(self) -> None:
if self.view.file_dialog.exec_() == QtWidgets.QDialog.Accepted:
file_names = self.view.file_dialog.selectedFiles()
self.view.progress_dialog.title = "Opening files..."
self.thread = QtCore.QThread()
self.model.moveToThread(self.thread)
self.thread.started.connect(lambda: self.model.open_file(file_names))
self.thread.finished.connect(self.thread.deleteLater)
self.model.start_task.connect(self.view.progress_dialog.on_start)
self.model.update_task.connect(
lambda value: self.view.progress_dialog.on_update(value)
)
self.model.finish_task.connect(self.view.progress_dialog.on_finish)
self.model.finish_task.connect(self.thread.quit)
self.model.finish_task.connect(self.model.deleteLater)
self.model.finish_task.connect(self.thread.deleteLater)
self.thread.start()
When I run the above in a folder of 6 files, it's not running through things too fast (I'm actually performing processing which takes a total of about 5 seconds). It completes successfully and my terminal outputs:
16
33
50
66
83
100
but my ProgressDialog
window is just this for the whole process:
If I add self.progress_dialog.show()
at the end of __init__()
in View
(snipped for brevity)
# view.py
# Snip...
class View(QtWidgets.QMainWindow):
def __init__( ... ):
# Snip...
self.progress_dialog.show()
then a progress bar is added:
and upon opening files, the dialog behaves as expected:
An enlightening talk was given at Kiwi Pycon 2019 that helped me identify the problem: "Python, Threads & Qt: Boom!"
- Every
QObject
is owned by aQThread
- A
QObject
instance must not be shared across threadsQWidget
objects (i.e. anything you can "see") are not re-entrant. Thus, they can only be called from the main UI thread.
Point 3 was my problem. Qt
doesn't prevent one from calling a QWidget
object from outside the main thread, but it doesn't work. Even moving my ProgressDialog
to the created QThread
will not help. Hence, showing and hiding the ProgressDialog
MUST be handled by the main thread.
Furthermore, once a QObject
has been moved to a separate thread, rerunning the code will give the error:
QObject::moveToThread: Current thread (0xoldbeef) is not the object's thread (0x0).
Cannot move to target thread (0xnewbeef)
because it does not create a new model object, but reuses the old object. Hence, the code must be moved into a separate worker object unfortunately.
The correct code would be to:
on_start
and on_finish
from ProgressDialog
to View
(I rename them show_progress_dialog
and hide_progress_dialog
)open_file
logic in a separate QObject
workerview.progress_dialog.show()
by itself (the thread
can call hide
or open
when thread.finished
is emited though; I guess it's because of special logic implemented in Qt when the thread ends)from typing import Any, Optional
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt, pyqtSignal
class ProgressDialog(QtWidgets.QDialog):
def __init__(
self,
parent_: Optional[QtWidgets.QWidget] = None,
title: Optional[str] = None,
):
super().__init__(parent_)
self._title = title
self.pbar = QtWidgets.QProgressBar(self)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.pbar)
self.setLayout(layout)
self.resize(500, 50)
def on_update(self, value: int):
self.pbar.setValue(value)
@property
def title(self):
return self._title
@title.setter
def title(self, title_):
self._title = title_
self.setWindowTitle(title_)
class View(QtWidgets.QMainWindow):
def __init__(
self, controller, parent_: QtWidgets.QWidget = None, *args: Any, **kwargs: Any
) -> None:
super().__init__(parent_, *args, **kwargs)
self.controller: Controller = controller
self.setWindowTitle("App")
self.container = QtWidgets.QFrame()
self.container_layout = QtWidgets.QVBoxLayout()
self.container.setLayout(self.container_layout)
self.setCentralWidget(self.container)
# Create and position widgets
self.open_icon = self.style().standardIcon(QtWidgets.QStyle.SP_DirOpenIcon)
self.open_action = QtWidgets.QAction(self.open_icon, "&Open file...", self)
self.open_action.triggered.connect(self.controller.on_press_open_button)
self.toolbar = QtWidgets.QToolBar("Main ToolBar")
self.toolbar.setIconSize(QtCore.QSize(16, 16))
self.addToolBar(self.toolbar)
self.toolbar.addAction(self.open_action)
self.file_dialog = self._create_open_file_dialog()
self.progress_dialog = ProgressDialog(self)
def _create_open_file_dialog(self) -> QtWidgets.QFileDialog:
file_dialog = QtWidgets.QFileDialog(self)
filters = [
"Excel Documents (*.xlsx)",
]
file_dialog.setWindowTitle("Open File...")
file_dialog.setNameFilters(filters)
file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles)
return file_dialog
def show_progress_dialog(self):
self.progress_dialog.setModal(True)
self.progress_dialog.show()
def hide_progress_dialog(self):
self.progress_dialog.hide()
self.progress_dialog.setModal(False)
self.progress_dialog.pbar.reset()
self.progress_dialog.title = None
# model.py
import time
from typing import Any, Optional
from PyQt5.QtCore import QObject, pyqtSignal
class Model:
def __init__(
self,
controller,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__()
self.controller = controller
class OpenFileWorker(QObject):
update: pyqtSignal = pyqtSignal(int)
finished: pyqtSignal = pyqtSignal()
def __init__(self) -> None:
super().__init__()
def open_file(self, files: str) -> None:
for ndx, file_ in enumerate(files):
print(file_) # In truth, here, I'm actually performing processing
time.sleep(1) # Only here for simulating a long-running task
self.update.emit(int((ndx + 1) / len(files) * 100))
self.finished.emit()
# controller.py
from typing import Any
from app.model.model import Model, OpenFileWorker
from app.view.view import View
from PyQt5 import QtCore, QtGui, QtWidgets
class Controller:
def __init__(
self,
*args: Any,
**kwargs: Any,
) -> None:
self.model = Model(controller=self, *args, **kwargs)
self.view = View(controller=self, *args, **kwargs)
def on_press_open_button(self) -> None:
if self.view.file_dialog.exec_() == QtWidgets.QDialog.Accepted:
file_names = self.view.file_dialog.selectedFiles()
self.view.progress_dialog.title = "Opening files..."
self.thread = QtCore.QThread()
self.open_worker = OpenFileWorker()
self.open_worker.moveToThread(self.thread)
self.view.show_progress_dialog()
self.thread.started.connect(lambda: self.open_worker.open_file(file_names))
self.open_worker.update.connect(
lambda value: self.view.progress_dialog.on_update(value)
)
self.open_worker.finished.connect(self.view.hide_progress_dialog)
self.open_worker.finished.connect(self.thread.quit)
self.thread.finished.connect(self.open_worker.deleteLater)
self.thread.start()