Pythonで外部プロセスをバックグラウンド実行しつつ、出力をGTK+のテキストビューにリアルタイム表示する
「マルチスレッド処理で外部プロセスをバックグラウンド実行する」では外部プロセスをバックグラウンド実行しながら出力を端末上にそのまま表示していたが、ここではPyGTKを用いてGUIの複数行テキスト入力欄(テキストビュー)に表示する。
問題とその解決法
外部プロセスの出力をテキストビューに表示する上での問題としては
- 外部プロセスが終了するまでGUIのウィンドウが固まる
- 出力の一番最後の部分を常に表示するようにしたい
ということがあげられるが、PyGTKのFAQに参考になるページが見つかり、同じ要領でsubprocessを使用して書いたところ、意図した通りに動作するものが作れた。
メモとしては
- 外部プロセスの実行部分は別のスレッドで実行
- gtk.main_quit()より先に、関数gtk.gdk.threads_init()を呼ぶ
- 外部プロセスを実行するスレッド側において、描画を伴う処理*1があるところで、前に関数gtk.gdk.threads_enter()を、後ろにgtk.gdk.threads_leave()を呼ぶ
- 自動スクロールに関しては、gtk.TextViewのget_end_iter()、gtk.TextBuffer*2のplace_cursor()とinsert()、gtk.TextViewのscroll_to_mark()という流れで記述
となるが、コードの例のほうが分かりやすい。
コード例
[任意]ファイル名: subprocessgui.py
#! /usr/bin/python # -*- encoding: utf-8 -*- import subprocess import threading import signal import sys import os try: import pygtk pygtk.require('2.0') except: pass try: import gtk import pango except: sys.exit(1) class MainWindow(gtk.Window): """ メインウィンドウ """ def __init__(self, *args, **kwargs): gtk.Window.__init__(self, *args, **kwargs) # ウィンドウ self.set_size_request(600, 360) self.set_title('subprocess GUI test') # ショートカットキー(アクセラレータ) self.accelgroup = gtk.AccelGroup() self.add_accel_group(self.accelgroup) # メニューバー項目 self.menuitem = gtk.MenuItem('_File', True) self.imagemenuitem = gtk.ImageMenuItem(gtk.STOCK_QUIT, self.accelgroup) self.gtkmenu = gtk.Menu() self.gtkmenu.add(self.imagemenuitem) self.menuitem.set_submenu(self.gtkmenu) self.menubar = gtk.MenuBar() self.menubar.append(self.menuitem) # 出力のテキストビューの下に1行テキスト入力欄と停止ボタンを横に並べる self.entry = gtk.Entry() self.button = gtk.Button() self.button.set_label(gtk.STOCK_STOP) # 停止のストックアイコンを self.button.set_use_stock(True) # 持ったボタンを作成する self.button.set_sensitive(False) # 最初は無効 self.hbox = gtk.HBox(False, 0) self.hbox.pack_start(self.entry, True, True, 0) self.hbox.pack_start(self.button, False, False, 0) self.textview = gtk.TextView() self.textbuf = self.textview.get_buffer() # TextViewのフォント設定 self.textview.set_editable(False) # 編集不可にする設定 self.textview.modify_font(pango.FontDescription('Monospace, Normal 10')) self.sw = gtk.ScrolledWindow() # TextViewをスクロールさせるために self.sw.add(self.textview) # ScrolledWindowの子にする self.sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) self.vbox = gtk.VBox(False, 0) self.vbox.pack_start(self.menubar, False, False, 0) self.vbox.pack_start(self.sw, True, True, 0) self.vbox.pack_start(self.hbox, False, False, 0) self.add(self.vbox) # テキスト入力欄をフォーカスする self.entry.grab_focus() # シグナルの接続 self.entry.connect('activate', self.on_entry_activated) # 入力欄でEnter self.button.connect('clicked', self.on_button_clicked) self.imagemenuitem.connect('activate', gtk.main_quit) self.connect('delete_event', gtk.main_quit) # スレッド使用のための初期化 gtk.gdk.threads_init() def on_entry_activated(self, widget): """ テキスト入力欄のコマンドを実行して出力をテキストビューに表示 """ # テキスト入力欄から文字列を取り出す self.cmd = self.entry.get_text() # 空のときには何もしない if self.cmd == '' or self.cmd.isspace(): return self.entry.set_text('') # 前後のスペースを削除 tmp = self.cmd.strip() # 連続したスペースを単一のスペースにする while True: self.cmd = tmp.replace(' ', ' ') if self.cmd == tmp: # 2つのスペースがこれ以上1つに置換されなければOK break tmp = self.cmd t = threading.Thread(target=self.th_execute) t.start() def on_button_clicked(self, widget): """ 停止ボタンが押されたときに子プロセスを終了する """ # (OSの)シグナルで子プロセスを終了 # os.kill()はUNIX系OS専用 # 以下、Windows上でのos.kill()に関するURL(動作は未確認) # http://bugs.python.org/issue1220212 # http://effbot.org/pyfaq/how-do-i-emulate-os-kill-in-windows.htm os.kill(self.pid, signal.SIGTERM) widget.set_sensitive(False) # widgetとself.buttonは同一 self.killed = True def th_execute(self): """ 外部コマンドを実行してテキストビューに結果を表示 """ self.killed = False subproc_args = {'stdin' : subprocess.PIPE, 'stdout' : subprocess.PIPE, 'stderr' : subprocess.STDOUT, 'close_fds' : True, } cmdline = self.cmd.split(' ') try: p = subprocess.Popen(cmdline, **subproc_args) except OSError: # 実行に失敗したらテキストビューにエラーを表示 gtk.gdk.threads_enter() self.textbuf.place_cursor(self.textbuf.get_end_iter()) self.textbuf.insert_at_cursor('Failed to execute command: %s\n' % cmdline[0]) self.textview.scroll_to_mark(self.textbuf.get_insert(), 0) gtk.gdk.threads_leave() return self.pid = p.pid # 停止するときに使用 gtk.gdk.threads_enter() self.button.set_sensitive(True) gtk.gdk.threads_leave() stdouterr = p.stdout # 出力をテキストバッファに加え、一番後ろにスクロールする for line in stdouterr: gtk.gdk.threads_enter() self.textbuf.place_cursor(self.textbuf.get_end_iter()) self.textbuf.insert_at_cursor(line) self.textview.scroll_to_mark(self.textbuf.get_insert(), 0) gtk.gdk.threads_leave() p.wait() # この終了ステータスによって分岐処理することも可能 self.button.set_sensitive(False) class SubprocessGUI: """ 外部プロセスを起動して出力を表示する """ def main(self): """ GTK+のメインループを呼ぶ """ win = MainWindow() win.show_all() gtk.main() if __name__ == '__main__': app = SubprocessGUI() app.main()
(2009/4/22)メインウィンドウのクラス継承、スレッド側の処理をメインウィンドウのメンバ関数へ、子プロセスkill時の処理改善
(2009/6/30)不要な処理の削除など微調整
実行
起動後、テキスト入力欄にコマンドを入力してEnterを押すと、出力が上のテキストビューに表示される。処理に時間のかかるものは途中で止めることもできる。
一種のシェルのようにも見えるが、以下のような制約がある。
- topなど、端末を制御するタイプのコマンドは実行できない*3。
- 色を付けるエスケープシーケンスは機能しない*4。
- あるコマンドの出力を別のコマンドにつなげることはできない*5。
- 履歴を扱ったり補完を行ったりすることはできない
- 複数のスペース文字は全て単一のスペース文字に置き換えられる・ダブルクォートしても扱いは同じ
- フォントは「Monospace, Normal 10」固定
- 文字のエンコーディングに関しては、GTK+ 2で使用されているUTF-8のみ扱える(検出や変換の処理は行っていない)
関連記事:
参考URL:
- Python ライブラリリファレンス: 6.8.2 Popen オブジェクト
- Python ライブラリリファレンス: 7.5.6 Thread オブジェクト
- PyGTK リファレンス: gtk.Widget - grab_focus()やmodify_font()を使用
- PyGTK リファレンス: gtk.TextView
- PyGTK リファレンス: gtk.TextBuffer
- pango リファレンス: pango.FontDescription - フォント変更時に使用
- PyGTK FAQ: 14.23. How do I send the output of an external process to a gtk.TextView without freezing the GUI?