argparse嫌いのあなたへ贈るパッケージ「NAAM」

この記事は #kosen10s Advent Calendar 2日目の記事である。 昨日の記事はAllajahの「2017年にあったKosen10'sの動きまとめ」だ。

argparseを使いたくない

PythonでCLIなユーティリティを作るとき、コマンド引数のパースをする一番標準的な方法は argparse を使うことである。

しかし、私はPythonを始めたときからずっとargparseがどうにも好きになれない。ほんの数十行のお手軽スクリプトのために、数行から場合によっては10行以上のパース定義を書かなければならないからだ。こっちは --hoge 1234 みたいな引数をいい感じにパースしてほしいだけなのに、 add_argument 関数を大量のキーワード引数と共に仰々しく呼ばなければならない。しかもこのキーワード引数は使い方に慣れないとよく意味がわからない。結局、毎回argparseを使うごとにGoogleで検索するのがいつもの流れだった。

怒られるかもしれないが、つまるところ、argparseはPythonicではないと思っている。

代替ライブラリもcementなどが知られているが、どれも便利さやサブコマンドを考慮していて学習コストが重い。もうちょっといい感じにできないものか。

NAAM - No Argparse Any More

そういうわけで、関数定義を元にめっちゃいい感じに引数をパースしてくれるライブラリを作ってみた。その名も「NAAM - No Argparse Any More」だ。

PyPIにも公開したので、 pip install naam でインストールできる。Python 3.6で書いていて現状だとそれ以前のPython 3でも動くはず。ただしこれからType hintsまわりの実装をするためそのうちPython 3.6以降のみ動作対象になるだろう。

NAAMはとにかく使い方がシンプルで、引数をパースして渡して欲しい関数を naam.bind_args() でデコレートするだけだ。 例えば以下のような関数があったとする。

def hello(first_name, last_name=None):
    msg = 'Hello world! My name is %s.'
    if last_name is None:
        print(msg % first_name)
    else:
        print(msg % '{} {}'.format(first_name, last_name))

これは first_name を入れたHello Worldを表示し、もし last_name がNoneでなければフルネームで表示するというものだ。この関数をCLIツール化してみよう。

変更はimport文とデコレータと関数呼び出しを追加するだけである。空行を除いてDiffは実に3行しかない。

from naam import bind_args

@bind_args
def hello(first_name, last_name=None):
    msg = 'Hello world! My name is %s.'
    if last_name is None:
        print(msg % first_name)
    else:
        print(msg % '{} {}'.format(first_name, last_name))

hello()

このスクリプト(example/optional.py)を引数無しで実行すると、以下のような出力が出る。

$ python optional.py
Usage: optional.py [-l LAST_NAME | --last_name LAST_NAME] FIRST_NAME

おわかりだろうか。このUsageは完全に自動生成されている。キーワード引数はdefault値があることからoptionalな引数となっていることがわかる。

では名前だけ渡してみたらどうなるだろうか?

$ python optional.py Miku
Hello world! My name is Miku.

名前だけのHello Worldが表示される。

さらに、optionalな引数である LAST_NAME を渡してみる。

$ python optional.py --last_name Hatsune Miku
Hello world! My name is Miku Hatsune.

これはもちろん、短縮名でも可能だ。

$ python optional.py -l Hatsune Miku
Hello world! My name is Miku Hatsune.

ちなみに、頭文字が同じで短縮名がカブる場合は衝突を避けるようになっている。下のような関数 (example/duplicated.py)があった場合、

from naam import bind_args

@bind_args
def fn(name, lip=None, lap=None, loop=None):
    pass

fn()

以下のように表示される。

$ python duplicated.py
Usage: ./duplicated.py [-l LIP | --lip LIP] [-L LAP | --lap LAP] [-L2 LOOP | --loop LOOP] NAME

小文字→大文字→大文字+数字 という風に避けているが、これは好みの分かれるところでもあるし、デコレータに引数を渡すなどして動作を変えられるようにするかもしれない。

どうやって実装したか

一番キモなのは inspect.py だ。実行時にPythonコードをメタに解析するのに使われる標準ライブラリである。デコレータに渡ってきた関数を inspect.signature() に渡すと、その名の通り関数のシグネチャ情報が手に入る(もちろんアノテーション情報付き!)。そのシグネチャから引数を取り出し、順序有り引数と順序なし(optional)引数に分けた上で、短縮名の解決を行うと引数情報が完成する。最後に、実際に渡ってきた引数とこれを照らし合わせて実行するというのが全体の流れだ。

現在のコードはノリと勢いで書いたものなので決してエレガントではないが、 __init__.py に100行ちょっと書いた程度で軽く読めると思うので読みたい方はぜひ。

これからの展望

とりあえず動いたという程度なので、もうちょっとまともにしたい。(typedmarshalなどもっと先にエンハンスするべきものもあるが…)

  • シンプルなUsageだけでなく、読みやすいverboseなUsageを出す
  • Docstringをパースして説明文付きの丁寧なUsageを出す
  • --help に対応する
  • Type annotationを読んで適切にcastさせる
  • 値なしargument(フラグを立てるだけ)に対応する
  • サブコマンドに対応する

次回の担当は我らがでなり。お楽しみに!