Search code examples
pythonpython-3.xpyqtpyqt5qprogressbar

Pop up a Widget containing a QProgressBar between two QWizardPages


I'm working on a GUI for creating and managing virtual environments for Python 3. For this I use Python 3.7.4 and PyQt5. I would like the Creation process of the virtual environment to be done by a wizard and by using the create() method of Python's venv module. So far everything works as expected. The virtual environment is created properly and the wizard switches to the next page.

Now, the step where the virtual environment is being created (this happens when switching from the first page to the second), I've included a widget that shows up containing a progress bar to bridge the few seconds while venv creates the virtual environment. This works, but the widget shows only black content when it shows up.

I have tried to fix it with threads and also with multiprocessing (by calling the two functions at the same time), but that did not work. Although the widget appears, the animation does not run as usual and is already at 100% as soon as it's visible. Also it appears after the environment has been created.

Here is a screenshot:

enter image description here


Here are the parts of code to reproduce:

from subprocess import Popen, PIPE, CalledProcessError
from venv import create

from PyQt5 import QtWidgets, QtGui, QtCore
from PyQt5.QtCore import (Qt, QRect, QSize, QMetaObject, QDir, QFile, QRegExp,
                          QBasicTimer)
from PyQt5.QtGui import (QIcon, QFont, QPixmap, QStandardItemModel,
                         QStandardItem)

from PyQt5.QtWidgets import (QMainWindow, QApplication, QAction, QHeaderView,
                             QFileDialog, QWidget, QGridLayout, QVBoxLayout,
                             QLabel, QPushButton, QSpacerItem, QSizePolicy,
                             QTableView, QAbstractItemView, QMenuBar, QMenu,
                             QStatusBar, QMessageBox, QWizard, QWizardPage,
                             QRadioButton, QCheckBox, QLineEdit, QGroupBox,
                             QComboBox, QToolButton, QProgressBar, QDialog,
                             QHBoxLayout)




#]===========================================================================[#
#] FIND INSTALLED INTERPRETERS [#============================================[#
#]===========================================================================[#

# look for installed Python versions in common locations
versions = ['3.9', '3.8', '3.7', '3.6', '3.5', '3.4', '3.3', '3']

notFound = []
versFound = []
pathFound = []

for i, v in enumerate(versions):
    try:
        # get installed python3 versions
        getVers = Popen(["python" + v, "-V"],
                            stdout=PIPE, universal_newlines=True)
        version = getVers.communicate()[0].strip()

        # get paths of the python executables
        getPath = Popen(["which", "python" + v],
                            stdout=PIPE, universal_newlines=True)
        path = getPath.communicate()[0].strip()

        versFound.append(version)
        pathFound.append(path)

    except (CalledProcessError, FileNotFoundError):
        notFound.append(i)


This is the progress bar:


#]===========================================================================[#
#] PROGRESS BAR [#===========================================================[#
#]===========================================================================[#

class ProgBarWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.initMe()


    def initMe(self):
        # basic window settings
        self.setGeometry(600, 300, 300, 80)
        self.setFixedSize(325, 80)
        self.setWindowTitle("Creating")
        self.setWindowFlag(Qt.WindowCloseButtonHint, False)
        self.setWindowFlag(Qt.WindowMinimizeButtonHint, False)

        horizontalLayout = QHBoxLayout(self)
        verticalLayout = QVBoxLayout()

        statusLabel = QLabel(self)
        statusLabel.setText("Creating virtual environment...")

        self.progressBar = QProgressBar(self)
        self.progressBar.setFixedSize(300, 23)

        self.timer = QBasicTimer()
        self.timer.start(0, self)
        self.i = 0

        verticalLayout.addWidget(statusLabel)
        verticalLayout.addWidget(self.progressBar)

        horizontalLayout.addLayout(verticalLayout)
        self.setLayout(horizontalLayout)


    def timerEvent(self, e):
        if self.i >= 100:
            self.timer.stop()
            #self.close()

        self.i += 1
        self.progressBar.setValue(self.i)


This is the wizard part:


#]===========================================================================[#
#] VENV WIZARD [#============================================================[#
#]===========================================================================[#

class VenvWizard(QWizard):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Venv Wizard")
        self.resize(535, 430)
        self.move(578, 183)

        self.setStyleSheet(
            """
            QToolTip {
                background-color: rgb(47, 52, 63);
                border: rgb(47, 52, 63);
                color: rgb(210, 210, 210);
                padding: 2px;
                opacity: 325
            }
            """
        )

        self.addPage(BasicSettings())
        self.addPage(InstallPackages())
        self.addPage(Summary())


First wizard page:

class BasicSettings(QWizardPage):
    def __init__(self):
        super().__init__()

        folder_icon = QIcon.fromTheme("folder")

        self.setTitle("Basic Settings")
        self.setSubTitle("This wizard will help you to create and set up "
                         "a virtual environment for Python 3. ")



        interpreterLabel = QLabel("&Interpreter:")
        self.interprComboBox = QComboBox()
        interpreterLabel.setBuddy(self.interprComboBox)

        # add items from versFound to combobox
        self.interprComboBox.addItem("---")
        for i in range(len(versFound)):
            self.interprComboBox.addItem(versFound[i], pathFound[i])

        venvNameLabel = QLabel("Venv &name:")
        self.venvNameLineEdit = QLineEdit()
        venvNameLabel.setBuddy(self.venvNameLineEdit)

        venvLocationLabel = QLabel("&Location:")
        self.venvLocationLineEdit = QLineEdit()
        venvLocationLabel.setBuddy(self.venvLocationLineEdit)

        selectFolderToolButton = QToolButton()
        selectFolderToolButton.setFixedSize(26, 27)
        selectFolderToolButton.setIcon(folder_icon)
        selectFolderToolButton.setToolTip("Browse")

        # TODO: remove placeholder and add a spacer instead
        placeHolder = QLabel()


        # options groupbox
        groupBox = QGroupBox("Options")

        self.withPipCBox = QCheckBox("Install and update &Pip")
        self.sysSitePkgsCBox = QCheckBox(
            "&Make system (global) site-packages dir available to venv")
        self.launchVenvCBox = QCheckBox(
            "Launch a terminal with activated &venv after installation")
        self.symlinksCBox = QCheckBox(
            "Attempt to &symlink rather than copy files into venv")


        # events
        self.withPipCBox.toggled.connect(self.collectData)
        self.sysSitePkgsCBox.toggled.connect(self.collectData)
        self.launchVenvCBox.toggled.connect(self.collectData)
        self.venvNameLineEdit.textChanged.connect(self.collectData)
        self.venvLocationLineEdit.textChanged.connect(self.collectData)
        self.interprComboBox.currentIndexChanged.connect(self.collectData)
        self.symlinksCBox.toggled.connect(self.collectData)
        selectFolderToolButton.clicked.connect(self.selectDir)


        # store the collected values
        self.interprVers = QLineEdit()
        self.interprPath = QLineEdit()
        self.venvName = QLineEdit()
        self.venvLocation = QLineEdit()
        self.withPip = QLineEdit()
        self.sysSitePkgs = QLineEdit()
        self.launchVenv = QLineEdit()
        self.symlinks = QLineEdit()


        # register fields
        self.registerField("interprComboBox*", self.interprComboBox)
        self.registerField("venvNameLineEdit*", self.venvNameLineEdit)
        self.registerField("venvLocationLineEdit*", self.venvLocationLineEdit)

        self.registerField("interprVers", self.interprVers)
        self.registerField("interprPath", self.interprPath)
        self.registerField("venvName", self.venvName)
        self.registerField("venvLocation", self.venvLocation)
        self.registerField("withPip", self.withPip)
        self.registerField("sysSitePkgs", self.sysSitePkgs)
        self.registerField("launchVenv", self.launchVenv)
        self.registerField("symlinks", self.symlinks)


        # grid layout
        gridLayout = QGridLayout()
        gridLayout.addWidget(interpreterLabel, 0, 0, 1, 1)
        gridLayout.addWidget(self.interprComboBox, 0, 1, 1, 2)
        gridLayout.addWidget(venvNameLabel, 1, 0, 1, 1)
        gridLayout.addWidget(self.venvNameLineEdit, 1, 1, 1, 2)
        gridLayout.addWidget(venvLocationLabel, 2, 0, 1, 1)
        gridLayout.addWidget(self.venvLocationLineEdit, 2, 1, 1, 1)
        gridLayout.addWidget(selectFolderToolButton, 2, 2, 1, 1)
        gridLayout.addWidget(placeHolder, 3, 0, 1, 2)
        gridLayout.addWidget(groupBox, 4, 0, 1, 3)
        self.setLayout(gridLayout)


        # options groupbox
        groupBoxLayout = QVBoxLayout()
        groupBoxLayout.addWidget(self.withPipCBox)
        groupBoxLayout.addWidget(self.sysSitePkgsCBox)
        groupBoxLayout.addWidget(self.launchVenvCBox)
        groupBoxLayout.addWidget(self.symlinksCBox)
        groupBox.setLayout(groupBoxLayout)



    #]=======================================================================[#
    #] SELECTIONS [#=========================================================[#
    #]=======================================================================[#

    def selectDir(self):
        """
        Specify path where to create venv.
        """
        fileDiag = QFileDialog()

        folderName = fileDiag.getExistingDirectory()
        self.venvLocationLineEdit.setText(folderName)


    def collectData(self, i):
        """
        Collect all input data.
        """
        self.interprVers.setText(self.interprComboBox.currentText())
        self.interprPath.setText(self.interprComboBox.currentData())
        self.venvName.setText(self.venvNameLineEdit.text())
        self.venvLocation.setText(self.venvLocationLineEdit.text())

        # options
        self.withPip.setText(str(self.withPipCBox.isChecked()))
        self.sysSitePkgs.setText(str(self.sysSitePkgsCBox.isChecked()))
        self.launchVenv.setText(str(self.launchVenvCBox.isChecked()))
        self.symlinks.setText(str(self.symlinksCBox.isChecked()))


The second wizard page:

class InstallPackages(QWizardPage):
    def __init__(self):
        super().__init__()

        self.setTitle("Install Packages")
        self.setSubTitle("Specify the packages which you want Pip to "
                         "install into the virtual environment.")

        # ...

        self.progressBar = ProgBarWidget()


    def initializePage(self):
        #interprVers = self.field("interprVers")
        interprPath = self.field("interprPath")
        self.venvName = self.field("venvName")
        self.venvLocation = self.field("venvLocation")
        self.withPip = self.field("withPip")
        self.sysSitePkgs = self.field("sysSitePkgs")
        #launchVenv = self.field("launchVenv")
        self.symlinks = self.field("symlinks")

        # overwrite with the selected interpreter
        sys.executable = interprPath

        # run the create process
        self.createProcess()

        # tried threading, but didn't really change the behaviour
        #Thread(target=self.progressBar.show).start()
        #Thread(target=self.createProcess).start()


    def createProcess(self):
        """
        Create the virtual environment.
        """
        print("Creating virtual environment...")  # print to console
        self.progressBar.show()  # the window containing the progress bar

        # the create method from Python's venv module
        create('/'.join([self.venvLocation, self.venvName]),
            system_site_packages=self.sysSitePkgs,
            symlinks=self.symlinks, with_pip=self.withPip)

        self.progressBar.close()  # close when done
        print("Done.")  # print to console when done


The last wizard page (not relevant in this case.):


class Summary(QWizardPage):
    def __init__(self):
        super().__init__()

        self.setTitle("Summary")
        self.setSubTitle("...............")

        # ...



if __name__ == "__main__":
    import sys

    app = QApplication(sys.argv)

    ui = VenvWizard()
    ui.show()

    sys.exit(app.exec_())

My questions:

Is this the correct way to show up a prgress bar between two QWizardPages? If not, what could be a better way achieving that?


Solution

  • In this case I have 2 observations:

    • Checking the code you provide I do not see how to calculate the percentage of progress so you should use the QProgressBar to indicate that there is a job running for it do not use a QBasicTimer but only use setRange(0, 0)
    class ProgBarWidget(QWidget):
        def __init__(self):
            super().__init__()
            self.initMe()
    
        def initMe(self):
            # basic window settings
            self.setGeometry(600, 300, 300, 80)
            self.setFixedSize(325, 80)
            self.setWindowTitle("Creating")
            self.setWindowFlag(Qt.WindowCloseButtonHint, False)
            self.setWindowFlag(Qt.WindowMinimizeButtonHint, False)
    
            horizontalLayout = QHBoxLayout(self)
            verticalLayout = QVBoxLayout()
    
            statusLabel = QLabel(self)
            statusLabel.setText("Creating virtual environment...")
    
            self.progressBar = QProgressBar(self)
            self.progressBar.setFixedSize(300, 23)
            self.progressBar.setRange(0, 0)
    
            verticalLayout.addWidget(statusLabel)
            verticalLayout.addWidget(self.progressBar)
    
            horizontalLayout.addLayout(verticalLayout)
            self.setLayout(horizontalLayout)
    
    • Observing a widget in black makes me assume that the create function consumes a lot of time so that task must be executed in another thread, but the GUI must not modify it from another thread directly but use signals to transmit the information, for that I implement a worker(QObject) that lives in another thread and that inform the beginning and end of the task that consumes a lot of time.
    from functools import partial
    from PyQt5.QtCore import QObject, QTimer, QThread, pyqtSignal, pyqtSlot
    
    # ...
    
    class InstallWorker(QObject):
        started = pyqtSignal()
        finished = pyqtSignal()
    
        @pyqtSlot(tuple)
        def install(self, args):
            self.started.emit()
            location, name, site_packages, symlinks, withPip = args
            create(
                "/".join([location, name]),
                system_site_packages=site_packages,
                symlinks=symlinks,
                with_pip=withPip,
            )
            self.finished.emit()
    
    # ...
    
    class InstallPackages(QWizardPage):
        def __init__(self):
            super().__init__()
    
            self.setTitle("Install Packages")
            self.setSubTitle("Specify the packages which you want Pip to "
                             "install into the virtual environment.")
    
            self.progressBar = ProgBarWidget()
    
            thread = QThread(self)
            thread.start()
            self.m_install_worker = InstallWorker()
            self.m_install_worker.moveToThread(thread)
            self.m_install_worker.started.connect(self.progressBar.show)
            self.m_install_worker.finished.connect(self.progressBar.close)
    
        def initializePage(self):
            # ...
    
            # run the create process
            self.createProcess()
    
        def createProcess(self):
            """
            Create the virtual environment.
            """
            args = (
                self.venvName,
                self.venvLocation,
                self.withPip,
                self.sysSitePkgs,
                self.symlinks,
            )
            wrapper = partial(self.m_install_worker.install, args)
            QTimer.singleShot(0, wrapper)