Help us understand the problem. What is going on with this article?

Python + PyQt5でGUIアプリを作ってみた

More than 3 years have passed since last update.

pythonでGUIで操作するアプリを作ってみました。その時のことを、簡単にまとめてみます。

仕様:特定の拡張子のファイルのリストを作成し、1件ずつ処理し、その進捗を表示する

例えば、特定フォルダ以下にあるPythonファイルを開いて、1件ずつ処理して、○○件中××件目を実行中と表示するようなイメージです。

特定の拡張子のファイルを集めるクラスを作る

まずは、特定の拡張子のファイルを集めてリストを作るクラスFileListを作ります。

filelist.py
import os


class FileList():
    ''' store file list'''
    def __init__(self, root_dir, ext):
        self.root_dir = root_dir
        self.ext = ext
        self.files = []

    def retrieve(self):
        for rd, _, fl in os.walk(self.root_dir):
            for f in fl:
                _, fext = os.path.splitext(f)
                if fext == self.ext:
                    self.files.append(os.path.join(rd, f))

    def print(self):
        for f in self.files:
            print(f)

残念なほど簡単なクラスです。

試しに実行してみます。

20170625001.png

うん、多分、正しい。

GUIの設計

GUIを設計します。必要な要素は、以下のようになります。

  1. 対象フォルダを入力するパーツ
  2. 実行を指示するためのパーツ
  3. 進捗を表示するパーツ

対象フォルダを入力するためのパーツとは、「フォルダ参照」ボタンと、指定したフォルダを表示するテキストボックスがあれば良いでしょう。

実行を指示するためのパーツは、「実行」ボタンがあれば良いでしょう。

進捗を表示するパーツは、テキストボックスに処理中のファイル名を表示して、プログレスバーに全体の進捗割合を表示するようにします。

20170625002.png

このパーツ配置について、適切なGUI設計ツールを使うと簡単にできるんじゃないかと思うのですが、とりあえず、地道に手で書きます。

基本的には、First programs in PyQt5 から丸パクリします。

guimain.py
import sys
import os
from PyQt5.QtWidgets import (QWidget, QApplication, 
                             QPushButton, QLineEdit,
                             QHBoxLayout, QVBoxLayout, 
                             QTextEdit, QProgressBar,
                             QFileDialog)
from PyQt5.QtCore import Qt


class MainWidget(QWidget):
    dirname = ''
    step = 0

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

    def initUI(self):
        self.resize(480, 360)

        self.txtFolder = QLineEdit()
        self.btnFolder = QPushButton('参照...')

        hb1 = QHBoxLayout()
        hb1.addWidget(self.txtFolder)
        hb1.addWidget(self.btnFolder)

        self.btnExec = QPushButton('実行')
        self.btnExec.setEnabled(False)

        hb2 = QHBoxLayout()
        hb2.addWidget(self.btnExec)

        self.txtLog = QTextEdit()

        self.pbar = QProgressBar()
        self.pbar.setTextVisible(False)

        layout = QVBoxLayout()
        layout.addLayout(hb1)
        layout.addLayout(hb2)
        layout.addWidget(self.txtLog)
        layout.addWidget(self.pbar)
        self.setLayout(layout)

        self.setWindowTitle('PyQt5 Sample')


def main(args):
    app = QApplication(args)
    dialog = MainWidget()
    dialog.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main(sys.argv)

20170626001.png

だいたい、想定どおりな感じです。

実装

「参照...」ボタン(btnFolder)の機能

btnFolderのclickedイベントとして定義します。

self.btnFolder.clicked.connect(self.show_folder_dialog)
def show_folder_dialog(self):
    ''' open dialog and set to foldername '''
    dirname = QFileDialog.getExistingDirectory(self,
                                               'open folder',
                                               os.path.expanduser('.'),
                                               QFileDialog.ShowDirsOnly)
    if dirname:
        self.dirname = dirname.replace('/', os.sep)
        self.txtFolder.setText(self.dirname)
        self.btnExec.setEnabled(True)
        self.step = 0

フォルダを指定するダイアログを開いて、「実行」ボタンを有効化します。

「実行」ボタン(btnExec)の機能

指定したフォルダ以下の、「.py」ファイルを集めて、txtLogにファイル名を表示します。
また、実行中の進捗をプログレスバーで表現します。

FileListクラスをimportする

guimain.py
from filelist import FileList

FileList class のretrieve() メソッドを実行する

FileList classを当初のバージョンから少し修正します。

filelist.py
class FileList():
    ''' store file list'''
    def __init__(self):
        self.root_dir = ''
        self.ext = ''
        self.files = []

    def setup(self, root_dir, ext):
        self.root_dir = root_dir
        self.ext = ext
        self.retrieve()

    def retrieve(self):
        self.files = []
        for rd, _, fl in os.walk(self.root_dir):
            for f in fl:
                _, fext = os.path.splitext(f)
                if fext == self.ext:
                    self.files.append(os.path.join(rd, f))

    def print(self):
        for f in self.files:
            print(f)

init() で、 root_dirext を指定していましたが、それを新たに定義した setup() メソッドに移します。

QThreadからの継承に

GUIのプログラムでは、GUIのアレコレがmutli-threadで動作しているので、ファイルをアレコレする作業自体もmulti-threadで動作するように、FileList classをQThreadからの継承にします。

また、1件の処理ごとに、プログレスバーを動かすことができるように、シグナルを送るための機能も実装します。

本来ならば、process_file()の中で、例えば画像ファイルであればなんらかの編集をする等するのですが、このsample中ではソレは本筋ではないので、ひとまず何もしません。

filelist.py
import os
import sys
from PyQt5.QtCore import pyqtSignal, QMutexLocker, QMutex, QThread


class FileList(QThread):
    ''' store file list'''

    sig_file = pyqtSignal(str)

    def __init__(self, parent=None):
        super(FileList, self).__init__(parent)
        self.stopped = False
        self.mutex = QMutex()

    def setup(self, root_dir, ext):
        self.root_dir = root_dir
        self.ext = ext
        self.retrieve()
        self.stopped = False

    def stop(self):
        with QMutexLocker(self.mutex):
            self.stopped = True

    def run(self):
        for f in self.files:
            fname = f
            self.process_file(fname)
            self.sig_file.emit(fname)   # sginal送信
        self.stop()
        self.finished.emit()        # signal送信

    def retrieve(self):
        ''' root_dirからext拡張子を持つファイルを取得する '''
        self.files = []
        for rd, _, fl in os.walk(self.root_dir):
            for f in fl:
                _, fext = os.path.splitext(f)
                if fext == self.ext:
                    self.files.append(os.path.join(rd, f))
        self.length = len(self.files)

    def process_file(self, path):
        ''' ひとまず何もしない '''
        cnt = 0
        if os.path.exists(path):
            cnt += 1
        else:
            cnt = 0

    def print(self):
        for f in self.files:
            print(f)


def main(args):
    root_dir = '.'
    ext = '.py'
    if len(args) == 3:
        root_dir = args[1]
        ext = args[2]
    fileList = FileList()
    fileList.setup(root_dir, ext)
    fileList.print()

if __name__ == '__main__':
    main(sys.argv)

このFileList classを参照して、ボタンを押したら、指定したフォルダを走査して、その過程を表示するguimain.pyは以下のようになります。

guimain.py
import sys
import os
from PyQt5.QtWidgets import (QWidget, QApplication, QPushButton, QLineEdit,
                             QHBoxLayout, QVBoxLayout, QTextEdit, QProgressBar,
                             QFileDialog)
from PyQt5.QtCore import pyqtSlot, Qt
from filelist import FileList


class MainWidget(QWidget):
    dirname = ''
    step = 0

    def __init__(self, parent=None):
        super(MainWidget, self).__init__(parent)
        self.initUI()
        self.fileList = FileList()
        self.fileList.sig_file.connect(self.update_status)
        self.fileList.finished.connect(self.finish_process)

    def initUI(self):
        self.resize(480, 360)

        self.txtFolder = QLineEdit()
        self.txtFolder.setReadOnly(True)
        self.btnFolder = QPushButton('参照...')
        self.btnFolder.clicked.connect(self.show_folder_dialog)
        hb1 = QHBoxLayout()
        hb1.addWidget(self.txtFolder)
        hb1.addWidget(self.btnFolder)

        self.btnExec = QPushButton('実行')
        self.btnExec.clicked.connect(self.exec_process)
        self.btnExec.setEnabled(False)
        self.btnExec.setVisible(True)

        self.btnExit = QPushButton('終了')
        self.btnExit.setVisible(False)  # 無効化
        self.btnExit.setEnabled(False)  # 表示しない
        self.btnExit.clicked.connect(self.close)

        hb2 = QHBoxLayout()
        hb2.addWidget(self.btnExec)
        hb2.addWidget(self.btnExit)     # 初期状態で不可視のボタンを追加

        self.txtLog = QTextEdit()
        self.txtLog.setReadOnly(True)

        self.pbar = QProgressBar()
        self.pbar.setTextVisible(False)

        layout = QVBoxLayout()
        layout.addLayout(hb1)
        layout.addLayout(hb2)
        layout.addWidget(self.txtLog)
        layout.addWidget(self.pbar)
        self.setLayout(layout)

        self.setWindowTitle('PyQt5 Sample')

    def show_folder_dialog(self):
        ''' open dialog and set to foldername '''
        dirname = QFileDialog.getExistingDirectory(self,
                                                   'open folder',
                                                   os.path.expanduser('.'),
                                                   QFileDialog.ShowDirsOnly)
        if dirname:
            self.dirname = dirname.replace('/', os.sep) # ディレクトリの区切りをOSに合わせて変換しておく
            self.txtFolder.setText(self.dirname)
            self.btnExec.setEnabled(True)
            self.step = 0

    def print_log(self, logstr):
        self.txtLog.append(logstr)

    @pyqtSlot()
    def exec_process(self):
        if os.path.exists(self.dirname):
            try:
                QApplication.setOverrideCursor(Qt.WaitCursor)
                self.fileList.setup(self.dirname, '.py')
                maxCnt = self.fileList.length
                self.pbar.setValue(0)
                self.pbar.setMinimum(0)
                self.pbar.setMaximum(maxCnt)
                self.fileList.start()
            except Exception as e:
                self.print_log(str(e))
            finally:
                QApplication.restoreOverrideCursor()
        else:
            self.print_log('{0} is not exists'.format(self.dirname))

    @pyqtSlot(str)
    def update_status(self, filename):
        self.txtLog.append(filename)
        self.step += 1
        self.pbar.setValue(self.step)   # progressBarを進める

    @pyqtSlot()
    def finish_process(self):
        self.fileList.wait()
        # 実行ボタンを隠す
        self.btnExec.setEnabled(False)
        self.btnExec.setVisible(False)
        # 終了ボタンを表示する
        self.btnExit.setEnabled(True)
        self.btnExit.setVisible(True)

def main(args):
    app = QApplication(args)
    dialog = MainWidget()
    dialog.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main(sys.argv)

この後半の急加速っぷり

なんか面倒になってきたので、以上のように書くと、とりあえず動きますよって感じです。

FileList.run()の中で、1件ずつ self.sig_file.emit(fname) することで、シグナルが送られ、それをguimain.update_status()で受けることで、プログレスバーを一つずつ進められます。


まとめ

PythonでGUIアプリを書いてみました。QThreadを継承するのがキモになります。

本日のコード

fukuit
最近、事務系の職場に異動したので、職業プログラマではなくなりました。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした