2022-05-01

【備忘録】PySide6 の QThread

PySide (Qt for Python) は、Qt(キュート)の Python バインディングで、GUI などを構築するためのクロスプラットフォームなライブラリです。Linux/X11, macOS および Microsoft Windows をサポートしています。配布ライセンスは LGPL で公開されています。

QThread を利用したスレッド処理のサンプルは、本ブログ記事で既に取り上げていますが [1]、使い出すと、あれもこれもと応用したくなるので、わかりやすいサンプルを用意して備忘録としてまとめました。

今回のお題

時間が掛かる処理でも GUI が固まらないアプリにするために QThread を利用する。

下記の OS 環境で動作確認をしています。

Fedora Linux 36 beta x86_64
Python 3.10.4
PySide6 6.3.0

ベースとするサンプル

サンプルでは、スレッド処理の部分をダミーで sleep 関数を使うというのもアリですが、具体的な例を扱いたかったので、ファイルをダウンロードするサンプルにしました。ある程度のサイズがないとダウンロードがすぐに終わってしまうので、160MB 程度の PLD Linux を固定でダウンロードすることにしました。ダウンロード元は KDDI 総合研究所の FTP サイトを利用させていただきました。

sample_download.py
#!/usr/bin/env python
# coding: utf-8
# Reference:
# https://stackoverflow.com/questions/1517616/stream-large-binary-files-with-urllib2-to-file
import shutil
import urllib.request
from benchmark import time_elapsed
@time_elapsed
def download(url):
filename = url[url.rfind('/') + 1:]
status = True
length = 16 * 1024
try:
req = urllib.request.urlopen(url)
with open(filename, 'wb') as fp:
try:
shutil.copyfileobj(req, fp, length)
except Exception as e:
print(e)
status = False
except OSError as e:
print(e)
status = False
return status
if __name__ == '__main__':
url = 'https://ftp.kddilabs.jp/Linux/distributions/PLD/iso/2.0/i386/pld-2.0-MINI.i386.iso'
result = download(url)
print(result)

ダウンロードに掛かる時間を把握したかったので、デコレータで計測しています。デコレータの中身は以下のとおりです。

benchmark.py
#!/usr/bin/env python
# coding: utf-8
import time
from functools import wraps
# Reference:
# https://qiita.com/hisatoshi/items/7354c76a4412dffc4fd7
def time_elapsed(func):
@wraps(func)
def wrapper(*args, **kargs):
start = time.time()
result = func(*args, **kargs)
elapsed = time.time() - start
print('{:s} function took {:.3f} sec'.format(func.__name__, elapsed))
return result
return wrapper
view raw benchmark.py hosted with ❤ by GitHub

さて sample_download.pyPyCharm 上で実行すると、プロジェクトのディレクトリ内に指定した URL のファイル (pld-2.0-MINI.i386.iso) がダウンロードされます。自分の使っている環境では 10 秒前後でダウンロードが完了します(ばらつきがあります)。

sample_download.py の実行例
/home/bitwalk/Projects/PySide6_sample/venv/bin/python /home/bitwalk/Projects/PySide6_sample/sample_download.py
download function took 8.409 sec
True

プロセスは終了コード 0 で終了しました

このサンプルを GUI 化します。

PySide6 による GUI サンプル

GUI 化するサンプルは、プッシュボタンをクリックするとファイルのダウンロードを開始して、ダウンロードの間に進捗ダイアログ (QProgressDialog) を表示して、ダウンロードが完了したらダイアログを閉じるというだけのシンプルなものです。

使用するファイルおよびクラス(関数)は下記の通りです。前述のダウンロード用の download 関数も利用します。

  • qt_thread.py (Example)
    • qt_thread_progress.py (EndlessProgressDialog)
    • qt_thread_download.py (URLDownload, URLDownloadWorker)
      • sample_download.py (download)

サンプルの動作

まずサンプルの動作を説明します。

サンプル (qt_thread.py) を実行すると Download large file と表示されたボタンが表示されます。

このボタンをクリックすると、進捗ダイアログが表示され、ダウンロードが始まります。

このダイアログはダウンロードの開始と完了をモニターしているだけで、途中の進捗はモニターしていません。そのため進捗 (progress) と言っても、進捗バーが延々と回っているだけの仕様にしています。また、ダウンロード中のキャンセル処理は考慮していません。

このダイアログを modal window にして、ダイアログが表示されている間は、メインのプッシュボタンのウィンドウを操作できないようにしています。

ダウンロードが完了すると、進捗ダイアログが閉じられ、メインのウィンドウの操作ができるようになります。

折角、時間が掛かる処理をスレッド化しているのですから、別スレッドで処理させている間に他の操作ができるようにすべきでしょうが、内容をできるだけシンプルにするために、このような面白くもないサンプルにしました。

qt_thread.py

GUI のメインウィンドウを作成するメインプログラムです。 QMainWindow クラスを継承しています。

qt_thread.py
#!/usr/bin/env python
# coding: utf-8
import sys
import PySide6
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QPushButton,
QStatusBar,
)
from qt_thread_download import URLDownload
from qt_thread_progress import EndlessProgressDialog
class Example(QMainWindow):
statusbar: QStatusBar = None
msec = 3000
obj = None
# sample file to download
url = 'https://ftp.kddilabs.jp/Linux/distributions/PLD/iso/2.0/i386/pld-2.0-MINI.i386.iso'
def __init__(self):
super().__init__()
self.init_ui()
self.setWindowTitle('QThread')
# PySide6 version
print('PySide', PySide6.__version__)
def init_ui(self):
# push button
button = QPushButton('Download large file')
button.clicked.connect(self.on_click)
button.setStatusTip('click to start downloading')
self.setCentralWidget(button)
# status bar
self.statusbar = QStatusBar()
self.statusbar.showMessage('Welcome!', self.msec)
self.setStatusBar(self.statusbar)
def on_click(self):
# show progress dialog
self.dlg = EndlessProgressDialog(self)
self.dlg.show()
# update status
self.statusbar.showMessage('downloading, ...')
# instance for download in thread
self.obj = URLDownload(self.url)
self.obj.completed.connect(self.download_finish)
self.obj.start()
def download_finish(self, success: bool):
print(success)
# stop and delete dialog
self.dlg.cancel()
# update status
self.statusbar.showMessage('finish downloading', self.msec)
def main():
app = QApplication(sys.argv)
hello = Example()
hello.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
view raw qt_thread.py hosted with ❤ by GitHub

プッシュボタンをクリックしたときの処理を on_click メソッドで定義しています。

ファイルをダウンロードする処理は URLDownload クラスのインスタンス self.obj が担い、start メソッドで他スレッドでダウンロード処理を行います。

    def on_click(self):
        # 進捗ダイアログの表示
        self.dlg = EndlessProgressDialog(self)
        self.dlg.show()
        # ステータスバーの表示を更新
        self.statusbar.showMessage('downloading, ...')
        # スレッドで処理する URLDownload クラスのインスタンスを作成し、start メソッドで起動
        self.obj = URLDownload(self.url)
        self.obj.completed.connect(self.download_finish)
        self.obj.start()

self.obj の処理が終了すると completed シグナルが emit されるので、それを受けて終了処理をするようにしておきます。

終了処理 (self.download_finish) では、completed シグナルが emit された時の引数の値(この例では bool 型の変数)を受け取り、その内容を表示、ダイアログの終了やステータスバーに表示するメッセージを更新しています。

    def download_finish(self, success: bool):
        # ダウンロードの成否を標準出力
        print(success)
        # 進捗ダイアログを終了
        self.dlg.cancel()
        # ステータスバーの表示を更新
        self.statusbar.showMessage('finish downloading', self.msec)

qt_thread_progress.py

進捗ダイアログでありながら、詳細な進捗を表示しない、キャンセルボタンを表示しない、modal window にしてメインのウィンドウでの操作をさせない、という進捗ダイアログらしからぬ EndlessProgressDialog クラスを、QProgressDialog を継承して定義しています。

qt_thread_progress.py
#!/usr/bin/env python
# coding: utf-8
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QProgressDialog
class EndlessProgressDialog(QProgressDialog):
def __init__(self, parent):
super().__init__(labelText='Working...', parent=parent)
self.setWindowModality(Qt.WindowModal)
self.setCancelButton(None)
self.setRange(0, 0)
self.setWindowTitle('in progress')

qt_thread_download.py

別スレッドでファイルをダウンロードするクラスを二つ定義しています。

qt_thread_download.py
#!/usr/bin/env python
# coding: utf-8
from PySide6.QtCore import (
QObject,
QThread,
Signal,
)
from sample_download import download
class URLDownload(QObject):
completed = Signal(bool)
def __init__(self, url):
super().__init__()
self.thread = QThread()
self.worker = URLDownloadWorker(url)
def start(self):
# move this instance to other thread
self.worker.moveToThread(self.thread)
# event handling
self.thread.started.connect(self.worker.run)
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.worker.downloadCompleted.connect(self.end)
# start the thread which starts self.worker.run
self.thread.start()
def end(self, success: bool):
self.completed.emit(success)
class URLDownloadWorker(QObject):
downloadCompleted = Signal(bool)
finished = Signal()
def __init__(self, url):
super().__init__()
self.url = url
def run(self):
status = download(self.url)
self.downloadCompleted.emit(status)
self.finished.emit()

URLDownload

QObject を継承した URLDownload クラスは、ダウンロードに関わるスレッドの処理をします。すわわち、ダウンロード処理をするインスタンスの処理を、別スレッドへ移す処理をしています。

ます、コンストラクタで、別スレッド(QThread クラス)のインスタンス self.thread とダウンロード処理(URLDownloadWorker クラス)をするインスタンスを、ダウンロード元の情報 (url) を引数にして定義します。

    def __init__(self, url):
        super().__init__()
        self.thread = QThread()
        self.worker = URLDownloadWorker(url)

QThread は、start()(スロットと呼ばれる、connect に紐づくメソッド)を実行すると、スレッドのインスタンスの run() を呼び出して、スレッドの実行を開始します。

そのため、URLDownload クラスも start() メソッドを用意して、その中で、まず QThread のインスタンスの start() を実行するための準備をしてから、スレッドの実行を開始します。

    def start(self):
        # スレッド処理するインスタンスを、別スレッドに移動
        self.worker.moveToThread(self.thread)
        # スロットの処理
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)
        self.worker.downloadCompleted.connect(self.end)
        # run() を呼び出して、スレッドの実行を開始
        self.thread.start()

ダウンロードを処理するインスタンス self.worker を、moveToThread(self.thread) で別スレッドへ移します。

その上で、別スレッドの実行が開始されたとき (started) に self.worker.run を実行すること、処理が終わったとき (finished) の処理(インスタンスの破棄を含む)を定義し、さらに、カスタム処理(この場合、self.worker.downloadCompleted))を定義します。

最後に self.thread.start() を実行して、別スレッドでの処理を始めます。

end メソッドではself.worker.downloadCompleted) に対応する処理を定義しています。と言っても、スレッド処理の完了時の結果を、completed シグナルを emit して、呼び出し元に引き渡すだけの処理です。

    def end(self, success: bool):
        self.completed.emit(success)

URLDownloadWorker

URLDownloadWorker クラスは、ダウンロード処理をします。シグナルとスロットでスレッド間のやりとりをするため、QObject を継承してます。

シグナルは、下記のように2種類、クラス変数で用意しておきます。

    downloadCompleted = Signal(bool)
    finished = Signal()

run メソッドでは、冒頭で紹介したダウンロード用の download 関数を実行後、self.downloadCompleted シグナルを emit してダウンロードの成否を返して、終了の self.finished シグナルを emit しています。

    def run(self):
        status = download(self.url)
        self.downloadCompleted.emit(status)
        self.finished.emit()

シグナルを一応、self.downloadCompletedself.finished に分けてはいますが、まとめられるかもしれません。

まとめ

PySide6 を利用した GUI プログラミングで、スレッド化するために必要な最低限の処理をまとめました。そのため、スレッド処理を中断する場合などは、ここでは全然考慮していません。この記事を書いているときに、もっと詳しく説明されているサイト [2] を見つけましたので、ご興味のある方は、参考にされてはいかがでしょうか。

参考サイト

  1. bitWalk's: Matplotlib と QThread [2022-03-05]
  2. [PySide] QThread を使って時間のかかる処理をスレッド化する - へっぽこプログラマーの備忘録 [2022-02-20]

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ Linuxへ
にほんブログ村

0 件のコメント: