-1

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

adam.hendry
  • 4,458
  • 5
  • 24
  • 51
  • For future reference, try to make your code more easy to reproduce. The directory structure is not essential for the problem, and you could even use a single code block for all parts instead of asking people to create four separate files. Remember: people should focus on the problem, not be distracted by recreating it, and a lot of users (that could potentially answer you) feel actually discouraged to try your code if it requires too many actions to do it, with the result that they will just ignore the question at all; providing an easily reproducible example should be *your* responsibility. – musicamante Oct 28 '21 at 09:13
  • 1
    If imports were the issue (which is unlikely, but still a rightful objection), trying to reduce the code would have shown that. It's a well known fact that creating a MRE usually solves ~50% of the problems. You're right, you don't have to make anyone happy, nor you should care. But you're asking a question and looking for answers; it's not only in your interest to ensure it gets as much audience as possible (by increasing the possibility of answers), but also a way to show respect to people that might want to spend their time for you (and for free) by providing their knowledge and experience. – musicamante Oct 28 '21 at 18:18
  • Agree to disagree – adam.hendry Oct 29 '21 at 04:04
  • Well, of course, we don't have to agree, and that's a good thing. But, still, you've got (interesting) questions, and few useful answers outside those provided by yourself. This is a public community, the moment we agree to join it, we also have to agree to its (sometimes unspoken, subtle and even controversial) rules. We may not like them, and that's our personal and absolutely rightful view. But this is a public space, not our backyard: if we post questions or answers, and get criticism for them while knowing that *that* is a highly possible result, ranting about it won't help us in any way. – musicamante Oct 29 '21 at 04:26

1 Answers1

0

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()
adam.hendry
  • 4,458
  • 5
  • 24
  • 51