はじめに
Pythonの全てのCLIアプリケーションフレームワークに習熟してるわけではないです.
自分が必要だと思ったものから優先的に実装しています.こうした方がいいとかあれば,リポジトリのIssueにお願いします.
背景
2019年現在,ソフトウェア系の研究をする人が避けて通れない言語がPythonと言っても過言では無くなってきました(炎上).実験でスクリプトを回すとき,パラメータ変更のために毎回ソースコードを書き換えるなんていう真似をしている人はおそらく居ないと思いますが,皆様どのようにCLIアプリケーションを構築されていますでしょうか.
Pythonの有名なCLIアプリケーションフレームワークはいくつかあります.僕の個人的な感想を併記します.
- Click
- ○:関数を簡単にコマンドにできるのは便利
- ×:デコレータ地獄になっているコードを読み解きたいという気持ちには全くならない
- Cliff
- ○:OpenStackのサポートがあって将来的なサポートも期待できそう
- ×:setuptoolsの力を借りないとサブコマンドを使えなくて嬉しくない
- Python Fire
- ○:Googleのサポートがあるので将来的なサポートも期待できそう
- ×:これ使いやすいですか?
- Cement
- ×:本家ドキュメントのGetting startedを読んでこんなに使い方が分からないとは思わなかった
- Plac
- ×:もはやPythonを書いていない
正直どれもこれも学習コストが高いか,可読性を犠牲にしており,それなら自分でargparse
のラッパーを書いた方がええわ!となりました.世間のPythonistaたちはどうしてるんでしょうか…
そもそもPythonのargparse
は非常に高機能で,CLIアプリケーション構築に必要な機能はほとんど網羅しているかと思います.インタラクティブシェル的な流行りの機能は難しいですが,そんな要件,普通に存在しますか?僕は幸い出会ったことがありません.というわけで,僕自身の機能に対するニーズはargparse
で十分に満たされており,後はどれだけ快適に書けるか,というところだけです.長くなりましたがこういう経緯で新しく実装することにしました.
方針
- 学習コストをできるだけ低く抑えること
- argparseの使用感を損なわずにコマンドを構造的に書けるようにすること
- 標準的な機能を利用する上では追加の依存を必要としないこと
- Python3.5以上のみをサポートすること
- もうこの世にPython2を使っているのはgcloudコマンドしか居ないため
気持ちだけGoのCLIアプリフレームワークのspf13/cobraを参考にしました.
uroboros
GitHubで公開しています.現在v0.1.0です.
名前の由来
ヘビっぽい単語で使えそうなのを探していたとき,ouroboros
(ウロボロス)は使われていましたがWikipediaを見るとuroboros
でも良さそうだったのでこれにしました.
ちなみにウロボロスは自分の尾を飲み込む蛇で終わり始まりが無いこと,不老不死,循環性,無限性の象徴ですが,このフレームワークにおいてサブコマンドは再帰的に構築され,ループしているとエラーを吐くので全然名前を表していないなと作ってから思いました.
インストール
pipでインストールするだけです.今のところサードパーティーの依存パッケージはありません.
$ pip install uroboros
使い方
覚える必要があるのはargparse
の使い方と,uroboros.Command
,uroboros.Option
だけです.しかも大抵の要件ではuroboros.Command
だけでも十分でしょう.uroborosではuroboros.Command
を継承して作成したコマンドクラスをノードとするN分木を作るようなイメージでCLIツールを構築していきます.
# main.py from uroboros import Command from uroboros import ExitStatus class RootCommand(Command): """アプリケーションのルートコマンド""" # アプリケーションのルートコマンド. name = 'sample' # アプリケーションの説明 long_description = 'This is a sample command using uroboros' version = "v0.0.0" def build_option(self, parser): """ 引数の追加. argparse.ArgumentParserのインスタンスが引数として渡される. """ parser.add_argument('--version', action='store_true', default=False, help='Print version') # 追加した後にparserを返す return parser def run(self, args): """ このコマンドが指定されたときに実行する内容. argsはargparse.Namespaceオブジェクトで引数をパースした結果. """ if args.version: print("{name} v{version}".format( name=self.name, version=self.version)) else: # このコマンドのヘルプテキストを出力するヘルパー関数 self.print_help() # 返り値はExit Statusになる. # `uroboros.ExitStatus`を使っても良いし,単にintでも良い. return ExitStatus.SUCCESS root = RootCommand() if __name__ == "__main__": # 実行時は単にそのコマンドのexecuteを呼ぶだけ exit_code = root.execute() exit(exit_code)
これが基本的なコマンド一つの実装になります.ポイントは
argparse.ArgumentParser
のインスタンスが渡されるので自由にオプションを追加する- 実行時には引数が
argparse
によってパースされ,argparse.Namespace
のインスタンスが渡されるので,自由にオプションを読み取って実行する - 返り値はそのまま終了ステータスになるので,intまたは
uroboros.ExitStatus
*1を返す
です.試しにヘルプを表示するとこうなります.
$ python main.py -h usage: sample [-h] [--version] This is a sample command using uroboros optional arguments: -h, --help show this help message and exit --version Print version # ちゃんとバージョン表示できる $ python main.py --version sample v0.0.0
ではサブコマンドを追加してみます.
# main.py from uroboros import Command from uroboros import ExitStatus class RootCommand(Command): ... class HelloCommand(Command): # サブコマンド名 name = 'hello' # サブコマンド一覧を表示したときの短いメッセージ short_description = 'Hello world!' long_description = 'Print "Hello world!" to stdout' def run(self, args): print(self.short_description) return ExitStatus.SUCCESS root = RootCommand() root.add_command(HelloCommand()) ...
これでhello
サブコマンドが使えるようになります.
# short descriptionはここで表示される $ python main.py -h usage: sample [-h] [--version] {hello} ... This is a sample command using uroboros optional arguments: -h, --help show this help message and exit --version Print version Sub commands: {hello} hello Hello world! # long descriptionはこっちで表示される $ python main.py hello -h usage: sample hello [-h] Print "Hello world!" to stdout optional arguments: -h, --help show this help message and exit # Hello worldしてみる $ python main.py hello Hello world!
更に複数のモジュールから複雑なコマンドを生成するサンプルはこちらにあります. これをargparseで実装する場合,(色々と省略すると)以下の様になります.
import argparse def print_version(parser, args): if args.version: print("sample v0.0.0") else: parser.print_help() def hello(parser, args): print("Hello world!") def main(): parser = argparse.ArgumentParser() parser.add_argument('--version', action='store_true', default=False, help='Print version') parser.set_defaults(func=print_version) sub_parser = parser.add_subparsers() hello_parser = sub_parser.add_parser('hello') hello_parser.set_defaults(func=hello) args = parser.parse_args() args.func(parser, args) if __name__ == '__main__': main()
細かい動作が違いますが概ねこういう感じです.規模が大きくなってきたときにどうなるかは考えるまでも無いでしょう.更にサブコマンドの下にサブコマンドを…等をし始めると,あっという間にスパゲティコードの誕生です.
uroborosでは内部でadd_subparser()
を使っており,実際の挙動としては上記のような挙動を全てラップしているような感じになります.
オプション引数の共有
いくつかのサブコマンドで同じオプション引数を使い回したいという需要はあると考えています*2.これを容易に実現する仕組みがuroboros.Option
です.コードの全体像はリポジトリのexampleに任せ,要点だけ説明します.このexampleでは指定されたディレクトリ内のファイルを一覧表示するfiles
コマンド, ディレクトリを一覧表示するdirs
コマンドを実装しています.
まずuroboros.Option
を継承したオプションクラスを作成します. uroboros.Command
と同様にbuild_option
メソッドで引数を追加できます.
from pathlib import Path from uroboros import Option, Command class CommonOption(Option): def build_option(self, parser): # 共通するオプションを追加する parser.add_argument('path', type=Path, help="Path to show") parser.add_argument('-a', '--absolute', default=False, action='store_true', help='Show absolute path') return parser class RootCommand(Command): ... # 共通するオプションをクラス変数`options`に設定する class DirsCommand(Command): name = 'dirs' options = [CommonOption()] ... class FilesCommand(Command): name = 'files' options = [CommonOption()] ... root = RootCommand() root.add_command(DirsCommand()) root.add_command(FilesCommand()) ...
これだけでdirs
サブコマンドとfiles
サブコマンドでpath
という位置引数と--absolute
というオプション引数が使えるようになります.
このOptionの良いところは単にくくりだして記述量を減らせるだけでなく,値のバリデーションをまとめることが出来る点にあります.
... class CommonOption(Option): def build_option(self, parser): ... # 実行時にCommandの`run`より先に呼ばれる def validate(self, args): # 返すのはExceptionのリスト.バリデーション違反がない場合,空のリストを返す. errors = [] # 指定したパスが存在しない場合エラーにする if not args.path.exists(): errors.append(Exception("'{}' does not exists.".format(args.path))) return errors ...
これで例えば存在しないパスを指定すると,files
サブコマンドでもdirs
サブコマンドでも共通して存在しないパスを弾くことが出来ます.
$ python main.py files /does/not/exists '/does/not/exists' does not exists. $ python main.py dirs /does/not/exists '/does/not/exists' does not exists.
注意点
良い意味でも悪い意味でのargparseのラッパーに過ぎないため,コマンドライン引数のパースは完全にargparse任せになっています.このため,現状ではサブコマンド以下の引数の名前と,親コマンドで使用している引数の名前がコンフリクトすると最後に設定したオプションで上書きされてしまいます.例えば
$ python main.py root -d fuga command1 -d poyo
のような仕様にした場合,最終的には後者の-d poyo
で上書きされてしまいます.これは
- 親コマンドの引数のパースをする
- サブコマンド以下の引数だけ抽出
- サブコマンドの引数のパースをする
の順で解決することでうまく行くはずですが,引数へのアクセシビリティが下がるため避けています.
つまり,今はargs.hoge
やargs.fuga
でアクセスできるコマンドライン変数に対しサブコマンドごとに名前空間を区切ると,args.subcommand.hoge
みたいなアクセスの仕方になってしまい,親コマンドからの相対的な位置に依存することになり,せっかく全てのコマンドが個別でexecute()
できる仕様が崩壊してしまいます.現状のコマンドの引数のみにフォーカスして渡すことも出来ますが,上位コマンドのオプション引数を解決できなくなってしまいます.
これについては今後の展望で述べるHook機能を使うことで一部解消できるはずです.各コマンドのオプションは各コマンドごとにパースし,必要な処理はHookで行うことで,最終的に実行されるCommandのrun
メソッドでは常にグローバルな名前空間にアクセスする必要がなくなる(親コマンドのオプション引数はその親コマンドのHookで解消する)ためです.が,逆に上位コマンドのオプションにはアクセスできなくなるため,上手い仕組みが必要だと考えています.
今後の展望
- 各コマンドにHookを用意する
- 例えばルートコマンドにHookを仕掛けてアプリケーション全体でのloggerの設定をするなどに使う
- バリデーションエラーハンドラーのサポート
- 現状ではエラーが発生したらそのままlogger.error()して終了してしまう.
- バリデーションからの回復を可能にするなどがあると便利そう.
add_command
で複数のCommandインスタンスを受け取れるようにする- すぐできるが需要があるのか分からない
- オプションをファイルから読み取り/ファイルへ保存
他にも要望や改善等あれば是非お願いします.
まとめ
argparseについての知識を活かしたまま,よりCLIアプリケーションを構築しやすくするためのフレームワークとしてuroborosを作成・公開しました.
これにより,サブコマンドの数の増加・そのネスト数の増加に伴って複雑化しがちなCLIアプリケーションを比較的簡単に構築することができるようになりました.
何かフィードバック等あれば遠慮無く頂けると幸いです.よろしくお願いします.