ソースに絡まるエスカルゴ

貧弱プログラマの外部記憶装置です。

【python/PySide6】printの内容をGUIに表示させる(PySide6でのバックグラウンド処理)

 以前PySide6を導入して簡単なGUIを作る記事とprintの内容をファイルに出力する方法の記事を書きました。


 今回はこれらの応用としてPySide6を使ってprintの内容をGUIに表示させる方法の備忘録になります。


 では、始めます。


1:PySide6でバックグラウンド処理を行う
 PySide6で何かの処理中に画面を随時更新する場合にはバックグラウンド処理を行う必要があるので、まずはその説明をします。

 基本的にはバックグラウンド処理したいクラスにSignalのスレッドを定義し、emit関数を使ってSignalの値を呼び出し側へ渡します。そして呼び出し元でそのSignalの値を取り出して処理していくという流れです。

 ちゃんと書くとそこそこ長くなるので、以下に要点だけを抽出した簡単なソースコードを記述しておきます。このソースコード単体では動かないので注意してください。

# メイン処理側
# スレッドクラスの準備
sub_thread = SubThread()
sub_thread.connect(get_signal)  # シグナルの接続
sub_thread.finished.connect(finish_thread) # スレッドが終了した際の処理の接続

def get_signal(signal_str):
    # シグナルを受け取った時の処理
    print(signal_str)

def finish_thread():
    # バックグラウンド処理が終わった時の処理
    print("finished")


# バックグラウンド処理側(Signalを送る側)
class SubThread(QThread):
    sub_signal = Signal(str) # Signalでstr型を送る

    def __init__(self, parent=None):
      QThread.__init__(self, parent)

    def send_signal(self):
      self.sub_signal.emit("test") # Signalで送信

 メイン側でバックグラウンド処理のクラスのオブジェクトを作成し、そこに「connect」関数でシグナルを受け取る処理の関数を設定します。「finished.connect」で処理が終了した後の処理も設定できます。
 そしてバックグラウンド処理のクラスではSignalをクラス変数として定義し、「emit」関数を設定することで値を渡すことができます。

 これでバックグラウンド処理がわかったので、この方法を使ってprintの内容をGUIに表示させるコードを書いていきます。


2:printの内容をGUIに表示させるサンプル
 今回はループでprintを行うpythonファイルをGUIのファイルから呼び出すという形でサンプルを作成しました。

 ループでprintを行うファイルは以下の通りです。

・print_log.py

#-*- coding:utf-8 -*-
import sys
from time import sleep

def count_up(num, count):
    print("--- start ----")

    for i in range(0, count):
        print(num + i)
        sleep(0.5)

    print("--- end ----")

if __name__ == '__main__':
    count_up(1, 10)


 GUIのファイルは以下の通りです。

gui_test.py

#-*- coding:utf-8 -*-
import sys, threading, re
from copy import deepcopy
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *

import print_log


class Form(QDialog):
    """ GUIクラス
    """

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

        # Widgetsの設定(タイトル、固定横幅、固定縦幅)
        self.setWindowTitle("Title test")
        self.setFixedWidth(400)
        self.setFixedHeight(260)

        # num入力部分
        num_layout = QHBoxLayout()
        self.num_edit = QLineEdit("") # テキスト入力
        num_layout.addWidget(QLabel("num :"), 1)
        num_layout.addWidget(self.num_edit, 4)

        # count入力部分
        count_layout = QHBoxLayout()
        self.count_edit = QLineEdit("") # テキスト入力
        count_layout.addWidget(QLabel("count :"), 1)
        count_layout.addWidget(self.count_edit, 4)

        # ログ表示部分
        self.text_layout = QHBoxLayout()
        self.textbox = QListView()
        self.text_list = QStringListModel()
        self.textbox.setModel(self.text_list)
        self.text_layout.addWidget(self.textbox)

       # プログレスバー部分
        pb_layput = QHBoxLayout()
        self.pb = QProgressBar()
        self.pb.setFixedWidth(370)
        self.pb.setTextVisible(False)
        pb_layput.addWidget(self.pb)

        # ボタン部分
        run_layout = QHBoxLayout()
        self.run_button = QPushButton("start")
        self.run_button.clicked.connect(self.run_log)
        run_layout.addWidget(QLabel(""), 2)
        run_layout.addWidget(self.run_button, 1)
        run_layout.addWidget(QLabel(""), 2)

        # レイアウトを作成して各要素を配置
        layout = QVBoxLayout()
        layout.addLayout(num_layout)
        layout.addLayout(count_layout)
        layout.addLayout(self.text_layout)
        layout.addLayout(pb_layput)
        layout.addLayout(run_layout)

        # レイアウトを画面に設定
        self.setLayout(layout)

        # ログスレッドクラスの準備
        self.lp = LogThread()
        self.lp.log_thread.connect(self.show_log)  # シグナルスロットの接続
        self.lp.finished.connect(self.show_result) # スレッドが終了した際の処理の接続


    def run_log(self):
        # ログプロセスを実行する
        num = self.num_edit.text()
        count = self.count_edit.text()

        if self.is_number(num) and self.is_number(count):
            # GUIを非活性にする
            self.set_all_enabled(False)
            # プログレスバーの開始
            self.pb.setMinimum(0)
            self.pb.setMaximum(0)
            # 値を設定
            self.lp.set_count(int(num), int(count))
            # ログプロセスを実行する
            self.lp.start()
        else:
            # 入力値エラーとしてダイアログ表示
            QMessageBox.warning(self, "注意", "numとcountには正の半角整数を入力してください。")


    def is_number(self, number):
        # 正規表現で数字だった場合はTrue/そうでない場合はFalse
        if re.fullmatch(r"[0-9]+", number) is None:
            return False
        else:
            return True


    def show_log(self, log):
        # 書き込み中の進捗をGUIに表示する
        log_list = self.text_list.stringList()
        log_list.append(str(log))
        self.text_list.setStringList(log_list)
        self.textbox.scrollToBottom()


    def show_result(self):
        # 結果を表示する
        QMessageBox.information(self, "終了", "終了しました。")
        # プログレスバーの停止
        self.pb.setMinimum(0)
        self.pb.setMaximum(100)
        self.set_all_enabled(True) # GUIの表示を戻す


    def set_all_enabled(self, flg):
        # GUIの有効/無効を設定する
        self.num_edit.setEnabled(flg)
        self.count_edit.setEnabled(flg)
        self.run_button.setEnabled(flg)


class LogThread(QThread):
    """ ログファイルを読み取るクラス
    """
    log_thread = Signal(str)
    log_file_path = "./log.txt"
    read_flg = False
    num = 0
    count = 0


    def __init__(self, parent=None):
        """ コンストラクタ
        """
        QThread.__init__(self, parent)


    def __del__(self):
        # Threadオブジェクトが削除されたときにThreadを停止
        self.wait()


    def set_count(self, num, count):
        self.num = num
        self.count = count


    def run(self):
        # 標準出力の出力先をファイルにする
        sys.stdout = open(self.log_file_path, "w")

        # ログファイルを読み取るスレッドを開始
        self.read_flg = True
        read_thread = threading.Thread(target=self.read_log)
        read_thread.setDaemon(True)
        read_thread.start()

        try:
            # 別ファイルのprintする処理を開始
            print_log.count_up(self.num, self.count)
        except Exception as e:
            print(e)
        finally:
            # 標準出力を元に戻す
            sys.stdout.close()
            sys.stdout = sys.__stdout__
            self.read_flg = False


    def read_log(self):
        # 書き込み時のログファイルを読込み差分の行をSignalのemitに設定する関数
        old_lines = list()
        new_lines = list()

        while self.read_flg:
            with open(self.log_file_path, "r", encoding="utf-8") as f:
                sys.stdout.flush() # このflushの記述がないと処理途中でログ出力されない

                # listなので書き換えられないようdeepcopy
                new_lines = deepcopy(f.readlines())
                old_size = len(old_lines)
                new_size = len(new_lines)

                if  old_size < new_size:
                    # 差分の行をSignalで値を渡す
                    for i in range(old_size, new_size):
                        self.log_thread.emit(new_lines[i].replace("\n",""))
                    old_lines = deepcopy(new_lines)


if __name__ == '__main__':
    # Qtアプリケーションの作成
    app = QApplication(sys.argv)

    # フォームを作成して表示
    form = Form()
    form.show()

    # 画面表示のためのループ
    sys.exit(app.exec())

 上記2ファイルを以下のように同階層に配置します。

 gui_test.pyの方を実行すると以下のようにGUIが表示されるのでnumとcountのところに半角数字を入力します。

 startボタンを押下して実行すると画面が非活性になりリアルタイムでログの内容が表示され、処理が終わると終了ダイアログが表示されます。


 詳しくはソースコードを読んでいただきたいのですが、少し解説すると、ログの書き出しを行っている時にログファイルを開いてその行数を取得し、以前の行数と差分があれば差分の行を1行ずつSignalで送っているという処理を行っています(read_log関数)。

 重要なのが「sys.stdout.flush() 」という一文で、これをどこかに書いていないとprintの内容がリアルタイムでGUIに反映されず、処理が終わった後にまとめてGUIに反映されるという動きになります。
 この理由は、printのみではバッファに溜まり続けるだけで出力されないため、flushでバッファの内容を出力してあげる必要があるからです。


 以上がPySide6を使ってprintの内容をGUIに表示させる方法になります。

 Signalを使うのが初めてだったりflushの必要性を知らなかったりと割と手こずりましたが、GUIのバックグラウンド処理やリアルタイムに反映する記述方法がわかったので色々と楽しいことに使えそうです。


・参考資料