monogusaというパッケージを作りはじめた
monogusaというパッケージを作りはじめた。まだ完成には至っていないのだけれど現状でできることなどを書きながらここで使用感を確認していくことにした。
monogusa
monogusaというのはもちろん「ものぐさ」から来ている。お布団が大好きだったりこたつから出たくないような人々のこと。その場から動きたくない、手の届く範囲に望みのものが置かれて欲しいみたいな状況が目に浮かぶ。
今回はどういう感じのものを作ろうか?ということをお気持ちベースでメモに書いてみてから作ることにしてみた。
考えてみると、自分は自分の書くコードに対して、ドキュメントを書くということをサボりがちであるという認識を持っている。これが以下のどれなのかと言うのがはっきりしていない。
- ドキュメントを書くのが苦手
- 英語の文章を書くのが苦手 (英語でドキュメントを書くのが苦手)
後者は間違いないことではあるのだけれど。自己認識の上では前者かどうかは未知数という感じ。そういうわけで今回はもう少し文章や説明を多めに作ってみることにしてみている。このプロジェクトに関してはどうせ自分のためのコードなので英語でドキュメントを書く必要も無いような気がして来ている。
ポイントはコレ。
ある状態からある状態へ手軽に遷移できて欲しい
ここでの状態には色々な意味がある。詳しくはメモの方を参照してみると良いかもしれない(とはいえ自分のためのメモなので読んでも意味が読み取りづらいかもしれない)。
hello world
まだpipyにはあげていないのでテキトーにcloneしてきて pip install -e .
をして見て欲しい。とりあえずインストールができたことにして筆を進める。
monogusaなので手軽になにかを行いたい。その何かは例えばコマンドとしての提供だったりする。手元で書いた関数がそのままコマンドとして提供されて欲しい。この機能だけを見るとhandofcatsと重なる部分があるのだけれど。monogusaはサブコマンドになる。
例えば以下の様な関数定義がある。
00cli.py
def hello(*, name: str) -> None: print(f"hello {name}") def bye(*, name: str = "bye") -> None: print(f"bye {name}")
これをmonogusa越しに呼ぶとコマンドになる。
$ python -m monogusa.cli 00cli.py -h usage: 00cli.py [-h] {hello,bye} ... optional arguments: -h, --help show this help message and exit subcommands: {hello,bye} hello bye
hello, byeという関数がサブコマンド化されている。キーワード引数はフラグとして利用できるようになる。
$ python -m monogusa.cli 00cli.py hello --name=world hello world # help $ python -m monogusa.cli 00cli.py hello -h usage: 00cli.py hello [-h] --name NAME optional arguments: -h, --help show this help message and exit --name NAME
デフォルトキーワード引数はデフォルト値として扱われる (下の例では 'bye' がデフォルト値として使われる)。
$ python -m monogusa.cli 00cli.py bye bye bye $ python -m monogusa.cli 00cli.py bye -h usage: 00cli.py bye [-h] [--name NAME] optional arguments: -h, --help show this help message and exit --name NAME (default: 'bye') # <- default値
このままだとinvokeのようなタスクランナーと被るような所があるかもしれない。
docstring
もちろん関数のdocstringはhelp titleとして扱われる。例えば以下のようにdocstringを追加した時に。
--- 00cli.py 2019-12-16 23:05:53.016345987 +0900 +++ 01cli.py 2019-12-16 23:08:23.321588713 +0900 @@ -1,6 +1,8 @@ def hello(*, name: str) -> None: + """hello message""" print(f"hello {name}") def bye(*, name: str = "bye") -> None: + """bye bye""" print(f"bye {name}")
help usageとして表示される。
$ python -m monogusa.cli 01cli.py -h usage: 01cli.py [-h] {hello,bye} ... optional arguments: -h, --help show this help message and exit subcommands: {hello,bye} hello hello message # <- ここが追加されている bye bye bye
async defされた関数
ちょっとしたおまけとしてasyncioの関数にも対応している。
02async-cli.py
import time import asyncio async def hello(): print("hello", time.time()) await asyncio.sleep(0.5) print("bye", time.time())
実行できる。
$ python -m monogusa.cli 02*.py hello hello 1576505522.472876 bye 1576505522.9737217
DEBUG=1
という形で環境変数に値を設定して実行してあげれば asyncio.run(<coroutine function>, debug=True)
として実行される。
$ LOGGING=DEBUG DEBUG=1 python -m monogusa.cli 02async-cli.py hello DEBUG:asyncio:Using selector: EpollSelector hello 1576505750.2488549 bye 1576505750.7505808 DEBUG:asyncio:Close <_UnixSelectorEventLoop running=False closed=False debug=True>
この辺りのインターフェイスは変わるかもしれない。環境変数経由の値渡しは知らなければわからないインターフェイスなのでたくさんは増やさない予定。
positional arguments (component)
ところで今までの全部の関数定義の中でキーワード引数 (keyword only arguments) だけを使ってきた。では通常の引数 (positional arguments) は何に使われるのかというとDI的な機能として使われる。
例えば以下の様に、引数と同名のcomponent関数を用意してあげると自動的に埋め込まれて使われる。
03use-di.py
from monogusa import component def hello(database_url: str) -> None: print(f"db from {database_url}") @component def database_url() -> str: return "sqlite:///:memory:"
例えば上の例では database_url
が自動的に埋め込まれて使われる。
$ python -m monogusa.cli 03use-di.py hello db from sqlite:///:memory:
この辺りは以下の機能を参考にして作られていた
- pytestのfixtureの機能
- fastAPIのDIの機能1
このDI的な機能は再帰的に実行される。例えばhelloはDBに依存し、DBはdatabase_urlに依存している。
$ python -m monogusa.cli 04use-di.py hello save hello message in sqlite:///:memory:
例えば以下の様なコードもOK。型ヒントは指定してあげる必要がある。
04use-di.py
from __future__ import annotations from monogusa import component def hello(db: DB) -> None: db.save("hello message") @component def database_url() -> str: return "sqlite:///:memory:" class DB: def __init__(self, url) -> None: self.url = url def save(self, message: str) -> None: print(f"save {message} in {self.url}") @component def db(database_url: str) -> DB: return DB(database_url)
ただこれだけではまだ不足している気がしていて、lifecycle的なイベントが必要かもしれないと思っている。あとまだ他のfixture的な機能で用意されているgeneratorを利用したteardown付きのfixtureはサポートしてない。後々必要になるかもしれない。
web interface
ところでコレだけでは本当に価値があるとは思えない。それこそ単にコマンドを作れば良いだけな気がするし、タスクランナーからタスクを手元で実行すれば良いだけな気がする。
monogusaというのはこたつから出たくない人種、たとえばテレビのリモコンは手の届くところにあってほしい。良き所に良きものが置かれているという空気感をここでの文脈に持ってきたい。例えばweb APIとして提供できるようになっていると嬉しいかもしれない。
ここでwebAPIとして提供されるとはどういうことかといえば、消費者が自分自身から他の人へ広がるということになるのかもしれない。とりあえずはクローズドな環境でのことをイメージして見てほしい。
例えばfastAPIの上に乗ったglueコードを上手く取り扱うことができると、APIの機能の提供と同時にSwagger UI経由でbrowsable API的な機能が利用可能になる。この辺りはMicroBatchFrameworkの影響設けているかもしれない。
今はまだ手で書かなければいけない。けれど以下の様な感じで使える。
05web.py
$ python 05web.py -h usage: 05web.py [-h] [--show-doc] [--debug] [--port PORT] optional arguments: -h, --help show this help message and exit --show-doc --debug --port PORT
直接実行するとuvicornが動く。
$ python 05web.py --port=55555 INFO: Started server process [320004] INFO: Uvicorn running on http://127.0.0.1:55555 (Press CTRL+C to quit) INFO: Waiting for application startup. INFO: Application startup complete.
ここでAPI requestをしてみると返ってくる。コマンド実行の抽象化なのでstdoutとstderrがresponseとして返ってくる。そして全部POST。
$ echo '{"name": "world"}' | http --json POST :55555/hello HTTP/1.1 200 OK content-length: 101 content-type: application/json date: Mon, 16 Dec 2019 14:57:20 GMT server: uvicorn { "duration": 2.3603439331054688e-05, "stderr": [], "stdout": [ "save hello message in sqlite:///:memory:" ] }
せっかくasgiなのでwebsocketなどのAPIを返したりすると良いのかもしれないと思ったりはしているが、ちょっとopenAPI docと相性が悪いような気がしている。
もちろん、fastAPIなので http://localhost:55555/docs
などにアクセスすればSwagger UI経由でweb画面が見れる。
$ python -m webbrowser -t http://localhost:55555/docs
ここまでを自動でできれば格好良いのだけれど。まだこの辺りは手書きする必要がある。現状は以下の様な形への変換が必要になる。DIなどはfastAPIのものを使う様に書き換えている。
05web.py
from __future__ import annotations import typing as t from fastapi import FastAPI, Depends from pydantic import BaseModel import monogusa.web.runtime as web app = FastAPI() class HelloInput(BaseModel): name: str def database_url() -> str: return "sqlite:///:memory:" class DB: def __init__(self, url) -> None: self.url = url def save(self, message: str) -> None: print(f"save {message} in {self.url}") def db(database_url=Depends(database_url)) -> DB: return DB(database_url) @app.post("/hello", response_model=web.CommandOutput) def hello(input: HelloInput, db: DB = Depends(db)) -> t.Dict[str, t.Any]: with web.handle() as s: db.save("hello message") return s.dict() if __name__ == "__main__": from monogusa.web import cli cli.run(app)
あるいは単にopenapi.jsonが欲しい場合には --show-doc
付きで実行してみれば良い。この辺りもfastAPIに乗っかることができればよしなにやってくれる。
$ python 05web.py --show-doc openapi: 3.0.2 info: title: Fast API version: 0.1.0 paths: /hello: post: summary: Hello operationId: hello_hello_post requestBody: content: application/json: schema: $ref: '#/components/schemas/HelloInput' required: true responses: '200': description: Successful Response content: application/json: schema: $ref: '#/components/schemas/CommandOutput' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' components: schemas: CommandOutput: title: CommandOutput required: - stdout - stderr - duration type: object properties: stdout: title: Stdout anyOf: - type: array items: type: string - type: string stderr: title: Stderr anyOf: - type: array items: type: string - type: string duration: title: Duration type: number HTTPValidationError: title: HTTPValidationError type: object properties: detail: title: Detail type: array items: $ref: '#/components/schemas/ValidationError' HelloInput: title: HelloInput required: - name type: object properties: name: title: Name type: string ValidationError: title: ValidationError required: - loc - msg - type type: object properties: loc: title: Location type: array items: type: string msg: title: Message type: string type: title: Error Type type: string
その他先のこと
その他先のこととして、タスクキューとの組み合わせなどを考えている。非同期タスクを良い感じに取り扱いたい。それもローカル、オンプレ、クラウド上で良い感じに動くような形で。最初はSQS辺りを使うものかもしれない。あるいはredis経由でarqをwrapしたものになるかもしれない。これらの依存が動かなくては動かせないとだるいのでおそらくインメモリーの何かしらもローカルで動かせるようにすると思う。monogusaなので。
あとはbotとのつなぎ込みも手軽にできて欲しい。ある環境向けの閉じたwebAPIが個人の操作をチーム内に提供することだとしたら、botを通じたチャット経由でのUIを用意することはより制限を緩和化したゆるい関係にも機能を提供することにほかならない (民主化と言えば良いのかわからないがそういうような雰囲気の何か)。その辺りまで手軽に使える様になったら良いのかなーと思ったりしている。