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

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

PyGTKでのプログレスバーについて(シンプルなGUIダウンローダを作成)

urllibのダウンロードで進行状況を表示、キャッシュをせずに直接ファイルに保存」のコードをもとに、PyGTKで入力欄に入れたURLをダウンロードしてファイルに保存するシンプルなダウンローダを作成した。

コード

#! /usr/bin/python
# -*- encoding: utf-8 -*-

import urllib
import locale
import time
import sys
import os
try:
  import pygtk
  pygtk.require('2.0')
except:
  pass
try:
  import gtk
  import gobject
except:
  sys.exit(1)
if gtk.pygtk_version < (2,4,0):
  print >> sys.stderr, 'Error: PyGTK 2.4.0 or later required'
  sys.exit(1)

locale.setlocale(locale.LC_TIME, 'C')  # 最終更新日時(英語)解析のため

def run_errordialog(message):
  """
  渡されたメッセージを使用してエラーダイアログを表示
  PyGTK 2.4.0以上が必要
  """
  errdlg = gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format=message)
  errdlg.set_title('Error')
  errdlg.set_markup(message)
  errdlg.run()
  errdlg.destroy()
def run_warningdialog(message):
  """
  渡されたメッセージを使用して警告ダイアログを表示
  PyGTK 2.4.0以上が必要
  """
  errdlg = gtk.MessageDialog(type=gtk.MESSAGE_WARNING, buttons=gtk.BUTTONS_OK)
  errdlg.set_title('Warning')
  errdlg.set_markup(message)
  errdlg.run()
  errdlg.destroy()

class MyURLopener(urllib.FancyURLopener):
  """
  進行状況を表示しながらURLをファイルに直接ダウンロードして
  タイムスタンプも処理する
  """
  version = 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)'
  mtime_remote = None    # 「Last-Modified: 」ヘッダが無い場合の値とする
  length = -1            # 同様にヘッダの無い場合のための値とする
  downloaded = 0         # ダウンロード済みバイト数
  download_done = False  # ダウンロード完了フラグ
  cancelled = False      # 停止ボタンが押されたかどうか
  def set_localfile_timestamp(self):
    """
    ダウンロードしたファイルの最終更新日時を
    サーバの「Last-Modified」ヘッダに合わせる
    """
    try:
      # 「Sun, 29 Jun 2008 12:34:56 GMT」の形式
      new_timestamp = time.mktime(time.strptime(self.mtime_remote, u'%a, %d %b %Y %H:%M:%S GMT')) - time.timezone
    except ValueError:
      run_warningdialog('Failed to parse "Last-Modified" header for URL "%s"' % self.url)
      return
    try:
      os.utime(self.filename, (new_timestamp, new_timestamp))  # 場所, (アクセス, 更新)
    except OSError:
      run_warningdialog('Failed to set timestamp: %s' % localfile)
  def retrieve(self, url, filename, pgbar, btn_start, btn_stop, entry, bufsiz=4096):
    """
    進行状況を表示しながらURLをファイルに直接ダウンロード
    データのキャッシュは行わない
    上書きされたメンバ関数
    """
    self.url = url
    self.filename = filename
    try:
      f_in = self.open(self.url)
    except IOError:
      run_errordialog('Cannot open URL "%s"' % self.url)
      yield False

    # 長さとタイムスタンプをヘッダから取得
    for l in str(f_in.info()).splitlines():
      if 'Content-Length: ' in l:
        self.length = float(l[len('Content-Length: '):])  # 都合によりfloat化
      elif 'Last-Modified: ' in l:
        self.mtime_remote = l[len('Last-Modified: '):]

    # 出力ファイルを開く
    try:
      f_out = open(self.filename, 'wb')
    except IOError:
      run_errordialog('Cannot write file "%s"' % self.filename)
      yield False

    # ボタンの状態を変更し、停止ボタンだけが押せるようにする
    btn_start.set_sensitive(False)
    btn_stop.set_sensitive(True)
    entry.set_sensitive(False)

    # 一定時間ごとにプログレスバーを更新するタイマーを開始
    gobject.timeout_add(150, self.update_progressbar, pgbar)
    # PyGTK FAQ 23.20.
    # How do I update a progress bar and do some work at the same time
    # http://faq.pygtk.org/index.py?req=show&file=faq23.020.htp
    # データを溜めながら少しずつファイルに書き出していく
    while True:
      try:
        data = f_in.read(bufsiz)    # データをバッファ分までダウンロード
        if not data:
          break
        f_out.write(data)           # 読んだ分だけファイルに書き込む
        self.downloaded += len(data)
        if self.cancelled == True:  # キャンセルされた場合
          raise IOError
        else:
          yield True                # 停止ボタンが押されていなければループ続行
      except IOError:
        f_in.close()
        f_out.close()
        try:
          os.remove(self.filename)  # 途中までのファイルを消す
        except OSError:
          pass
        yield False                 # その後の処理を止める
    f_in.close()                    # (全てダウンロードしたら)接続を切断
    f_out.close()                   # 出力ファイルを閉じる
    self.download_done = True
    # Last-Modifiedヘッダがある場合にローカルファイルのタイムスタンプを変更
    if self.mtime_remote:
      self.set_localfile_timestamp()
    # ボタンの状態を戻す
    btn_start.set_sensitive(True)
    btn_stop.set_sensitive(False)
    entry.set_sensitive(True)
    yield False

  def update_progressbar(self, pgbar):
    if self.cancelled == True:
      return False
    if self.length != -1:
      fraction = self.downloaded / self.length
      pgbar.set_text('%d / %d' % (self.downloaded, self.length))
      pgbar.set_fraction(fraction)
    else:
      pgbar.set_text('%d' % self.downloaded)
      pgbar.pulse()  # 左右に動かすとき、動かす度に呼ぶ
    if self.download_done == True:
      pgbar.set_fraction(1.0)
      return False
    else:
      return True


class MainWindow(gtk.Window):
  """
  シンプルなダウンローダ(プログレスバーのテスト)
  """
  def __init__(self, *args, **kwargs):
    gtk.Window.__init__(self, *args, **kwargs)
    # ウィンドウ
    self.set_title('PyGTK Simple Downloader')

    # ショートカットキー(アクセラレータ)
    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)

    # URL用テキストエントリ
    self.entry = gtk.Entry()
    # URL用テキストラベル
    self.label_url = gtk.Label('_URL:')
    self.label_url.set_use_underline(True)
    self.label_url.set_mnemonic_widget(self.entry)
    # 上の2つを横に並べる
    self.hbox_url = gtk.HBox(False, 0)
    self.hbox_url.pack_start(self.label_url, False, False, 0)
    self.hbox_url.pack_start(self.entry, True, True, 0)
    # 保存先指定
    self.chooserbtn = gtk.FileChooserButton('save')
    self.chooserbtn.set_action(gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
    # 保存先用テキストラベル
    self.label_save = gtk.Label('_Save in:')
    self.label_save.set_use_underline(True)
    self.label_save.set_mnemonic_widget(self.chooserbtn)
    # 上の2つを横に並べる
    self.hbox_save = gtk.HBox(False, 0)
    self.hbox_save.pack_start(self.label_save, False, False, 0)
    self.hbox_save.pack_start(self.chooserbtn, True, True, 0)

    # ダウンロード開始ボタン
    self.btn_start = gtk.Button()
    self.btn_start.set_label('St_art')
    self.btn_start.set_use_underline(True)
    # ダウンロード停止ボタン
    self.btn_stop = gtk.Button()
    self.btn_stop.set_label('S_top')
    self.btn_stop.set_use_underline(True)
    self.btn_stop.set_sensitive(False)  # 押せない状態
    # 上の2つを横に並べる
    self.hbox_dl = gtk.HBox(False, 0)
    self.hbox_dl.pack_start(self.btn_start, True, True, 0)
    self.hbox_dl.pack_start(self.btn_stop, True, True, 0)

    # プログレスバー
    self.pgbar = gtk.ProgressBar()
    self.pgbar.set_text('Ready')
    self.pgbar.set_pulse_step(0.1)
    #self.pgbar.set_orientation(gtk.PROGRESS_RIGHT_TO_LEFT)  # 右から左へ

    # メニュー、水平ボックス、プログレスバーを縦に並べる
    self.vbox = gtk.VBox(False, 0)
    self.vbox.pack_start(self.menubar, False, False, 0)
    self.vbox.pack_start(self.hbox_url, False, False, 0)
    self.vbox.pack_start(self.hbox_save, False, False, 0)
    self.vbox.pack_start(self.hbox_dl, False, False, 0)
    self.vbox.pack_start(self.pgbar, False, False, 0)
    self.add(self.vbox)

    # シグナルを手動で接続
    self.entry.connect('activate', self.on_entry_activated)
    self.btn_start.connect('clicked', self.on_btn_start_clicked)
    self.btn_stop.connect('clicked', self.on_btn_stop_clicked)
    self.imagemenuitem.connect('activate', gtk.main_quit)
    self.connect('delete_event', gtk.main_quit)
  def on_entry_activated(self, widget):
    """
    URLのテキストエントリでEnterを押したときに
    「開始ボタンがクリックされた」と通知する
    """
    self.btn_start.emit('clicked')
  def on_btn_start_clicked(self, widget):
    """
    ダウンロード開始ボタンが押されたときの処理
    """
    # URL文字列を取得
    url = self.entry.get_text()
    # URLが空の場合は処理しない
    if url == '' or url.isspace():
      return

    # 保存ファイル名
    name = os.path.basename(url)
    if name == '':  # 「/」の後ろの名前がない場合はその前の文字列を使用
      name = url[len('http://'):]
    file = os.path.join(os.getcwd(), name.replace('/', ''))
    self.opener = MyURLopener({})

    # PyGTK FAQ 23.20.
    # How do I update a progress bar and do some work at the same time
    # http://faq.pygtk.org/index.py?req=show&file=faq23.020.htp
    g = self.opener.retrieve(url, file, self.pgbar, widget, self.btn_stop, self.entry)
    gobject.idle_add(g.next)  # メインループより優先度を下げて実行
  def on_btn_stop_clicked(self, widget):
    """
    ダウンロード停止ボタンが押されたときの処理
    """
    widget.set_sensitive(False)
    self.btn_start.set_sensitive(True)
    self.opener.cancelled = True
    self.entry.set_sensitive(True)

class PyGTKSimpleDownloader:
  """
  シンプルなダウンローダ(プログレスバーのテスト)
  """
  def main(self):
    """
    アプリケーションのメイン処理
    """
    win = MainWindow()
    win.show_all()
    gtk.main()


if __name__ == '__main__':
  app = PyGTKSimpleDownloader()
  app.main()

(2009/1/13)Windowsでデータがファイル書き出し時に破損する不具合を修正(ファイルへの書き込みモードをバイナリモードに修正・えす氏に感謝)
(2009/4/22)メインウィンドウのクラス継承や例外処理の微調整

使い方

入力欄にURLを入力してEnterキーもしくは「Start」のボタンを押すと、ファイルがダウンロードされて指定ディレクトリに保存される。
進行状況は約0.15秒ごとに更新されるようにしている。

ループごとに進行状況を更新するようにした場合...

下は作成途中の段階でのコードだが、これを実行すると、プログレスバーの更新が滑らかで重たくなる。上のコードとの違いは、少しずつ「ダウンロード + 書き込み」の処理をループで行っているところで、毎回のループごとに更新するようにしているところ。こうなってしまってもやはりまずい。

  def retrieve(self, url, filename, pgbar, btn_start, btn_stop, entry, bufsiz=4096):
    """
    進行状況を表示しながらURLをファイルに直接ダウンロード
    データのキャッシュは行わない
    上書きされたメンバ関数
    """
    self.url = url
    self.filename = filename
    try:
      f_in = self.open(self.url)
    except IOError:
      run_error_dialog('Cannot open URL "%s"' % self.url)
      yield False

    # 長さとタイムスタンプをヘッダから取得
    for l in str(f_in.info()).splitlines():
      if "Content-Length: " in l:
        self.length = float(l[len("Content-Length: "):])  # 都合によりfloat化
      elif "Last-Modified: " in l:
        self.mtime_remote = l[len("Last-Modified: "):]

    # 出力ファイルを開く
    try:
      f_out = open(self.filename, "wb")
    except IOError:
      run_error_dialog('Cannot write file "%s"' % self.filename)
      yield False

    # ボタンの状態を変更し、停止ボタンだけが押せるようにする
    btn_start.set_sensitive(False)
    btn_stop.set_sensitive(True)
    entry.set_sensitive(False)

    # PyGTK FAQ 23.20.
    # How do I update a progress bar and do some work at the same time
    # http://faq.pygtk.org/index.py?req=show&file=faq23.020.htp
    # データを溜めながら少しずつファイルに書き出していく
    while True:
      data = f_in.read(bufsiz)    # データをバッファ分までダウンロード
      if not data:
        break
      f_out.write(data)           # 読んだ分だけファイルに書き込む
      self.downloaded += len(data)

      # 直接ここにプログレスバー更新のコードを書くと
      # ループごとに更新を行うことになるため
      # 滑らかになる一方、CPUの使用率が非常に高くなる
      if self.length != -1:
        fraction = self.downloaded / self.length
        pgbar.set_text("%d / %d" % (self.downloaded, self.length))
        pgbar.set_fraction(fraction)
      else:
        pgbar.set_text("%d" % self.downloaded)
        pgbar.pulse()  # 左右に動かすとき、動かす度に呼ぶ

      if self.cancelled == True:  # キャンセルされた場合
        f_in.close()
        f_out.close()
        os.remove(self.filename)  # 途中までのファイルを消す
        yield False               # その後の処理を止める
      else:
        yield True                # 停止ボタンが押されていなければループ続行
    f_in.close()                  # (全てダウンロードしたら)接続を切断
    f_out.close()                 # 出力ファイルを閉じる
    self.download_done = True
    # Last-Modifiedヘッダがある場合にローカルファイルのタイムスタンプを変更
    if self.mtime_remote:
      self.set_localfile_timestamp()
    # ボタンの状態を戻す
    btn_start.set_sensitive(True)
    btn_stop.set_sensitive(False)
    entry.set_sensitive(True)
    yield False

関連記事: