monogusaにdefault componentという機能を追加した
monogusaにdefault componentという機能を追加した。何故欲しくなったのかなどを思考の整理のためにメモしておく。
default component?
このdefault componentという名前は独自の名前で暫定的な名前。通常のcomponentは名前と型によってmappingされる。一方default componentは戻り値の型のみによってmappingされる。
component
例えば以下のコードのuse()
上でFooオブジェクトを利用しているが、これは登録した関数名と引数名が一致している必要があった。以下のように利用する側も登録する側もfoo
という名前を利用していれば問題なく取得できた。
00use.py
from monogusa import component class Foo: pass @component def foo() -> Foo: Foo() def use(foo: Foo) -> None: print(foo) if __name__ == "__main__": from monogusa.cli import run run()
一方で利用する側と名前が食い違っていた場合にはエラーになっていた。
つまり以下の様なf
とfoo
のような名前が一致していないコードはエラーになっていた。
01use.py
--- 00use.py 2020-02-13 19:55:00.000000000 +0900 +++ 01use.py 2020-02-13 19:56:50.000000000 +0900 @@ -10,8 +10,8 @@ return Foo() -def use(foo: Foo) -> None: - print(foo) +def use(f: Foo) -> None: + print(f)
ValueError: component (f : <class '__main__.Foo'>) is not found
default_component
一方、default_componentは型のみによって取得されるので以下の様な名前が一致していないコードも動く。
02use.py
--- 01use.py 2020-02-13 19:56:50.000000000 +0900 +++ 02use.py 2020-02-13 20:02:33.000000000 +0900 @@ -1,11 +1,11 @@ -from monogusa import component +from monogusa import default_component class Foo: pass -@component +@default_component def foo() -> Foo: return Foo()
<__main__.Foo object at 0x1104ec3a0>
resolve_args()
ちなみにいちいちコマンドとして実行しなくても、以下の様な形で依存を注入した結果を取得する事ができる。
03resolve.py
from monogusa import component from monogusa.dependencies import resolve_args @component def one() -> int: return 1 def use(one: int) -> None: print(one) print("!", resolve_args(use)) # ! [1]
なぜdefault componentが欲しくなったか?
なぜdefault componentが欲しくなったか?理由をメモしておく。 これは特定のusecaseにおけるdefault実装を用意したいと思ったため。
例えば、CLI, slack bot, discord botのそれぞれにおいてreply_message()
のような操作を持つオブジェクトを考えてみる。例えば以下の様なProtocolを満たすもの。1
import typing_extensions as tx @tx.runtime_checkable class Replier(tx.Protocol): def reply_message(self, text:str) -> None: ...
このReplierはCLIでは単純にstdoutに出力するだけのものかもしれない。
class ForConsole: def reply_message(self, text:str) -> None: print("reply message:", text)
一方でslack botやdiscord bot用のそれなら特定のメッセージへのreplyとして返すような実装になるだろう。
class ForSlackBot: def reply_message(self, text:str) -> None: # 何らかの内部実装を呼び出す self.internal.reply(text)
さて、ここでmonogusaの機能について振り返ってみると「既存の関数定義を色々なやり方で公開する機能」というような位置づけのものになる。つまり以下の様な形になる。
- 既存の関数定義を、CLIアプリケーションとして (monogusa.cli)
- 既存の関数定義を、web APIとして (monogusa.web)
- 既存の関数定義を、chatbotのコマンドとして (monogusa.chatbot.slackbot, monogusa.chatbot.discordbot)
ここで例えばslackbot用の実装を利用するcomponentを考えたときに、default実装を要求したくなることがあった。2
まだ、未実装だが、例えばある特定のメッセージに反応を返すbotを考えたときにこの種の機能が欲しくなる。 (例えばsubscribe, MessageEventというデコレーターとイベントのセットで特定のイベントが購読できるようになるという機能が実装された未来の世界について考えている)
from monogusa.events import subscribe, MessageEvent @subscribe(MessageEvent) def on_amazon_url(ev: MessageEvent, replier: Replier) -> None: if "https://amazon.co.jp" not in ev.content: return replier.reply_message("OK")
ここで、元のメッセージへの反応をするためにどうすれば良いかわからなくなる。そこで例えば先程言及したような振る舞いを持つオブジェクトがそれぞれのcontext上で登録されているとする。そのような場合はそのオブジェクトを利用して反応を返してあげれば良い。
つまりそれぞれのusecaseごとにReplier
というprotocolを実装したcomponentが存在しているというイメージ。
(あるいはMessageEvent自体がreplyみたいなメソッドを持つ未来もあるかもしれない。それでも内部にコミュニケーション用のオブジェクトとしてこの種のオブジェクトを持つことになるはず)
このとき、依存として埋め込まれる replier
がそれ以外の名前の引数として扱われた場合にエラーになるのは嬉しくない。たとえばr: Replier
などが許されないとするとちょっと厳しい。引数名の部分はimportして利用したりなどが不可能なものなので、登録されているcomponentの名前を事前に知っておく必要がでてくる。これは使い勝手が悪い。
幸いtype hintの部分つまり型の部分に関しては値なのでimport可能なものになる。なのでこれを利用することには意味がある。 (特にinterface/protocolと実装という形で詳細や依存を閉じ込めておけるのであれば不要なimportなども避けられるかもしれない)
そんなわけでdefault componentが欲しくなった。
追記: 全部がdefault componentではだめか?
考えてみると全部がdefault componentという世界観も考えられる。
すごくざっくり以下の様にまとめるとして、default componentだけではだめか?
- component -- nameと型がセットのcomponent。named component
- default component -- 型だけからマッピングされるcomponent。
これはやってやれないことはないけれど。同じ型を違う形で使うことが難しくなる。クラスとインスタンスの関係は通常1:Nなのにも関わらず1:1を暗黙に課すというのは気持ち悪いように感じる。
あるいは同じミドルウェアに接続するオブジェクトだがリクエスト先が異なるようなものに関しても型レベルで分ける必要がある(といっても継承するだけといえばそれだけだけれど)。やっぱりそれがデフォルトなのは気持ち悪いように感じる。
基本的にはstrictな通常のcomponentの方をユーザーが使うという方向になるような気がしている。
gist
handofcatsで関数をコマンド化したときに戻り値をデフォルトで表示してくれるようにした
handofcatsで関数をコマンド化したときに戻り値をデフォルトで表示してくれるようにした。
どうして?
今までは以下の様な関数をコマンド化したときに何も表示されなかった。
00hello.py
from handofcats import as_command @as_command def hello(*, name="world") -> str: return f"hello, {name}"
戻り値は捨てられるので、当然といえば当然なのだけれど。かなしい。 というか、間違えてしまったのじゃないかと一瞬戸惑うことがあった。
以前はこう。
# 3.1.0 より前 $ python 00hello.py
何も表示されない。悲しい。
3.1.0からは、この戻り値の扱いを変えた。
$ python 00hello.py hello, world
デフォルトはほぼほぼprint。
戻り値の扱いの詳細
実際にはprintではなくNoneではなかったらprintという形になる。こういうイメージ。
def cont(v: t.Any) -> t.Any: if v is None: return None print(v) return v
というわけで、リストなどを返す関数をコマンドとして実行すると以下の様な形になる。
$ python 01nums.py [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
01nums.py
from handofcats import as_command @as_command def nums() -> list: return list(range(10))
jqfpyと組み合わせると、ちょっといい感じに扱えるかもしれない。とはいえこれはJSONではない。
$ python 01nums.py | jqfpy --squash 0 1 2 3 4 5 6 7 8 9
出力方法を変えたい場合
もちろん、デフォルトの出力方法が気に食わない場合があるかもしれない。その場合は出力方法を変えられる。 実際には、出力方法ではなく、継続を変えるみたいな感覚で名前がcont (continuation) になっている1。
それを使って、今度は本当にdictをJSON化して出力してみる。
02custom-output.py
python 02custom-output.py [ { "no": 0, "name": "藤本 直子", "address": "滋賀県渋谷区津久戸町20丁目16番12号" }, { "no": 1, "name": "山本 花子", "address": "徳島県福生市豊町35丁目23番20号" }, { "no": 2, "name": "小泉 幹", "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784" }, { "no": 3, "name": "山本 亮介", "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号" }, { "no": 4, "name": "山口 翔太", "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号" } ]
02custom-output.py
from handofcats import as_command, Config import json @as_command( config=Config(cont=lambda x: print(json.dumps(x, indent=2, ensure_ascii=False))) ) def nums() -> list: import faker faker.Faker.seed(0) fake = faker.Faker("ja_JP") return [{"no": i, "name": fake.name(), "address": fake.address()} for i in range(5)]
configにcontを渡して出力方法が変更できる。
今度は完全にJSONなのでjqfpyに渡してちょっとだけ違った形に整形することもできる。1行ごとに改行を挟んだJSON(LDJSON)にしたりするとgrepで雑にフィルタリングができて便利なことがある。
$ python 02custom-output.py | jqfpy --squash -c {"no": 0, "name": "藤本 直子", "address": "滋賀県渋谷区津久戸町20丁目16番12号"} {"no": 1, "name": "山本 花子", "address": "徳島県福生市豊町35丁目23番20号"} {"no": 2, "name": "小泉 幹", "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784"} {"no": 3, "name": "山本 亮介", "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号"} {"no": 4, "name": "山口 翔太", "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号"}
はい。
handofcatsコマンドから
as_command
デコレーターを付けないソースコードに対して、いきなりhandofcatsコマンドを利用して関数をコマンドであるかのように呼ぶ場合には--cont
というオプションで渡せる。関数の指定は<module name>:<function name>
。モジュール名の部分は物理的なファイル名でも良い。
$ python -m handofcats 03with-handofcats.py people --cont=03with-handofcats.py:_output [ { "no": 0, "name": "藤本 直子", "address": "滋賀県渋谷区津久戸町20丁目16番12号" }, { "no": 1, "name": "山本 花子", "address": "徳島県福生市豊町35丁目23番20号" }, { "no": 2, "name": "小泉 幹", "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784" }, { "no": 3, "name": "山本 亮介", "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号" }, { "no": 4, "name": "山口 翔太", "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号" } ]
03with-handofcats.py
def _output(x): import json print(json.dumps(x, indent=2, ensure_ascii=False)) def people() -> list: import faker faker.Faker.seed(0) fake = faker.Faker("ja_JP") return [{"no": i, "name": fake.name(), "address": fake.address()} for i in range(5)]
--expose
時にcont部分のコードは生成される?
今の所は--expose
でhandofcats非依存のコードを生成したときにはcont部分のコードは生成されない。argparse化されるなら全部いじれるんだから、単に関数を実行してる部分に追加すれば良いんじゃないか?という思いが芽生えたのと、もし仮にこれを実装した場合にはlambdaなどで渡されるcontの扱いが地味に面倒そうだなーと思ったため。
ついでの変更点
ついでの変更点をここでメモしておく。--expose
のときのオプションに --untyped
というオプションがあったのだけれどこれを消した。そして --simple
というオプションを追加した。
--siimple
オプションを付加して呼び出すと、型の指定の部分と共にargparse関連のちょっとだけ冗長に見えるコードを消した形で出力されるようになる。
例えばバグを再現するコードの例を書くときには時々シンプルな表現が欲しくなった。このようなときに綺麗なヘルプメッセージのためのコードなどは邪魔でしかない。一方で--untyped
というオプションを個別に指定したい気持ちになることはなかった。そんなわけで --simple
に統合した。
生成されるコードの差分はこのような感じ。
--- output.full.py 2020-02-08 20:46:14.000000000 +0900 +++ output.simple.py 2020-02-08 20:46:14.000000000 +0900 @@ -1,13 +1,13 @@ -import typing as t + def hello(*, name="world") -> str: return f"hello, {name}" -def main(argv: t.Optional[t.List[str]] = None) -> t.Any: +def main(argv=None): import argparse - parser = argparse.ArgumentParser(prog=hello.__name__, description=hello.__doc__, formatter_class=type('_HelpFormatter', (argparse.ArgumentDefaultsHelpFormatter, argparse.RawTextHelpFormatter), {})) + parser = argparse.ArgumentParser(prog=hello.__name__, description=hello.__doc__) parser.print_usage = parser.print_help # type: ignore parser.add_argument('--name', required=False, default='world', help='-') args = parser.parse_args(argv)
↑のdiffは以下で生成。
$ python 00*.py --expose > output.full.py $ python 00*.py --expose --simple> output.simple.py $ diff -u output.full.py output.simple.py > a.diff
はい。
gist
gistは以下
https://gist.github.com/podhmo/9437d27a3c35213b2293a50511b5647e
-
本当に継続か?というと。。どうだろ。。↩