Search code examples
pyqt5qthreadqprogressbar

PyQt5 QProgressBar Does Not Appear when run in QThread


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)

ProjectStructure

Main

# 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

# 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

# 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

# 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:

Problem

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:

WithAdditionStart

and upon opening files, the dialog behaves as expected:

WithAdditionEnd


Solution

  • An enlightening talk was given at Kiwi Pycon 2019 that helped me identify the problem: "Python, Threads & Qt: Boom!"

    1. Every QObject is owned by a QThread
    2. A QObject instance must not be shared across threads
    3. QWidget 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:

    1. Move on_start and on_finish from ProgressDialog to View (I rename them show_progress_dialog and hide_progress_dialog)
    2. Create put the open_file logic in a separate QObject worker
    3. Call view.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)

    View

    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

    # 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

    # 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()