Edited at

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

More than 1 year has 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を継承するのがキモになります。


本日のコード