monogusaにdefault componentという機能を追加した

github.com

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()

一方で利用する側と名前が食い違っていた場合にはエラーになっていた。 つまり以下の様なffooのような名前が一致していないコードはエラーになっていた。

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


  1. ちなみに isinstance(val,type) でのassertionが入るので、runtime_checkable()を付ける必要がある。ただ、factoryを記述するタイミングでmypyでチェックされるならこのassertionは無効にしても良いかもしれない。あるいはabcモジュールを使って仮想継承させる形にしてProtocolを使わないという方法もあるかもしれない。ZCAのように継承関係を見るということまではしないつもり。

  2. あるいは、個別にcomponentを登録することはせずにcomponentのrepositoryを登録するという形でも良いかもしれない

handofcatsで関数をコマンド化したときに戻り値をデフォルトで表示してくれるようにした

github.com

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


  1. 本当に継続か?というと。。どうだろ。。