サブコマンドをincludeで自由に追加できるようにした
サブコマンドを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
このときに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
依存が多くなる。といってもこれくらいの時間だけなら全然待てる範囲かもしれない。気になってくるのはこのようなスクリプトが数十回実行される必要が出てきたようなとき(とはいえ対応としては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
このときjinja2を読み込むことを避けたい気持ちがあった。現状はmock的なオブジェクトを返して実行しているので危険かもしれない。
ちなみにこのscanを発展させて、依存関係をMakefileやninjaファイルのような何かに出力させることも未来の機能としては考えていたりする。このときに走るpythonプロセスの数を減らしたい(bulk actionでN+1の削減)し、go generate
のようなサブプロセスの実行系のタスクは直接焼き付けたい。
ただ、これらのコマンドを利用する状況は限定的かもしれないので、コマンドの追加もincludeでできるようにしたかったりする。
まとめ
ヘルプメッセージの表示までに時間がかかるコマンドは嫌い。 無駄に余計に時間がかかるのも嫌い。