試験運用中なLinux備忘録・旧記事

はてなダイアリーで公開していた2007年5月-2015年3月の記事を保存しています。

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*2place_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:

*1:GTK+ウィジェットのオブジェクトに対するメンバ関数の呼び出し

*2:gtk.TextViewに表示する(関連付けられた)文字列データ

*3:Hotwireというアプリケーションでは、この手のコマンドはVTEという端末ウィジェットを用いて実行するようになっている

*4:これもHotwireではVTEで実現しているが、端末に色を付ける代わりにグラフィカルな組み込みコマンドが用意されていたりする

*5:Hotwireでは接続を行うことができるように作られている