Youssef
Youssef

Reputation: 1495

Show realtime output of subprocess in a QDialog

I have a script that activates a virtual environment and runs pip commands inside it. For that I first create a bash script with the needed commands and pass the final command (which finally runs the script) to run_script() which produces an output line by line. The subprocess works fine as well as printing the output to a console.

Now, what I'm trying to achieve is to display the catched realtime output (that comes line by line) of run_script() (which shows the installation progress of pip install ...) together with a QProgressBar in a QDialog.

So far, I tried to set the text of self.statusLabel in ProgBarDialog class, but that doesn't work as expected. I thought I could create a loop similar to this

for line in output:
    self.statusLabel.setText(line)

and show each line of the process output after the other. But I don't know how exactly to catch each line from the output since the output comes as a big string and because of that, of course for line in output catches the characters and not the lines.

How do I have to manipulate the output to format it the correct way to be able to show it in a widget (for example a QLabel or something similar) inside a QDialog?

(it might be that the way I coded this is stupid or inefficient, so any suggests are welcome)


Minimal reproducible example:

Note: A virtual environment beside testfile.py is needed to reproduce.

testfile.py

from subprocess import Popen, PIPE
import sys
import os

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QApplication, QDialog, QVBoxLayout,
                             QHBoxLayout, QLabel, QProgressBar)


def has_bash():
    """
    Test if bash is available. If present the string `/bin/bash` is returned,
    an empty string otherwise.
    """
    res = Popen(
        ["which", "bash"], stdout=PIPE, stderr=PIPE, text="utf-8"
    )
    out, _ = res.communicate()
    shell = out.strip()
    return shell

def run_script(command):
    """
    Run the script and catch output of the subprocess line by line.
    The `command` argument is set in `run_pip()`.
    """
    process = Popen(command, stdout=PIPE, text="utf-8")

    while True:
        output = process.stdout.readline()
        if output == "" and process.poll() is not None:
            break
        if output:
            # TODO: show output in dialog together with a progressbar
            print(f"[PIP]: {output.strip()}")
    rc = process.poll()
    return rc

def run_pip(cmd, opt, package, venv_dir, venv_name):
    """
    Activate the virtual environment and run pip commands.
    """
    current_dir = os.path.dirname(os.path.realpath(__file__))
    script = os.path.join(current_dir, "run.sh")

    if has_bash():
        # create run script
        with open(script, "w") as f:
            f.write(
                "#!/bin/bash\n"
                f"source {venv_dir}/{venv_name}/bin/activate\n"
                f"pip {cmd}{opt}{package}\n"
                "deactivate\n"
            )
        # make it executable
        os.system(f"chmod +x {script}")
        # run script
        command = ["/bin/bash", script]
        run_script(command)


class ProgBarDialog(QDialog):
    """
    Dialog showing output and a progress bar during the installation process.
    """
    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setGeometry(675, 365, 325, 80)
        self.setFixedSize(350, 85)
        self.setWindowFlag(Qt.WindowCloseButtonHint, False)
        self.setWindowFlag(Qt.WindowMinimizeButtonHint, False)

        h_Layout = QHBoxLayout(self)
        v_Layout = QVBoxLayout()
        h_Layout.setContentsMargins(0, 15, 0, 0)

        self.statusLabel = QLabel(self)
        self.placeHolder = QLabel(self)

        self.progressBar = QProgressBar(self)
        self.progressBar.setFixedSize(325, 23)
        self.progressBar.setRange(0, 0)

        v_Layout.addWidget(self.statusLabel)
        v_Layout.addWidget(self.progressBar)
        v_Layout.addWidget(self.placeHolder)

        h_Layout.addLayout(v_Layout)
        self.setLayout(h_Layout)



if __name__ == "__main__":

    cmd = ["install "]
    opt = ["--upgrade "]
    package = "pylint"  # this could be any package
    current_dir = os.path.dirname(os.path.realpath(__file__))
    venv_name = "testenv"  # a virtual env beside this test file

    run_pip(cmd[0], opt[0], package, current_dir, venv_name)

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

    app = QApplication(sys.argv)
    progBar = ProgBarDialog()
    progBar.show()
    sys.exit(app.exec_())

Upvotes: 2

Views: 937

Answers (1)

eyllanesc
eyllanesc

Reputation: 243955

In this case it is better to use QProcess since it does not block the eventloop and notifies you through a signal when there is a new output:

import os

from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QProcess, Qt
from PyQt5.QtGui import QFontMetrics
from PyQt5.QtWidgets import QApplication, QDialog, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout


def has_bash():
    process = QProcess()
    process.start("which bash")
    process.waitForStarted()
    process.waitForFinished()
    if process.exitStatus() == QProcess.NormalExit:
        return bool(process.readAll())
    return False


class PipManager(QObject):
    started = pyqtSignal()
    finished = pyqtSignal()
    textChanged = pyqtSignal(str)

    def __init__(self, venv_dir, venv_name, parent=None):
        super().__init__(parent)

        self._venv_dir = venv_dir
        self._venv_name = venv_name

        self._process = QProcess(self)
        self._process.readyReadStandardError.connect(self.onReadyReadStandardError)
        self._process.readyReadStandardOutput.connect(self.onReadyReadStandardOutput)
        self._process.stateChanged.connect(self.onStateChanged)
        self._process.started.connect(self.started)
        self._process.finished.connect(self.finished)
        self._process.finished.connect(self.onFinished)
        self._process.setWorkingDirectory(venv_dir)

    def run_command(self, command="", options=None):
        if has_bash():
            if options is None:
                options = []
            script = f"""source {self._venv_name}/bin/activate; pip {command} {" ".join(options)}; deactivate;"""
            self._process.start("bash", ["-c", script])

    @pyqtSlot(QProcess.ProcessState)
    def onStateChanged(self, state):
        if state == QProcess.NotRunning:
            print("not running")
        elif state == QProcess.Starting:
            print("starting")
        elif state == QProcess.Running:
            print("running")

    @pyqtSlot(int, QProcess.ExitStatus)
    def onFinished(self, exitCode, exitStatus):
        print(exitCode, exitStatus)

    @pyqtSlot()
    def onReadyReadStandardError(self):
        message = self._process.readAllStandardError().data().decode().strip()
        print("error:", message)
        self.finished.emit()
        self._process.kill()
        """self.textChanged.emit(message)"""

    @pyqtSlot()
    def onReadyReadStandardOutput(self):
        message = self._process.readAllStandardOutput().data().decode().strip()
        self.textChanged.emit(message)


class ProgBarDialog(QDialog):
    """
    Dialog showing output and a progress bar during the installation process.
    """

    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.setFixedWidth(400)
        self.setWindowFlag(Qt.WindowCloseButtonHint, False)
        self.setWindowFlag(Qt.WindowMinimizeButtonHint, False)

        self.statusLabel = QLabel()
        self.placeHolder = QLabel()

        self.progressBar = QProgressBar()
        self.progressBar.setFixedHeight(23)
        self.progressBar.setRange(0, 0)

        v_Layout = QVBoxLayout(self)
        v_Layout.addWidget(self.statusLabel)
        v_Layout.addWidget(self.progressBar)
        v_Layout.addWidget(self.placeHolder)

    @pyqtSlot(str)
    def update_status(self, status):
        metrix = QFontMetrics(self.statusLabel.font())
        clippedText = metrix.elidedText(status, Qt.ElideRight, self.statusLabel.width())
        self.statusLabel.setText(clippedText)


if __name__ == "__main__":
    import sys

    app = QApplication(sys.argv)

    progBar = ProgBarDialog()

    current_dir = os.path.dirname(os.path.realpath(__file__))
    venv_name = "testenv"

    manager = PipManager(current_dir, venv_name)
    manager.textChanged.connect(progBar.update_status)
    manager.started.connect(progBar.show)
    manager.finished.connect(progBar.close)

    manager.run_command("install", ["--upgrade", "pylint"])

    sys.exit(app.exec_())

Upvotes: 4

Related Questions