サブコマンドをincludeで自由に追加できるようにした

github.com

サブコマンドをincludeで自由に追加できるようにした。

デフォルトで利用できるサブコマンド

例えば egoist init clikit で生成されるdefinitions.pyは以下の様なサブコマンドを持っている。

  • describe
  • generate
  • scan
usage: definitions.py [-h]
                      [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                      {describe,generate,scan} ...

optional arguments:
  -h, --help            show this help message and exit
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {describe,generate,scan}
    describe
    generate
    scan

このときのdefinitions.pyは以下の様なコード。大切なのはcreate_app()app.include()の部分だけ。

from egoist.app import create_app, SettingsDict, parse_args

settings: SettingsDict = {"rootdir": "cmd/", "here": __file__}
app = create_app(settings)

app.include("egoist.directives.define_cli")


# hello/main.goの生成用の定義。ただし今回の記事では使われていない。
@app.define_cli("egoist.generators.clikit:walk")
def hello(*, name: str) -> None:
    """hello message"""
    from egoist.generators.clikit import runtime, clikit

    with runtime.generate(clikit):
        runtime.printf("hello %s\n", name)


if __name__ == "__main__":
    for argv in parse_args(sep="-"):
        app.run(argv)

create_app()の中身

最近の変更でサブコマンドの追加も自由に行えるようになった。デフォルトのサブコマンドはcreate_app()の中で行われている。定義はこのような感じ。

def create_app(settings: SettingsDict) -> App:
    app = App(t.cast(t.Dict[str, t.Any], settings))
    app.include("egoist.commands.describe")
    app.include("egoist.commands.generate")
    app.include("egoist.commands.scan")
    return app

そう。実際のところはAppを作って先程の3つのコマンドをincludeしているだけ。

欲しいコマンドを絞ってincludeする

そんなわけで、欲しいコマンドを絞ってincludeすることで余分なサブコマンドをない形にできる。あるいは、自分の好みのサブコマンドを追加する事ができる。

例えばgenerateだけを持つようにするには以下の様にする。

--- ../00hello/definitions.py    2020-05-19 00:28:02.000000000 +0900
+++ definitions.py    2020-05-19 07:44:27.000000000 +0900
@@ -1,8 +1,9 @@
-from egoist.app import create_app, SettingsDict, parse_args
+from egoist.app import App, SettingsDict, parse_args
 
 settings: SettingsDict = {"rootdir": "cmd/", "here": __file__}
-app = create_app(settings)
+app = App(settings)
 
+app.include("egoist.commands.generate")
 app.include("egoist.directives.define_cli")

直接自分でAppを作って、"egoist.commands.generate"だけをinclude。実際このときのhelpを見ると以下のようにgenerateだけが使える様になる。

$ python definitions.py -h
usage: definitions.py [-h]
                      [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                      {generate} ...

optional arguments:
  -h, --help            show this help message and exit
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {generate}
    generate

どうしてこのようにサブコマンドを自由に追加できるような機能が欲しくなったかというと、オプショナルなコマンドを追加したくなったため。具体的には依存関係をMakefileに落とし込むようなコマンドが欲しくなったが、それはさすがに全ての人に公開する必須のコマンドというほどでもなく、利用したい状況は絞られるかなーと思ったので。

せっかくなので、そのサブコマンドの追加という方法自体、をユーザーに公開してしまおうという流れでこのような形になった。

自作のサブコマンド

自分でサブコマンドを追加してみることにする。argparseのparserの設定と選択されたときに実行されるコマンドがあれば十分。

以下の様な感じで書けば良い。テキトーに現在時刻を表示するようなnowコマンドを作ってみる。

実行例。

$ python definitions.py now -h
usage: definitions.py now [-h] [--format FORMAT]

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

$ python definitions.py now
2020-05-19T07:58:47

$ python definitions.py now --format="%Y-%m-%d"
2020-05-19

commands/now.py に定義してみることにする。

$ tree
.
├── commands
│   ├── __pycache__
│   │   └── now.cpython-38.pyc
│   └── now.py
└── definitions.py

2 directories, 3 files

TYPE_CHECKINGのif文で囲っているのは、mypyのときだけ読み込みたいから。

commands/now.py

from __future__ import annotations
import typing as t
from functools import partial
from egoist.app import App
from egoist.types import AnyFunction

if t.TYPE_CHECKING:
    from argparse import ArgumentParser


def now(app: App, *, format: str,) -> None:
    from datetime import datetime

    now = datetime.now()
    print(now.strftime(format))


def setup(app: App, sub_parser: ArgumentParser, fn: AnyFunction) -> None:
    sub_parser.add_argument("--format", default="%Y-%m-%dT%H:%M:%S%z")
    # ここのpartialは後で不要にするかもしれない
    sub_parser.set_defaults(subcommand=partial(fn, app))


def includeme(app: App) -> None:
    app.include("egoist.directives.add_subcommand")
    app.add_subcommand(setup, fn=now)

本体がnow()で、それをサブコマンドとして設定するのがsetup()。includemeはapp.include()から読まれたときに自動で実行されるコールバック関数。このあたりはpyramidのconfiguratorの振る舞いを参考にしている。

definitions.pyは以下の様に書き換える。

--- ../01exclude/definitions.py  2020-05-19 07:44:27.000000000 +0900
+++ definitions.py    2020-05-19 07:55:34.000000000 +0900
@@ -4,6 +4,7 @@
 app = App(settings)
 
 app.include("egoist.commands.generate")
+app.include("commands.now")
 app.include("egoist.directives.define_cli")

このようにするとnowというサブコマンドが実行できるようになる。

$ python definitions.py now
2020-05-19T07:58:47

実際サブコマンドの一覧に登録したnowが存在している。

$ python definitions.py
usage: definitions.py [-h] [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                      {generate,now} ...

optional arguments:
  -h, --help            show this help message and exit
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {generate,now}
    generate
    now
definitions.py: error: the following arguments are required: subcommand

特にcwdにdefinitions.pyがなければダメというわけではないので。1つ階層を登ったところからでも実行できる(config.include()に渡すモジュールのパスををdefintions.pyからの相対位置として記述できる)。

$ cd ../
$ python 02*/definitions.py now
2020-05-19T08:02:09

はい。

まとめ

  • egoistでサブコマンドを自由に選り好みできるようになった
  • 自作のサブコマンドを登録する事もできる
  • 例えば、特定の環境だけで便利なサブコマンドは特別にincludeして使いたい

ただ、全部を明示的に扱うということを考えると、create_app()でwrapするのはきれいではないかもしれない(素直にinitのタイミングで全部includeするコードを吐くような形の方が綺麗かもしれない)。

ちなみに、このようなことはgoではやりづらいが、なぜ不要なのかというとタスクランナーやMakefileあるいはちょっとしたシェルスクリプトでwrapするからというのがgoのユーザーから見た立場の意見。

gist

https://gist.github.com/podhmo/6dcae762e4eb09f310b1a27786517382

-hや--helpでのヘルプメッセージの表示は一瞬で終わってほしいと言う話

egoistを作っていて、けっこう気にしているポイントなども記事にしてみることにする。個人的には-h--helpに時間が掛かるCLIはあまり好きではない。 具体的には0.5sくらいでちょっとストレスを感じ、1.0sを越えると、使うたびに感じるストレスがそのツールの使用を遠ざけようとする程度には苦手1

pythonで気にしたいモジュールたち

方針としては、ヘルプメッセージが出るまでに行われるimportをへらすこと。

例えば、pandasは完全に避けたい(2)。キャッシュや諸々が効かない初回は遅い3

$ time python -c 'import pandas'

real    0m1.087s
user    0m0.693s
sys 0m0.287s

$ time python -c 'import pandas'

real    0m0.651s
user    0m0.663s
sys 0m0.123s

jinja2も、以前に比べれば早くなったとはいえ避けたい。これ一つ程度ならまだ大丈夫ではあるけれど。似たような規模のモジュールが増えてくると厳しくなってくる。

$ time python -c 'import jinja2.environment'

real    0m0.233s
user    0m0.096s
sys 0m0.024s

$ time python -c 'import jinja2'

real    0m0.123s
user    0m0.096s
sys 0m0.022s

標準ライブラリでは、asyncioもけっこう遅めのライブラリ。

$ time python -c 'import asyncio'

real    0m0.263s
user    0m0.099s
sys 0m0.033s

$ time python -c 'import asyncio'

real    0m0.129s
user    0m0.103s
sys 0m0.022s

この辺の読み込みが遅延されていると嬉しい。

ヘルプメッセージにかかるまでの時間

ヘルプメッセージの表示までに行われるインポートの内容を知りたい場合には、python -X importtimeのような形で実行してみれば良い。importtimeが3.7で追加されてから便利になった。その他それぞれのお好きな方法で。

egoistでの利用

どのようなことを気にしているかということを実際のコードを例に説明してみる。例えば、以下はjinja2を利用したegoistのコード例。はやい。

$ time python definitions.py -h
usage: definitions.py [-h] [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                      {describe,generate,scan} ...

optional arguments:
  -h, --help            show this help message and exit
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {describe,generate,scan}
    describe
    generate
    scan

real    0m0.145s
user    0m0.100s
sys 0m0.025s

deps0

このときにimportされたモジュールは以下の様な感じ。jinja2などがimportされていないのはこのコードの書き手が気にするべき事になっている。

ちなみに型チェックを有効にするためのimportはtyping.TYPE_CHECKINGで囲む必要が出てくる。最も、現状のegoistでは、appのdirective(app.sharedやapp.define_dirなどのデコレーターの部分)がuntypedなので上手くいかない状態ではあるけれど。

definitions.py

from __future__ import annotations
import typing as t
from egoist.app import App, SettingsDict, parse_args

if t.TYPE_CHECKING:
    from jinja2.environment import Environment as Jinja2Environment

settings: SettingsDict = {"rootdir": "", "here": __file__}
app = App(settings)

app.include("egoist.directives.define_dir")
app.include("egoist.directives.shared")


@app.shared
def get_jinja2_environment() -> Jinja2Environment:
    from jinja2 import Environment, FunctionLoader, StrictUndefined

    env = Environment(
        loader=FunctionLoader(lambda name: open(name).read()),
        undefined=StrictUndefined,
    )
    return env


@app.define_dir("egoist.generators.dirkit:walk")
def output(
    *, fizzbuzz_template="templates/fizzbuzz.j2", inputs_template="templates/inputs.j2",
) -> None:
    from egoist.generators.dirkit import runtime

    env = get_jinja2_environment()
    with runtime.create_file(f"fizzbuzz.txt", depends_on=[fizzbuzz_template]) as wf:
        t = env.get_template(str(fizzbuzz_template))
        print(t.render(n=30), file=wf)

    with runtime.create_file(f"inputs.html", depends_on=[inputs_template]) as wf:
        t = env.get_template(str(inputs_template))
        print(t.render(), file=wf)


if __name__ == "__main__":
    for argv in parse_args(sep="-"):
        app.run(argv)

ちなみに実際に実行してあげた場合の依存はこんな感じ。

$ time python definitions.py generate
[F] no change   ./output/fizzbuzz.txt
[F] no change   ./output/inputs.html

real    0m0.317s
user    0m0.179s
sys 0m0.030s

generate

依存が多くなる。といってもこれくらいの時間だけなら全然待てる範囲かもしれない。気になってくるのはこのようなスクリプトが数十回実行される必要が出てきたようなとき(とはいえ対応としてはbulk actionを試みる方の話であり、importの遅延の今回の話ではない。昔似たような話を書いたりはしていた https://pod.hatenablog.com/entry/2020/01/18/213043)。

egoistで気にしていること

モジュールインポートの遅延に関することで、egoistで気にしていることをメモしてみる。 (ヘルプメッセージの話から少し脱線している)

実行されないタスクのコードのimportを含めたくない

例えば他にタスクが追加されたとする。generateはタスク名を指定してそのタスクだけに限定して実行する事ができる。 python definition.py generate outputというような感じに。ここで先程のoutputタスクだけが実行された場合に、全然関係ない(clikit)関係のコードが読み込まれてほしくはない。デコレーターで読み込まれる部分も遅延させたい4

app.include("egoist.directives.define_cli")

@app.define_cli("egoist.generators.clikit:walk")
def hello(*, name: str) -> None:
    """hello message"""
    from egoist.generators.clikit import runtime, clikit

    with runtime.generate(clikit):
        runtime.printf("hello %s\n", name)

ちなみにincludeされるだけでdefine_cliなどが使われない場合には全く読み込まれない。

app.include("egoist.directives.define_cli")

このためにけっこう egoist.generators.{clikit,structskit,dirkit,filekit}__init__.pyの書き方は気をつけていたりする。

generateではなくscanではfakeのモジュールのimport

generateの他に依存関係を気にしたい場合にscanというコマンドが使える(generateも含めてコマンドの名前は後日変わるかもしれない)。これは内部的にはdry-run的な動作を行っている。他のgenerator(filekit,clikit,structskit)は事前に依存関係がわかるが、dirkitの場合はタスクの内部を読み込む必要が出てくる。

複数のタスクで同じ値を利用したい場合にはlru_cache(1)で済ませられずapp.sharedを使っている理由の一つ。

$ time python definitions.py scan
{'output/fizzbuzz.txt': ['templates/fizzbuzz.j2'], 'output/inputs.html': ['templates/inputs.j2']}

real    0m0.225s
user    0m0.126s
sys 0m0.047s

scan

このときjinja2を読み込むことを避けたい気持ちがあった。現状はmock的なオブジェクトを返して実行しているので危険かもしれない。

ちなみにこのscanを発展させて、依存関係をMakefileやninjaファイルのような何かに出力させることも未来の機能としては考えていたりする。このときに走るpythonプロセスの数を減らしたい(bulk actionでN+1の削減)し、go generateのようなサブプロセスの実行系のタスクは直接焼き付けたい。

ただ、これらのコマンドを利用する状況は限定的かもしれないので、コマンドの追加もincludeでできるようにしたかったりする。

まとめ

ヘルプメッセージの表示までに時間がかかるコマンドは嫌い。 無駄に余計に時間がかかるのも嫌い。


  1. これは単純にタイポが多いせいなのかもしれない。動作に正確性が欠けるというような。例えばふつうの人以上に間違う数が多いのであれば、間違いによるdelayの時間も多くなる。

  2. 結果が遅めなのは現在利用している環境が貧弱なせいもある(2012年のmacbook air)。

  3. pycacheが使えるということだけでなく、OSのページキャッシュが効きやすいみたいな話もあるかもしれない(?)。複数の実行例を載せているのは遅すぎる例をあげるというのは不本意というだけ。

  4. 正確に言えば、__init__.pyだけが読み込まれるがそのモジュールがimportするモジュールなどは読み込まれない。