最近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
swagger-marshmallow-codegen でpaths以下も見るようにした
swagger-marshmallow-codegen でpaths以下も見るようにした。あまりきれいとはいえない感じかもしれないけれど。
paths以下を見るということ
今まではdefinitions以下しか見なかったのだけれど。通常swaggerでapiの定義をするときにはpaths以下にも色々書く。というよりrequestとresponseがどのようになっているかはpaths以下の定義を見る事が多い。例えば以下のようなAPI定義(apiaryのtuotialからもらってきた)はどうなっているかというと。
paths: /message/{name}: x-summary: Message operations x-description: Operation description in Markdown get: summary: Get a message of the day description: | Description of the operation in Markdown operationId: getMessage parameters: - name: name in: path description: name to include in the message type: string x-example: 'Hello, Adam!' responses: default: description: Bad request 200: description: Successful response schema: $ref: '#/definitions/Message' examples: 'application/json': message: 'Hello, Adam!' definitions: Message: required: - message properties: message: type: string default: 'Hello, Adam!'
これは GET /message/{name}
というようなAPIが存在していて、そのrequestの形式で許可するものはpathのみ(apiaryのsampleはqueryになっていたけれどそれは間違い。こちらでは修正している)。また、outputとしてstatus=200
のresponseは Message
のschemaになっている。
Input, Output
先程のAPI定義からswagger-marshmallo-codegenを使ってschemaのコードを生成してみる。今度からInput,Outputというclassも生成されるようになった。具体的には以下の様なもの。
# -*- coding:utf-8 -*- from marshmallow import ( Schema, fields ) class Message(Schema): message = fields.String(required=True, missing=lambda: 'Hello, Adam!') class MessageNameInput(object): class Get(object): """Get a message of the day""" class Path(Schema): name = fields.String(description='name to include in the message') class MessageNameOutput(object): class Get200(Message): """Successful response""" pass
APIのrequestとresponse毎にInput,Outputが存在していてそのバリエーション毎にクラスが別れている。外側のクラスはnamespaceみたいなもの。
今回のAPIに関して言えば、以下のようなrequestとresponseになる。
GET /message/adam {"message": "Hello, Adam!"}
それぞれ MessageNameInput
と MessageNameOutput
が対応している。
x-marshmallow-name
Input,Outputの名前はpathのpatternからすごく雑に変換して決めている。
/message/{name} -> /message/name -> message, name -> MessageName
気に入らない場合もあるだろうから、 x-marshmallow-name
で名前を決められるようにした。
--- 00schema.yaml 2017-01-17 06:29:00.000000000 +0900 +++ 01schema.yaml 2017-01-17 06:33:33.000000000 +0900 @@ -2,6 +2,7 @@ /message/{name}: x-summary: Message operations x-description: Operation description in Markdown + x-marshmallow-name: Message get: summary: Get a message of the day description: |
以下のような修正を加えると出力結果は以下の様に変わる。
# -*- coding:utf-8 -*- from marshmallow import ( Schema, fields ) class Message(Schema): message = fields.String(required=True, missing=lambda: 'Hello, Adam!') class MessageInput(object): class Get(object): """Get a message of the day""" class Path(Schema): name = fields.String(description='name to include in the message') class MessageOutput(object): class Get200(Message): """Successful response""" pass
もう少し複雑なもの
Path以外にもparametersは色々ある。query,formData,body,header (詳しくはここ)。それらも見る。
例えばGithubのAPIの一部のAPI定義をすこしだけ弄った以下のようなyamを渡すと以下のようなコードを生成する。
definitions: emailsPost: items: type: string pattern: ".+@.+" type: array label: properties: color: maxLength: 6 minLength: 6 type: string name: type: string url: type: string type: object labels: items: $ref: '#/definitions/label' type: array labelsBody: items: type: string type: array parameters: owner: description: Name of repository owner. in: path name: owner required: true type: string repo: description: Name of repository. in: path name: repo required: true type: string number: description: Number of issue. in: path name: number required: true type: integer X-Github-Media-Type: description: | You can check the current version of media type in responses. in: header name: X-GitHub-Media-Type type: string Accept: description: Is used to set specified media type. in: header name: Accept type: string X-RateLimit-Limit: in: header name: X-RateLimit-Limit type: integer X-RateLimit-Remaining: in: header name: X-RateLimit-Remaining type: integer X-RateLimit-Reset: in: header name: X-RateLimit-Reset type: integer X-GitHub-Request-Id: in: header name: X-GitHub-Request-Id type: integer responses: labels: description: OK schema: $ref: '#/definitions/labels' label-created: description: Created schema: $ref: '#/definitions/label' paths: '/repos/{owner}/{repo}/issues/{number}/labels': delete: description: Remove all labels from an issue. parameters: - $ref: "#/parameters/owner" - $ref: "#/parameters/repo" - $ref: "#/parameters/number" - $ref: "#/parameters/X-Github-Media-Type" - $ref: "#/parameters/Accept" - $ref: "#/parameters/X-RateLimit-Limit" - $ref: "#/parameters/X-RateLimit-Remaining" - $ref: "#/parameters/X-RateLimit-Reset" - $ref: "#/parameters/X-GitHub-Request-Id" responses: '204': description: | No content. '403': description: | API rate limit exceeded. See http://developer.github.com/v3/#rate-limiting for details. get: description: List labels on an issue. parameters: - $ref: "#/parameters/owner" - $ref: "#/parameters/repo" - $ref: "#/parameters/number" - $ref: "#/parameters/X-Github-Media-Type" - $ref: "#/parameters/Accept" - $ref: "#/parameters/X-RateLimit-Limit" - $ref: "#/parameters/X-RateLimit-Remaining" - $ref: "#/parameters/X-RateLimit-Reset" - $ref: "#/parameters/X-GitHub-Request-Id" responses: '200': $ref: "#/responses/labels" '403': description: | API rate limit exceeded. See http://developer.github.com/v3/#rate-limiting for details. x-marshmallow-name: IssuedLabels post: description: Add labels to an issue. parameters: - $ref: "#/parameters/owner" - $ref: "#/parameters/repo" - $ref: "#/parameters/number" - $ref: "#/parameters/X-Github-Media-Type" - $ref: "#/parameters/Accept" - $ref: "#/parameters/X-RateLimit-Limit" - $ref: "#/parameters/X-RateLimit-Remaining" - $ref: "#/parameters/X-RateLimit-Reset" - $ref: "#/parameters/X-GitHub-Request-Id" - in: body name: body required: true schema: $ref: '#/definitions/emailsPost' responses: '201': $ref: "#/responses/label-created" '403': description: | API rate limit exceeded. See http://developer.github.com/v3/#rate-limiting for details. put: description: | Replace all labels for an issue. Sending an empty array ([]) will remove all Labels from the Issue. parameters: - $ref: "#/parameters/owner" - $ref: "#/parameters/repo" - $ref: "#/parameters/number" - $ref: "#/parameters/X-Github-Media-Type" - $ref: "#/parameters/Accept" - $ref: "#/parameters/X-RateLimit-Limit" - $ref: "#/parameters/X-RateLimit-Remaining" - $ref: "#/parameters/X-RateLimit-Reset" - $ref: "#/parameters/X-GitHub-Request-Id" - in: body name: body required: true schema: $ref: '#/definitions/emailsPost' responses: '201': $ref: "#/responses/label-created" '403': description: | API rate limit exceeded. See http://developer.github.com/v3/#rate-limiting for details.
こういう感じ。
# -*- coding:utf-8 -*- from marshmallow import ( Schema, fields ) from marshmallow.validate import ( Length, Regexp ) from swagger_marshmallow_codegen.schema import ( PrimitiveValueSchema ) import re class Label(Schema): color = fields.String(validate=[Length(min=6, max=6, equal=None)]) name = fields.String() url = fields.String() class IssuedLabelsInput(object): class Delete(object): class Header(Schema): X_GitHub_Media_Type = fields.String(description='You can check the current version of media type in responses.\n', dump_to='X-GitHub-Media-Type', load_from='X-GitHub-Media-Type') Accept = fields.String(description='Is used to set specified media type.') X_RateLimit_Limit = fields.Integer(dump_to='X-RateLimit-Limit', load_from='X-RateLimit-Limit') X_RateLimit_Remaining = fields.Integer(dump_to='X-RateLimit-Remaining', load_from='X-RateLimit-Remaining') X_RateLimit_Reset = fields.Integer(dump_to='X-RateLimit-Reset', load_from='X-RateLimit-Reset') X_GitHub_Request_Id = fields.Integer(dump_to='X-GitHub-Request-Id', load_from='X-GitHub-Request-Id') class Path(Schema): owner = fields.String(description='Name of repository owner.') repo = fields.String(description='Name of repository.') number = fields.Integer(description='Number of issue.') class Get(object): class Header(Schema): X_GitHub_Media_Type = fields.String(description='You can check the current version of media type in responses.\n', dump_to='X-GitHub-Media-Type', load_from='X-GitHub-Media-Type') Accept = fields.String(description='Is used to set specified media type.') X_RateLimit_Limit = fields.Integer(dump_to='X-RateLimit-Limit', load_from='X-RateLimit-Limit') X_RateLimit_Remaining = fields.Integer(dump_to='X-RateLimit-Remaining', load_from='X-RateLimit-Remaining') X_RateLimit_Reset = fields.Integer(dump_to='X-RateLimit-Reset', load_from='X-RateLimit-Reset') X_GitHub_Request_Id = fields.Integer(dump_to='X-GitHub-Request-Id', load_from='X-GitHub-Request-Id') class Path(Schema): owner = fields.String(description='Name of repository owner.') repo = fields.String(description='Name of repository.') number = fields.Integer(description='Number of issue.') class Post(object): class Body(PrimitiveValueSchema): v = fields.String(validate=[Regexp(regex=re.compile('.+@.+'))]) class Header(Schema): X_GitHub_Media_Type = fields.String(description='You can check the current version of media type in responses.\n', dump_to='X-GitHub-Media-Type', load_from='X-GitHub-Media-Type') Accept = fields.String(description='Is used to set specified media type.') X_RateLimit_Limit = fields.Integer(dump_to='X-RateLimit-Limit', load_from='X-RateLimit-Limit') X_RateLimit_Remaining = fields.Integer(dump_to='X-RateLimit-Remaining', load_from='X-RateLimit-Remaining') X_RateLimit_Reset = fields.Integer(dump_to='X-RateLimit-Reset', load_from='X-RateLimit-Reset') X_GitHub_Request_Id = fields.Integer(dump_to='X-GitHub-Request-Id', load_from='X-GitHub-Request-Id') class Path(Schema): owner = fields.String(description='Name of repository owner.') repo = fields.String(description='Name of repository.') number = fields.Integer(description='Number of issue.') class Put(object): class Body(PrimitiveValueSchema): v = fields.String(validate=[Regexp(regex=re.compile('.+@.+'))]) class Header(Schema): X_GitHub_Media_Type = fields.String(description='You can check the current version of media type in responses.\n', dump_to='X-GitHub-Media-Type', load_from='X-GitHub-Media-Type') Accept = fields.String(description='Is used to set specified media type.') X_RateLimit_Limit = fields.Integer(dump_to='X-RateLimit-Limit', load_from='X-RateLimit-Limit') X_RateLimit_Remaining = fields.Integer(dump_to='X-RateLimit-Remaining', load_from='X-RateLimit-Remaining') X_RateLimit_Reset = fields.Integer(dump_to='X-RateLimit-Reset', load_from='X-RateLimit-Reset') X_GitHub_Request_Id = fields.Integer(dump_to='X-GitHub-Request-Id', load_from='X-GitHub-Request-Id') class Path(Schema): owner = fields.String(description='Name of repository owner.') repo = fields.String(description='Name of repository.') number = fields.Integer(description='Number of issue.') class IssuedLabelsOutput(object): class Get200(Label): """OK""" def __init__(self, *args, **kwargs): kwargs['many'] = True super().__init__(*args, **kwargs) class Post201(Label): """Created""" pass class Put201(Label): """Created""" pass