The Dabsong Conshirtoe

技術系の話を主にします。

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本体の実装がどうなってるか見ていきます。

クラス構成

ざっくりとこんな感じです。

f:id:Attsun_1031:20131202220639p:plain

  • 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メソッド

  1. ArgumentParser.parse_known_argsによりコマンドライン引数の解析を行う。--verbose--debugなどのオプションがデフォルトでセットされる。
  2. ロガーを生成する。
  3. 初期化後のhookポイントであるself.initialize_appメソッドを呼び出す。デフォルトでは何もしない。
  4. コマンド名が引数で渡されている場合、self.run_subcommandへ。コマンド名が渡されていない場合はインタラクティブモードに以降(詳細割愛)
  5. self.command_manager.find_commandによりコマンドをロード。
  6. コマンド実行前のhookポイントであるself.prepare_to_run_commandを実行。デフォルトでは何もしない。
  7. コマンドオブジェクトのget_parserによりコマンド固有引数解析用Parserを取得しパース。
  8. パースした結果を引数にコマンドオブジェクトのrunを実行。
  9. コマンド実行後の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_commandsadd_commandで追加されたコマンドから、引数argv[0]に一致するコマンド名をもつコマンドを探します。

戻り値ではコマンドクラスを返しており、この段階ではインスタンス化は行いません。

コマンドクラスのインスタンス化はAppクラスがfind_commandを呼んでコマンドクラスを取得したあと、optionsを元にすぐさま行っているのですが、どういう考慮でしょうね。

コマンドの生成ロジックはCommandManagerに隠蔽するのが素直な気がするのですが。

感想

バッチフレームワークの設計にも応用できそうな気がしました。

あんまりバッチやコマンドラインアプリケーション系のフレームワークって使ったことないのですが、だいたいがこういう設計になってるのかな。

明日はdrillbitsさんです!