cliffとその実装
python advent calendar 15日目の記事です。
以前から気になっていたcliffについて調べてみました。
ライブラリやフレームワークのコードを読むのが最近のマイブームなので、使い方とかは適当に流してcliffがどのような実装構成になっているか見てみたいと思います。
詳細が気になる方はドキュメントがちゃんとしてるので読んでみてください。 https://cliff.readthedocs.org/en/latest/demoapp.html
cliffってなに
pythonのコマンドラインアプリケーションフレームワーク。サブコマンドを登録したり引数を解析したりログを出したり、コマンドラインアプリに共通する部分を担ってくれます。
私は知りませんでしたが、gearboxというツールの実装に使われているそうです。
使用方法
標準出力になにか出すだけでの簡単なアプリケーションを作りながら、どのようにcliffを使うのか見てみましょう。
必要なもの
cliff.app.App
を継承したクラスこのクラスのrunメソッドを実行することでアプリが起動されます。インスタンス化してrunメソッドを叩くための関数も用意しておきましょう。(main.py)
# -*- coding: utf-8 -*- import sys from cliff.app import App from cliff.commandmanager import CommandManager class MyDemoApp(App): def __init__(self): super(MyDemoApp, self).__init__( description = 'my demo app', version = '0.1', command_manager = CommandManager('myapp.commands'), ) def main(argv=sys.argv[1:]): # エントリーポイントなる関数 myapp = MyDemoApp() return myapp.run(argv) if __name__ == '__main__': sys.exit(main(sys.argv[1:]))
CommandManager
は各コマンドを管理するクラスで、コンストラクタ引数にはコマンドが登録されているエントリーポイントグループ名を渡します。cliff.command.Command
を継承したクラスこのクラスが各コマンドを定義するクラスとなります。例えば標準出力になにか出すだけのコマンドだったらこんな感じ。(say.py)
# -*- coding: utf-8 -*- from cliff.command import Command class Say(Command): "A say command that prints a message." def take_action(self, parsed_args): self.app.stdout.write('hey!\n')
setup.py
バッチプログラムをインストールするスクリプトです。
詳細は省きますが、
entry_points
の定義にだけ触れておきます。console_scripts
にバッチの起動コマンドとなる関数を定義し、CommandManager
の初期化時に渡していたエントリーポイントグループに各コマンドのエントリーポイントを書きます。entry_points={ 'console_scripts': [ 'mycliffapp = app.main:main' ], 'myapp.commands': [ 'say = app.say:Say', ], },
これを
python setup.py install
することで、$ mycliffapp say hay!
というコマンドを実行できるようになります。(setuptoolsは知らないとこが多い・・。)
だいたいの使い方はこんなとこです。
オプション
特にアプリケーション側で何もすることなく、ヘルプやdebugなどのよくあるオプションをサポートしてくれます。
usage: mycliffapp [--version] [-v] [--log-file LOG_FILE] [-q] [-h] [--debug]
my demo app
optional arguments:
--version show program's version number and exit
-v, --verbose Increase verbosity of output. Can be repeated.
--log-file LOG_FILE Specify a file to log output. Disabled by default.
-q, --quiet suppress output except warnings and errors
-h, --help show this help message and exit
--debug show tracebacks on errors
インタラクティブモード
引数なしで実行すると、インタラクティブモードに入ります。インタラクティブモードでは、コマンド名のみでコマンドを実行できたりします。
$ mycliffapp
(mycliffapp) say
hay!
この実装にはcmd2というライブラリが使われています。
実装
ではcliff本体の実装がどうなってるか見ていきます。
クラス構成
ざっくりとこんな感じです。
- Appクラスがコマンドの流れを制御するクラス
- CommandManagerクラスが登録されたサブコマンドをロードしたり検索したりするクラス
- Commandクラスが各サブコマンドの動作を規定するクラス
cliffのユーザーはAppクラスとCommandoクラスを継承することになります。 簡単ですね。
※クラス図をastahで作ったんですが、pythonなひとたちはUML書くときに何使ってんでしょ?
それではひとつひとつ見て行きましょう。
cliff.app.App
アプリケーションのエントリーポイントとなるクラス。__init__
でロケールの設定やArgumentParser
の生成を行い、run
メソッドによりアプリケーションを起動します。
runメソッドが重要なので、処理の流れを見ていきます。 なお、runは長いのでコードは貼りません。気になる人は以下を見てください。 https://github.com/dreamhost/cliff/blob/master/cliff/app.py
runメソッド
ArgumentParser.parse_known_args
によりコマンドライン引数の解析を行う。--verbose
や--debug
などのオプションがデフォルトでセットされる。- ロガーを生成する。
- 初期化後のhookポイントである
self.initialize_app
メソッドを呼び出す。デフォルトでは何もしない。 - コマンド名が引数で渡されている場合、
self.run_subcommand
へ。コマンド名が渡されていない場合はインタラクティブモードに以降(詳細割愛) self.command_manager.find_command
によりコマンドをロード。- コマンド実行前のhookポイントである
self.prepare_to_run_command
を実行。デフォルトでは何もしない。 - コマンドオブジェクトの
get_parser
によりコマンド固有引数解析用Parserを取得しパース。 - パースした結果を引数にコマンドオブジェクトの
run
を実行。 - コマンド実行後のhookポイントである
self.clean_up
を実行。デフォルトでは何もしない。このメソッドはコマンドで例外が発生した場合でも実行される。
hookポイントを提供することでアプリケーション固有処理をポイントポイントで入れられるようになっています。テンプレートメソッドパターンというやつですね。
cliff.command.Command
サブコマンドを表すクラスです。
run
Appクラスからコールされます。
コードはこんな感じ
def run(self, parsed_args):
self.take_action(parsed_args)
return 0
戻り値はexit codeとなり得るので(実装次第)、変更したい場合はオーバーライドする必要があるでしょう。
take_action
サブコマンドの実際の処理を書くところです。abc.abstractmethod
になっているので、継承クラスでこのメソッドをオーバーライドする必要があります。
cliff.commandmanager.CommandManager
サブコマンドの管理をするクラスです。ロードしたり検索したり。 より役割をはっきりさせた名前にするなら、CommandRepositoryといったところでしょうか。
_load_commands
initの最後に_load_commandsというメソッドをコールしています。このメソッドは以下のような実装になっています。
def _load_commands(self):
for ep in pkg_resources.iter_entry_points(self.namespace):
LOG.debug('found command %r', ep.name)
cmd_name = (ep.name.replace('_', ' ')
if self.convert_underscores
else ep.name)
self.commands[cmd_name] = ep
return
pkg_resources
を使ってentry_pointを頼りにコマンドをロードしています。entry_pointへのコマンドの登録はsetup.pyで行います。
find_command
_load_commands
やadd_command
で追加されたコマンドから、引数argv[0]に一致するコマンド名をもつコマンドを探します。
戻り値ではコマンドクラスを返しており、この段階ではインスタンス化は行いません。
コマンドクラスのインスタンス化はAppクラスがfind_command
を呼んでコマンドクラスを取得したあと、optionsを元にすぐさま行っているのですが、どういう考慮でしょうね。
コマンドの生成ロジックはCommandManager
に隠蔽するのが素直な気がするのですが。
感想
バッチフレームワークの設計にも応用できそうな気がしました。
あんまりバッチやコマンドラインアプリケーション系のフレームワークって使ったことないのですが、だいたいがこういう設計になってるのかな。
明日はdrillbitsさんです!