最近pythonでcliのコマンドを作る時にやっていること

最近cliのコマンドを作る時にやっていることをまとめてみる。ここでのコマンドは特にパッケージとして提供されるシェルなどから実行されるコマンドのことを指している。

何が問題?

特にパッケージの提供者とパッケージのユーザーの望みが全く乖離せず一致している場合は問題がない。ユーザーが必要としている機能をパッケージの作者が提供すれば良い。 問題はところどころカスタマイズしたくなるような場合。このようなケースは自分がパッケージの作者でありユーザーである時によく発生するので面白い。パッケージの機能としては含めたくないものの現在のプロジェクトの範疇では必要となる、ただし新たなサブパッケージの様な何かを作る程汎用性があるとは思えないというような場合など。このような時にどうすれば良いのかということについてある程度回答ができるようになったのでまとめてみる。

おさらい

上の問題についてとりあえずpythonでの話しに限定して書いてみることにする。その前にpythonについてのおさらいの様な説明を書く。例えば、簡単なhelloというコマンドを作ってみる。

$ hello
hello world
$ hello --target someone
hello someone

実行したら hello world というメッセージを出力して終了する(実際に作成するコードではまともな何らかの処理になるイメージ)。--targetオプションで指定した文字列をworldの代わりに表示する。

def run(target):
    print("hello {target}".format(target=target))


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--target", default="world")
    args = parser.parse_args()
    run(args.target)


if __name__ == "__main__":
    main()

pythonの場合は以下のようなsetup.pyを書いてあげるとパッケージとしてインストールできるようになる。

from setuptools import setup

setup(name='hello',
      version='0.0',
      description='hello',
      packages=['.'],
      entry_points="""
      [console_scripts]
hello=hello:main
"""
)

現在は以下のような状況。pip install -e . などでインストールしてみる。

$ tree
├── hello.py
└── setup.py
$ pip install -e .

パッケージとしてインストールされていれば。他のパッケージからimportすることもできるし-mオプション経由でpythonコマンドから呼び出す事もできる。

$ python
Python 3.5.2 (default, Sep 19 2016, 02:49:52) 
[GCC 4.2.1 Compatible Apple LLVM 7.3.0 (clang-703.0.31)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import hello
>>> hello.run("world")
hello world
>>>
$ python -m hello
hello world
$ python -m hello --target someone
hello someone

また、上のsetup.pyでは console_scripts の設定も書いたので、helloで呼び出す事もできる。

$ hello
hello world

ここまでがおさらい。パッケージ(ここではhello パッケージ)の提供者が何らかの機能(ここでは hello worldと表示するだけ)を提供しているという状態になった。

本題

ここからが本題。さてこのhelloパッケージを便利に使っているとする。ところでちょっとした機能の変更を加えたいとする。それはほんの1行に過ぎない変更かもしれない。あるいはその変更が良いものとして恒久的に残りうるものとも限らない。そんなある意味独善的だったり個人的な変更を少しだけ加えたい。このような場合にどうするかという話。

ユーザーが自分で独自のコマンドを作っている場合

ユーザーが自分の手で元のパッケージのコードをライブラリレベルで使っていてそれをラップしたようなコマンドを作っている場合は特に何も気にしなくて良い。通常コードを書くときと同様に対応すれば良い。ここではあまり問題にならない。

ユーザーがコマンドを単に利用者として使っている場合

ユーザーが提供されているコマンドをそのまま使っている場合。こちらの場合に問題が起きる。例えばhelloの代わりにgoobyeを表示するように変えたいとする。このようなときには、わざわざラッパー用のコマンドを作るだったりパッケージを作り直さ無くてはいけない(ここが面倒くさい)。つまりユーザーが自分で独自のコマンドを作らないといけない。

最近やっていること

そんなわけでちょっとした変更を加えたい時にちょっとした変更が加えられるコマンドをどのように作るべきかみたいなことを色々考えた結果、以下の様な形にするというのが良いという結論になった。

$ hello
hello world
$ hello --driver=./my.py:MyDriver
goodbye world

やっていることは単純で --driver というオプションを渡せるようにするということ。--driverに渡す文字列は利用したいdriverのパス。 インストールされているパッケージを利用するなら以下の様に渡す。

--driver foo.bar.boo:OurDriver

とは言え、このままであれば別途パッケージを作ってインストールしたり環境変数のPYTHONPATHにわざわざ入れてあげたりしなければ使えないので不便。ということで物理的なファイルのパスも受け取れるようにする。

--driver ./my.py:MyDriver

コードは以下の様になる。

import magicalimport


class Driver:
    def run(self, target):
        print("hello {target}".format(target=target))


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--target", default="world")
    parser.add_argument("--driver", default="{}:Driver".format(__name__))
    args = parser.parse_args()

    driver_class = magicalimport.import_symbol(args.driver, sep=":")
    driver = driver_class()
    driver.run(args.target)


if __name__ == "__main__":
    main()

magicalimportは個人的に作ったライブラリで物理的なファイルパスを指定してのimportをサポートするもの。そんなに大きなライブラリというわけでもないので依存したくなければ中のコードを除いて自分で似たような機能のものを作っても良い。

このようにdriverというオプションで渡した文字列からコマンドの実行用のインスタンスを作成するという仕組みにしておくと後で捗ると言うことが分かった。 例えば以下の様にして挙動を変えられる。

$ hello
hello world
$ hello --target=world --driver=./my.py:MyDriver
goodbye world

このときmy.pyは以下のようなもの。

class MyDriver:
    def run(self, target):
        print("goodbye {target}".format(target=target))

これでちょっとした思いつきで拡張したコード片をテキトウに置いておき、それを --driverオプションに渡すというような形でちょっとした挙動の変更ができるようになる。これがちょっとした試行錯誤に都合が良いと最近は思っている。

応用例

例えば、最近作った swagger-marshmallow-codegenなどでもこの方法は使われている。これはswaggerの定義ファイル(APIの仕様をjsonschemaに似た形式で書いたファイル)からmarshmallow(schemaライブラリ)のコードを生成するコマンドを提供している。

そして最近のおしごとではmongodbを使っているので、bson.ObjectIdをサポートしたschemaが生成したいという要求があった。ところが個人的な信条として独自にmongodb用のコードをここには含めたくない。一方でmongodbに対応した別のパッケージ(リポジトリ)を作る気も起きなかった。このような時に先程の様にdriverをオプションとして渡してあげられるようになったので便利になった。パッケージ自体の作成者も自分ではあるけれど。先にdriver経由のところで実装してみて良さそうと思ったら元のパッケージに反映させるみたいなことをやったりしている。

$ swagger-marshmallow-codegen --driver=./me:CustomDriver swagger.yaml > app/schema.py