ふつうのpython関数をサブコマンドとして扱えるようになった (ejectもあり)

github.com

今まではシングルコマンドだけの対応だったのだけれど、ふつうのpython関数をサブコマンドとして扱えるようになった。

handofcatsを作った当初は対応する気はなかったのだけれど、結局欲しくなったので実装してしまった。実装の過程 で結構ハマりどころはあったので後でどこかで話したいかもしれない(後述するeject機能などで手間取るポイントがいくつかある)。

作り始めの記事(正確にはリニューアル後の記事)

まだ更新自体はpypiにアップロードしていないけれどそのうちアップロードする。現状は2.x系なのだけれど、それなりに変更が激しかったのでバージョンは3.0.0などにするかもしれない。

handofcats?

そもそも話そうとしているパッケージが何かを知っている人は少ない気がする。なので紹介もしておく必要があるかもしれない。handofcatsという名前の通り、これは猫の手 (handofcats) も借りたいときに使いたいと言うようなパッケージ。つまり横着をしたいときに使うパッケージ。

(実ははじめはそのまま猫の手にしようかなと思ったりしたのだけれど、nekonoteは猫ノートになるなということで英語表記になった。複数形のsの位置が合っているかは未だに謎)

具体的にはpythonの関数をコマンドとして変換してくれるパッケージ。CLIとして利用できるようになる。

pythonの関数定義からCLIに変換

ここからは使い方の説明。例えば以下の様な関数定義があるとする。importしているデコレーターなどがhandofcatsで提供されているもの。コマンド化したい関数にas_commandデコレーターをつける。

01hello.py

import typing as t
from handofcats import as_command


@as_command
def hello(name: str, nick_name: t.Optional[str] = None) -> None:
    print(f"hello {name}")
    if nickname is not None:
        print(f"  {nickname}?")

このモジュールを他のモジュールからimportした場合は通常のpython moduleとして機能する。つまり関数はそのままimportしてふつうに関数として呼べる。

一方これを直接実行するとCLIとして機能する。

$ python 01hello.py foo
hello foo

$ python 01hello.py foo --nick-name F
hello foo
    F?

ここまではよくあるCLI用のパッケージ。違いは何かと言うと「やがて消えてなくなるライブラリ」ということ。この辺は以前の記事に書いていた。

自分の中でのライブラリの理想として、「やがて消えてなくなるライブラリ」というものがあります。これは、不要になったら淡雪のように溶けてなくなるようなライブラリが理想ということです。

消えてなくなる eject

消えてなくなるとなどと大げさに聞こえるようなことを言っいる気もするが、そこまで深い意味を見出す必要もないかもしれない。create-react-appのejectみたいな機能があるという理解だけで十分なきもする 。

handofcatsの場合は --expose オプションを実行すると内部で利用しているコードそのままの標準ライブラリだけに依存したコードを出力してくれる。そういうわけでゼロ依存を達成できる。つまり消えてなくなる。

$ python 01hello.py --expose | tee exposed.py
import typing as t


def hello(name: str, nick_name: t.Optional[str] = None) -> None:
    print(f"hello {name}")
    if nick_name is not None:
        print(f"    {nick_name}?")


def main(argv: t.Optional[t.List[str]] = None) -> t.Any:
    import argparse

    parser = argparse.ArgumentParser(prog=hello.__name__, description=hello.__doc__, formatter_class=type('_HelpFormatter', (argparse.ArgumentDefaultsHelpFormatter, argparse.RawTextHelpFormatter), {}))
    parser.print_usage = parser.print_help  # type: ignore
    parser.add_argument('name', help='-')
    parser.add_argument('--nick-name', required=False, help='-')
    args = parser.parse_args(argv)
    params = vars(args).copy()
    return hello(**params)


if __name__ == '__main__':
    main()

もちろん、生成されたコードは同様にCLIとして利用できる。このように最終的に消えてなくなることを意図している。

$ python exposed.py foo
hello foo

$ python exposed.py foo --nick-name F
hello foo
    F?

ヘルプメッセージ。

$ python exposed.py -h
usage: hello [-h] [--nick-name NICK_NAME] name

positional arguments:
  name                  -

optional arguments:
  -h, --help            show this help message and exit
  --nick-name NICK_NAME
                        - (default: None)

handofcatsコマンド

このパッケージをインストールするとhandofcatsというコマンドが使えるようになる。このコマンドもしくは python -m handofcatsを使うことでも標準ライブラリのみに依存したコードの生成ができる。

実は先程までのデコレーターなどはなくても良い。例えばデコレーターを除いた以下の様なコードを元に直接変換させる事もできる。

02hello.py

import typing as t


def hello(name: str, nick_name: t.Optional[str] = None) -> None:
    print(f"hello {name}")
    if nick_name is not None:
        print(f"  {nick_name}?")

単にargparseを書くというのが面倒というむきにはいきなりここから始めても良いかもしれない。<module>:<attr> という形式で指定する。

$ handofcats 02hello.py:hello --expose | tee exposed.py
...
# 出力されるコードは同じものなので省略
...

formatter_class

これはちょっとした余談なのだけど、生成されたコードに見慣れないクラスから動的にクラスを生成していた部分があった。formatter_classという引数に渡していた値。それぞれは以下の様な機能を持っている。

  • argparse.ArgumentDefaultsHelpFormatter -- デフォルト値をヘルプメッセージの末尾につけてくれる。ただし Noneでは無視されてしまうので"-"という値をヘルプメッセージのデフォルトにしている。
  • argparse.RawTextHelpFormatter -- argparseのヘルプ表示はコンソールの幅を気にして自動的に折り返してくれる。おそらく古くは便利だったのだと思うのだけれど、これが有効になってしまった場合にdocstringなどで定義したレイアウトを破壊してしまうのでそれを止めさせている。

ドキュメントには書いてあるがおそらくあまり知られていない内容。

__init__() をオーバーライドしていないから多重継承しても大丈夫ではあるけれど、今ならmix-inにするかcompositionにするなーというような感想を持ったりした )

--inplace

--inplace オプションを付けると自身のファイルを上書きしてくれるので便利。普段はもっぱらこちらを使う。handofcats的にはejectは概ね --expose --inplace

ここまでが復習。

サブコマンドが使えるようになった

ここからが本題。今回の変更でサブコマンドが使える様になった。サブコマンドは以下の様にして使う。冒頭のシングルコマンドのas_commandに対応したas_subcommandという名前のデコレーターが用意されている。

03cli.py

import typing as t
from handofcats import as_subcommand


@as_subcommand
def hello(name: str, nick_name: t.Optional[str] = None) -> None:
    print(f"hello {name}")
    if nick_name is not None:
        print(f"  {nick_name}?")


@as_subcommand
def byebye(names: t.List[str]) -> None:
    print(f"byebye {', '.join(names)}")


as_subcommand.run()

注意点としてas_subcommand.run()を呼んであげる必要がある。この辺りちょっと不便さを感じるものではあるけれど。対象の関数の数が1つではなく複数になった結果終わりを伝える方法が必要になった。これにrun()を利用している。

run()if __name__ == "__main__" で囲む必要はない。囲んでも害はない。

サブコマンドの場合も同様の形式で使える(hello()関数を使ってみる)。

$ python 03cli.py hello foo
hello foo

$ python 03cli.py hello foo --nick-name F
hello foo
    F?

$ python 03cli.py hello -h
usage: 03cli.py hello [-h] [--nick-name NICK_NAME] name

positional arguments:
  name                  -

optional arguments:
  -h, --help            show this help message and exit
  --nick-name NICK_NAME
                        - (default: None)

他のコマンドも使ってみる(今度はbyebye()関数を使ってみる)。

$ python 03cli.py byebye foo bar boo
byebye foo, bar, boo

$ python 03cli.py byebye -h
usage: 03cli.py byebye [-h] [names [names ...]]

positional arguments:
  names       - (default: None)

optional arguments:
  -h, --help  show this help message and exit

eject

同様に標準ライブラリのみに依存したコードを出力できる。

$ python 03cli.py --expose | tee exposed.py
import typing as t


def hello(name: str, nick_name: t.Optional[str] = None) -> None:
    print(f"hello {name}")
    if nick_name is not None:
        print(f"    {nick_name}?")


def byebye(names: t.List[str]) -> None:
    print(f"byebye {', '.join(names)}")


def main(argv: t.Optional[t.List[str]] = None) -> t.Any:
    import argparse

    parser = argparse.ArgumentParser(formatter_class=type('_HelpFormatter', (argparse.ArgumentDefaultsHelpFormatter, argparse.RawTextHelpFormatter), {}))
    subparsers = parser.add_subparsers(title='subcommands', dest='subcommand')
    subparsers.required = True

    fn = hello
    sub_parser = subparsers.add_parser(fn.__name__, help=fn.__doc__, formatter_class=parser.formatter_class)
    sub_parser.add_argument('name', help='-')
    sub_parser.add_argument('--nick-name', required=False, help='-')
    sub_parser.set_defaults(subcommand=fn)

    fn = byebye  # type: ignore
    sub_parser = subparsers.add_parser(fn.__name__, help=fn.__doc__, formatter_class=parser.formatter_class)
    sub_parser.add_argument('names', nargs='*', help='-')
    sub_parser.set_defaults(subcommand=fn)

    args = parser.parse_args(argv)
    params = vars(args).copy()
    subcommand = params.pop('subcommand')
    return subcommand(**params)


if __name__ == '__main__':
    main()

自分が標準ライブラリのargparseでサブコマンドのCLIを書くときに使っている構成と概ね同様の記述になっている。

def f(*, name:str, ...)subcommand(**params)

pythonに限って言うと、positional argumentsが一切ない引数なら、引数名をキーにした辞書を渡してもうまくいく(一方でこのために *args のような可変長引数には対応していない)。この形がとても良いのは、関数定義の引数とコマンドラインパーサー側のオプションに過不足があった場合にはエラーになる点。

個人的にはこのようにしてmainから実行される関数は、すべてをkeyword only argumentsにしている。その方がが辞書との関連がわかりやすく見通しが良いので。一方ですべてがCLIオプションとしてはフラグになるので文字列のリストを受け取るような引数を取る関数には向いていない (先程の例のbyebyeが該当)。

ベストプラクティスというと大げさではある気がするけれど、個人的にはおすすめの構成。

fnという変数への再代入

もう一つ特徴があるとすれば、すべてのサブコマンドのオプション定義をfnという変数から経由した同名の変数を利用している。これにも利点がある。

サブコマンドを持つCLIを作っているときに、複数のサブコマンドが同じ定義の同名のオプションを取りたいということがそれなりの頻度で発生する。このときそれぞれの変数名が異なっていると書き換えが必要になる。

書き換えが必要になるだけなら良いが、誤ってコピペで済ませた場合に全く有効にならないオプションを定義しておしまいという状態になったりする。正直なところCLIのオプション定義などはテストなどを書くこともなく割と気を抜いて実装することが多い。うかつな気質の人はこのままリリースしそうになることがある。

例えば以下の様なコードは嬉しくない(debugというオプションをhelloにもbyebyeにも持たせたいとする)。

    hello_parser.add_argument('--debug', action='store_true', help='-')

    byebye_parser = subparsers.add_parser(byebye.__name__, help=byebye.__doc__)
    byebye_parser.add_argument('names', nargs='*', help='-')
    # ↓ this
    hello_parser.add_argument('--debug', action='store_true', help='-')
    byebye_parser.set_defaults(subcommand=byebye)

これが地味に癪に障ったりするので同名の変数が良い。

handofcatsコマンド

もちろんhandofcatsコマンドにも対応している。シングルコマンドの場合には<module>:<attr>という形式だったが、サブコマンドの場合には<module>という書式を利用する。このようにするとサブコマンドとして扱われる。

ここでもデコレーターが必要のない。まっさらなただのpythonの関数を対象にCLIとして呼び出す事ができる。

04cli.py

def hello(*, name: str = "world"):
    print(f"hello {name}")


def byebye(*, name: str):
    print(f"byebye {name}")


# ignored
def _ignore(name: str):
    print("ignored")

_で始まる関数名のものは無視される。as_subcommandデコレーターが使われていた場合にはそちらの記述を尊重する。

$ handofcats 04cli.py -h
usage: handofcats [-h] [--expose] [--inplace] [--untyped]
                  [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                  {hello,byebye} ...

optional arguments:
  -h, --help            show this help message and exit
  --expose              dump generated code. with --inplace, eject from handofcats dependency (default: False)
  --inplace             overwrite file (default: False)
  --untyped             untyped expression is dumped (default: False)
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {hello,byebye}
    hello
    byebye


$ handofcats 04cli.py hello -h
usage: handofcats hello [-h] [--name NAME]

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  - (default: world)


$ handofcats 04cli.py byebye -h
usage: handofcats byebye [-h] --name NAME

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  - (default: None)

ところでこの辺りでデコレーターなしに利用できるならデコレーターの方の実装は不要なのでは?という気持ちも湧いてくる。湧いてきた。実際のところ、過去の実装では正規表現でごまかしていた依存部分の削除をASTベースでハンドリングするような力技が必要になったりした。それなりにコストも掛かっている。

ただの追加よりも既にあるものを除去することは意外と難しい。あるいは既にある関係性の中に対応した形式で挿入するような処理。たとえば、from __future__ import xxx 形式のものはファイルの先頭にあってほしい、また今回の変更で型付きの出力をデフォルトにしたが、typingモジュールのimportも重複があると格好が悪い。

とはいえ、以下の2つの理由があるのでもう少し様子見をしてみようという気持ちになった。

  • ユーザーから見て.pyのファイルがあればpythonインタプリタで実行しようとするのは自明。しかし特殊なコマンドを明示的に実行することを思いつくには距離がある。
  • デコレーターは複数ある候補(関数群)の中から公開する機能(関数)を選択するフィルターとしての機能と考えることができなくもない。

monogusaとの違いは?

今までシングルコマンドのみの単機能のということで割と冷ための扱いをしていたhandofcatsが、サブコマンドに対応したことで、monogusacliモジュールに近い使用感になってきた。monogusaというパッケージを知らない人のほうが多いかもしれない。なのでここからはちょっとした一人語りということにする。ちょっとした脱線。ちょっとした一人語り。

monogusaについての説明はこの辺の記事を。

実際、monogusaの基礎部分ではhandofcatsのコードが一部使われている。一方でこれはこの記事としては寄り道ではあるけれど、それならmonogusaとサブコマンドに対応したhandofcatsの違いはなんなのか?という疑問が湧いてくる。これについて考えみたい。

少し考えてみたところ、以下の様な違いがあるように感じた。

  • monogusaは、コアの機能定義を持ち上げて、ユーザーの提供範囲を広くして公開する機能
  • handofcatsは、素朴な関数をCLIコマンドとして扱うための機能
  • monogusaは、DI的な機能を持っている。handofcatsはそのようなマジカルな機能は存在しない。
  • handofcatsは、ゼロ(ゼロ依存)に立ち帰ることを目的としている。このときコードは残る。
  • monogusは、一時的な利用としての試行に使う。本番用の機能としてデプロイする場合は基盤にならない

あといちばん大きいのはmonogusaは実行基盤だけれど、handofcatsはコマンドのディスパッチャーのように機能する点。例えばmonogusaは非同期の関数をそのまま呼べる機能がついている。一方でhandofcatsでそのような操作をしたい場合には、各サブコマンドの中で各自asyncio.run()などを呼ぶことになる。

もちろんmonogusaにもeject的な機能を強化する事があるかもしれない。脱線してしまった。

まとめ

handofcatsがサブコマンドに対応した。いろいろ裏側は大変だった。

ちなみに細かな変更として以下等がある

  • 裏側の実装をほぼ書き直した
  • 型付きの出力をデフォルトにした(--typedオプションだったのが--untypedオプションに)
  • (実は内部で多段でArugmentParserを使っているのだけれど)先頭の引数の解析を丁寧にやるようにした
  • readmeがmarkdownになった(前はReSTだった)
  • default値のヘルプ表示を自前で表示するのを止めた(過去の実装ではemitした瞬間に固定されてしまうので不便)

ちなみに最近の記事であげたDEBUG=1でのログレベルの変更の機能はまだ生成結果に入っていない。このような機能を有効無効にするかという制御のコンポーネントをかけるようにしたという変更も実は裏側ではあったりする。

gist