ClickかわいいよClick
Pythonでコンソールアプリケーションを作るのに、Clickライブラリの便利さを知ってしまうと、argparse だの optparse だので引数をパースとかもう意味わからないですよね。
Clickについては、公式サイトとか、ikuyamadaさんの入門記事
「Clickで手軽にPythonのコンソールアプリケーションを作る」
を見ていただくとして。
Clickのプログレスバーのことを書きます。
ちょっと時間がかかる作業の進捗度合いをバーで表示することによって、ユーザの待ち時間のストレスを軽減してくれるあれです。
click.progressbar()
でiterableをラップすると、アイテムを1つずつ渡してくれると同時に、いい感じにプログレスバーを更新してくれます。
サンプル
簡単な例として、OS Xのsayコマンドでカウントダウンするだけのアプリを書いてみました。英独仏日の4ヶ国語に対応しています。(各国語の音声がインストールされている必要があります)
// マカー以外の方は say 関数を適宜編集してください。
#!/usr/bin/env python
#
# countdown
#
import click
import time
import os
_lang = 'en'
def say(num):
if _lang == 'ja':
voice = 'Kyoko'
elif _lang == 'fr':
voice = 'Virginie'
elif _lang == 'de':
voice = 'Anna'
else:
voice = 'Agnes'
os.system('say --voice %s %d' % (voice, num))
@click.command()
@click.argument('count', type=int)
@click.option('--lang', default='en')
def countdown(count, lang):
global _lang
_lang = lang
numbers = range(count, -1, -1)
with click.progressbar(numbers) as bar:
for num in bar:
say(num)
time.sleep(1)
if __name__ == '__main__':
countdown()
動かしてみます。
$ python countdown.py 10 --lang=ja
10からのカウントダウンが始まるとともに、こんな感じにプログレスバーが表示されます。
[##################------------------] 50% 00:00:06
それまでの経過から残り時間が推定されて、右端に表示されます。
click.progressbar( ) のオプション
click.progressbar(iterable=None, length=None, label=None, show_eta=True, show_percent=None, show_pos=False, item_show_func=None, fill_char='#', empty_char='-', bar_template='%(label)s [%(bar)s] %(info)s', info_sep=' ', width=36, file=None, color=None)
本家ドキュメント http://click.pocoo.org/5/api/#click.progressbar に解説がありますが一応。
- iterable : イテレート対象となるiterable。Noneの場合はlength指定が必須となる。
- length : イテレートするアイテムの数。iterableが渡されている場合デフォルトではそちらに長さを問い合わせる(が長さが判明しない場合もある)。lengthパラメータが指定されている場合は(iterableの長さではなく)指定された長さだけイテレートする。
- label : プログレスバーの隣りに表示するラベル。
- show_eta : 推定所要時間を表示するか否か。長さが決定できない場合には自動的に非表示にされる。
- show_percent : パーセンテージを表示するか否か。(デフォルトは、iterableが長さをもつ場合はTrueで、持たない場合はFalse)
- show_pos : 絶対位置を表示するか(デフォルトはFalse)
- item_show_func : 処理中のアイテムを表示する際に呼ばれる関数。この関数は、処理中のアイテムについてプログレスバーに表示されるべき文字列を返す。アイテムはNoneである可能性もあるので注意。
- fill_char : プログレスバーの塗りつぶし部分に用いる文字。
- empty_char : プログレスバーの塗りつぶされていない部分に用いる文字。
- bar_template : バーのテンプレートとして用いられるフォーマット文字列。中で用いられるパラメータは label, bar, info の3つ。
- info_sep : 複数の情報アイテム(eta等)のセパレータ。
- width : プログレスバーの幅(半角文字数単位で)。0を指定するとターミナルの全幅
- file – 書き込み先ファイル。ターミナル以外ならラベルのみが表示される。
- color : ターミナルがANSIカラーをサポートしているか。(自動識別)
落とし穴:残り時間が24時間以上の場合に何日かかるのかわからない
※2015/11/8執筆時現在のバージョン(5.1)における問題点です。本家にpull requestを送ったので、もしかしたら記事をお読みになった頃には解決されているかもしれません。
→2015/11/10の夜にpull requestがマージされました。現在のmasterではこの問題は解決済みです。
とはいえ、当面はmaster版を使えない環境の人(version <= 5.1 の皆さん)もいると思うので、以下の対応策は残しておきます。
とても便利な機能なのですが、1つ落とし穴がありまして。
試しに、先ほどのサンプルで100万からのカウントダウンを決行してみます。
$ python countdown.py 1000000
[------------------------------------] 0% 00:20:47
999999まで行った時点であと20分?
1つカウントするたびに1秒のsleepを入れてるから、どんなに早くても11日と13時間46分はかかるはず。
どういうこと?
残り時間が24時間以上の場合、表示が 00:00:00 からに戻っちゃうんですよね。
つまり残り時間を24時間で割った余りのみが表示されているわけです。
clickのソースを見てみます。
ProgressBarクラス(click/_termui_impl.py
で定義されている)の中で、
所要時間(eta)をフォーマットしているのは format_eta()
というメンバ関数で、
@property
def eta(self):
if self.length_known and not self.finished:
return self.time_per_iteration * (self.length - self.pos)
return 0.0
def format_eta(self):
if self.eta_known:
return time.strftime('%H:%M:%S', time.gmtime(self.eta + 1))
return ''
こんな実装になっています。
24時間以内に終わるタスクしか想定されていなくて悲しいです。
ここの '%H:%M:%S'
を '%d %H:%M:%S'
にすればいいのでしょうか。
違いますね。
$ python
Python 2.7.9 (default, Jan 7 2015, 11:50:42)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import time
>>> time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(0))
'1970-01-01 00:00:00'
>>> time.strftime('%H:%M:%S', time.gmtime(0))
'00:00:00'
>>> time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(60*60*24))
'1970-01-02 00:00:00'
>>> time.strftime('%H:%M:%S', time.gmtime(60*60*24))
'00:00:00'
```
``time.gmtime(t)`` は、数値を入れると、年・月・日・時・分・秒と、曜日、年の何日目か、サマータイムか、の9項目からなる構造体 ``time.struct_time`` を返すのですが、
unix epoch (1970年1月1日の午前0時0分0秒) を基準にしているので、``time.gmtime(0)`` は「1970年1月1日の」0時0分0秒であって、これを ``time.stftime()`` に投げて ``%Y-%m-%d`` を取ると "1970-01-01" が出てきます。
(``format_eta()`` の実装が ``'%H:%M:%S'`` で何とかなってるのは、unix epoch が中途半端な時刻じゃなくて 00:00:00 だからですね)
なので、日単位を考慮するなら、``time.gmtime`` や ``time.strftime`` に頼らずに自前で計算したほうがよさそうです。
たとえばこんな感じ:
```
def format_eta_impl(self):
if self.eta_known:
t = self.eta
seconds = t % 60
t /= 60
minutes = t % 60
t /= 60
hours = t % 24
t /= 24
if t > 0:
days = t
return '%dd %02d:%02d:%02d' % (days, hours, minutes, seconds)
else:
return '%02d:%02d:%02d' % (hours, minutes, seconds)
else:
return ''
```
ProgressBar クラスのメンバ関数 ``format_eta(self)`` をこれに差し替えたいのですが、ProgressBar クラス自体が click/_termui_impl.py で定義されていて、ライブラリ利用者からは見えない形になっているので
```
with click.progressbar(numbers) as bar:
bar.__class__.format_eta = format_eta_impl
for num in bar:
say(num)
time.sleep(1)
```
みたいにすればよさそうです。
ここは ``bar.format_eta = format_eta_impl`` ではうまく行きません。
(その辺りの解説はここでは割愛します!)
使ってみましょう。
```
$ python countdown.py 1000000
[------------------------------------] 0% 18d 23:20:12
```
ワンミリオンからのカウントダウンが始まりました。
残り時間18日と23時間20分12秒ww
というわけで。
(´-`).。oO(..ちゃんと書いて本家Clickにプルリク送るべきですね)
→ [送りました](https://github.com/mitsuhiko/click/pull/453)
→ [マージされました](https://github.com/mitsuhiko/click/pull/453#event-460027353)
## countdown.py(改善版)
```big_countdown.py
#!/usr/bin/env python
#
# countdown (for big number)
#
import click
import time
import os
_lang = 'en'
def say(num):
if _lang == 'ja':
voice = 'Kyoko'
elif _lang == 'fr':
voice = 'Virginie'
elif _lang == 'de':
voice = 'Anna'
else:
voice = 'Agnes'
os.system('say --voice %s %d' % (voice, num))
def format_eta_impl(self):
if self.eta_known:
t = self.eta
seconds = t % 60
t /= 60
minutes = t % 60
t /= 60
hours = t % 24
t /= 24
if t > 0:
days = t
return '%dd %02d:%02d:%02d' % (days, hours, minutes, seconds)
else:
return '%02d:%02d:%02d' % (hours, minutes, seconds)
else:
return ''
@click.command()
@click.argument('count', type=int)
@click.option('--lang', default='en')
def countdown(count, lang):
global _lang
_lang = lang
numbers = range(count, -1, -1)
with click.progressbar(numbers) as bar:
bar.__class__.format_eta = format_eta_impl
for num in bar:
say(num)
time.sleep(1)
if __name__ == '__main__':
countdown()
```
## 並列処理するタスクに click.progressbar を利用する場合の注意点
### 症状
- 処理は何も進んでいないのに最初にプログレスバーがドカッと半分ぐらい進む
- 残り時間表示が1〜2秒程度なのにいつまで経ってもタスクが完了しない
こんな症状にお困りの方のために。
例えば multiprocessing.pool.Pool を使う場合、progressbar の進捗表示は(処理が実際に行われたタイミングではなく)「処理がPoolに投入された時点」でインクリメントされます。
このため、処理は何も進んでいないのに最初にプログレスバーがドカッと半分ぐらい進んでしまう現象が発生します。
progressbar の方は最初の一瞬でもう半分ぐらいの処理が終わった気でいるので、処理残り時間をあと1,2秒と推測して表示します。これが、残り時間表示が1〜2秒程度なのにいつまで経ってもタスクが完了しない現象の原因です。
### 対策
実際に処理されたタイミングで進捗表示が進み、残り時間をできるだけ正確に推定できるようにするには
- click.progressbar() の引数に(iterable そのものではなく)length=(iterableのサイズ)を設定
- 1つ処理するたびに bar.update(1) を呼び出す
### multiprocessing で Ctrl-C での強制終了が効かない
Clickのせいではないですけど… 使いやすいCLIを作るために。
(multiprocessing.pool.Pool あるあるです)
Pool が KeyboardInterrupt を拾ってしまっているので、Pool側で KeyboardInterrupt を無視するように initializer で設定しておきましょう。
### 実装例
カウントダウン読み上げサンプルを並列化してみました。
(気持ち悪いですけど)
```countdown_mp.py
import click
import time
import os
import multiprocessing
from multiprocessing.pool import Pool
from functools import partial
import signal
_lang = 'en'
def say(num):
if _lang == 'ja':
voice = 'Kyoko'
elif _lang == 'fr':
voice = 'Virginie'
elif _lang == 'de':
voice = 'Anna'
else:
voice = 'Agnes'
os.system('say --voice %s %d' % (voice, num))
time.sleep(1)
return num
def _init_worker():
signal.signal(signal.SIGINT, signal.SIG_IGN)
@click.command()
@click.argument('count', type=int)
@click.option('--lang', default='en')
@click.argument('pool-size', type=int, default=multiprocessing.cpu_count())
@click.argument('chunk-size', type=int, default=5)
@click.argument('max-tasks-per-child', type=int, default=3)
def countdown(count, lang, pool_size, chunk_size, max_tasks_per_child):
global _lang
_lang = lang
pool = Pool(pool_size, _init_worker, maxtasksperchild=max_tasks_per_child)
imap_func = partial(pool.imap, chunksize=chunk_size)
numbers = range(count, -1, -1)
with click.progressbar(length=len(numbers)) as bar:
for result in imap_func(say, numbers):
bar.update(1)
pool.close()
if __name__ == '__main__':
countdown()
```