最近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!"}

それぞれ MessageNameInputMessageNameOutput が対応している。

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 (詳しくはここ)。それらも見る。

例えばGithubAPIの一部の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