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

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](https://qiita-image-store.s3.amazonaws.com/0/15553/06d209c6-5ba8-de1c-01ea-5df9d32d5983.png "20170625001.png")

うん、多分、正しい。

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

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

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

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

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

![20170625002.png](https://qiita-image-store.s3.amazonaws.com/0/15553/9757c24e-cbb9-2aac-e9f1-4002b1d539cc.png "20170625002.png")

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

基本的には、[First programs in PyQt5](http://zetcode.com/gui/pyqt5/firstprograms/) から丸パクリします。

```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](https://qiita-image-store.s3.amazonaws.com/0/15553/b36d1dc4-f5b0-8aea-291a-ce72a4c1900a.png "20170626001.png")

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

# 実装
## 「参照...」ボタン(btnFolder)の機能
btnFolderのclickedイベントとして定義します。

```py
self.btnFolder.clicked.connect(self.show_folder_dialog)
```

```py
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_dir ``` と ``` ext ``` を指定していましたが、それを新たに定義した ``` 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を継承するのがキモになります。

## 本日のコード

- [filelist.py](https://github.com/fukuit/Python_SelfLearning/blob/master/pyqt5/filelist.py)
- [guimain.py](https://github.com/fukuit/Python_SelfLearning/blob/master/pyqt5/guimain.py)