monogusaのweb周りのコードを自動生成できるようにした

github.com

monogusaのweb周りを更新した。大きな変更は前回からweb側のコードも生成されるようになったこと。前回の記事では「仮にこのように書けるとしたら...」という形でweb側のコードは手書きでの解説になったがそこは生成されるようになった。

関数定義からのweb UI生成

例えば以下の様な関数定義を持つcommands.pyがある。

commands.py

def hello(*, name="world"):
    print(f"hello {name}")

これに対して monogusa.web を実行する(この辺りのCLIインターフェイスは後々変わることがありそう。まぁexperimentalなので)。すると以下の様なweb.pyが生成される。

web.py

$ python -m monogusa.web commands.py
[F] create  $HOME/venvs/my/individual-sandbox/daily/20191225/example_monogusa/01web/web.py

生成されるコードについては後に触れるが、これは概ね前回の記事で説明に使った記事と同様のもの。当然同様に実行が可能。

サーバーが立ち上がる。

$ python web.py
Now go to http://127.0.0.1:55555/docs.
You will see the automatic interactive API documentation 
INFO:     Started server process [43110]
INFO:     Uvicorn running on http://127.0.0.1:55555 (Press CTRL+C to quit)
INFO:     Waiting for application startup.
INFO:     Application startup complete.

そして、/docs にアクセスすればSwagger UI経由のbrowsable APIが手にはいる。

あともちろんAPIが提供されることになるので POST /hello などとリクエストすると、元の関数がふつうに呼ばれる。ただしコマンド実行を模したものなのでstdout,stderrをresponseとして返す。1

$ echo '{"name": "foo"}' | http --json post :55555/hello
HTTP/1.1 200 OK
content-length: 88
content-type: application/json
date: Wed, 25 Dec 2019 06:31:29 GMT
server: uvicorn

{
    "duration": 2.7179718017578125e-05,
    "status_code": 0,
    "stderr": [],
    "stdout": [
        "hello world"
    ]
}

生成されるコードは以下の通り。基本的にfastAPIに依存している。

web.py

# this module is generated by monogusa.web.codegen
import commands
from fastapi import (
    APIRouter,
    Depends,
    FastAPI
)
import typing as t
from pydantic import BaseModel
from monogusa.web import runtime


router = APIRouter()


@router.post("/hello", response_model=runtime.CommandOutput)
def hello() -> t.Dict[str, t.Any]:
    with runtime.handle() as s:
        commands.hello()
    return s.dict()


def main(app: FastAPI):
    from monogusa.web import cli
    cli.run(app)


app = FastAPI()
app.include_router(router)


if __name__ == '__main__':
    main(app=app)

現在は1ファイルにまとめただけの出力だが、asgi appやmainは生成せずにrouter部分だけ生成するようにしても良いかもしれない。

引数指定が不要なもの

(これは趣味の範囲ではあるけれど)現在の実装ではキーワード引数がある関数はすべてJSON bodyとして扱われてrequestから渡されることを期待している。コマンドの実行を公開するという行為に絞ったわりきりなのでRESTだからどうとか考えることは辞めた。

キーワード引数が無い以下の様な関数などは以下の様に出力される。

commands.py

def cleanup_db():
    print("clean-up")

web.py (の一部)

@router.post("/cleanup_db", response_model=runtime.CommandOutput)
def cleanup_db() -> t.Dict[str, t.Any]:
    with runtime.handle() as s:
        commands.cleanup_db()
    return s.dict()

同様にPOSTでアクセスできるがbodyは不要。

$ python -m monogusa.web commands.py
[F] create  $HOME/venvs/my/individual-sandbox/daily/20191225/example_monogusa/02nokwargs/web.py
$ python web.py
...

別のシェルで

$ http POST :55555/cleanup_db
HTTP/1.1 200 OK
content-length: 84
content-type: application/json
date: Wed, 25 Dec 2019 06:42:06 GMT
server: uvicorn

{
    "duration": 4.839897155761719e-05,
    "status_code": 0,
    "stderr": [],
    "stdout": [
        "clean-up"
    ]
}

常にPOSTなのでGETはmethod not allowed。

$ http :55555/cleanup_db
HTTP/1.1 405 Method Not Allowed
content-length: 31
content-type: application/json
date: Wed, 25 Dec 2019 06:42:01 GMT
server: uvicorn

{
    "detail": "Method Not Allowed"
}

依存のあるもの

前回の記事でも言及したように2 positional argumentsは引数名と同名の関数で提供されるコンポーネントへの依存として扱われる。依存は実行時に解決される。これはfastAPIのDIの機能の上に乗るように調整されたコードが返ってくる。

例えば以下の様なコードを用意する。helloはdbにdbはdatabase_urlに依存している。

commands.py

from __future__ import annotations
import dataclasses
from monogusa import component


def hello(db: DB, *, name: str = "world") -> None:
    db.save(f"hello {name}")


@component
def database_url() -> str:
    return "sqlite:///:memory:"


@component
def db(database_url: str) -> DB:
    return DB(database_url)


@dataclasses.dataclass
class DB:
    database_url: str

    def save(self, msg: str) -> None:
        print(f"save: {msg} in {self.database_url!r}")

これは以下の様なコードが生成される(必要な部分だけを抜粋)。

import commands

def db(database_url: str=Depends(commands.database_url)) -> commands.DB:
    return commands.db(database_url)


router = APIRouter()


class HelloInput(BaseModel):
    name: str  = 'world'


@router.post("/hello", response_model=runtime.CommandOutput)
def hello(input: HelloInput, db: commands.DB=Depends(db)) -> t.Dict[str, t.Any]:
    with runtime.handle() as s:
        commands.hello(db, **input.dict())
    return s.dict()

そのまま利用できるものは直接 Depends(...) を使い、依存がfastAPI上で定義されて欲しいものは別途関数を生成している。fastAPIは非同期の依存も許容するのでmonogusaの方も非同期の依存を許容するように後で書き換えたい。

もちろんしっかり動作する。

$ echo '{}' | http --json POST :55555/hello
HTTP/1.1 200 OK
content-length: 117
content-type: application/json
date: Wed, 25 Dec 2019 06:53:38 GMT
server: uvicorn

{
    "duration": 3.361701965332031e-05,
    "status_code": 0,
    "stderr": [],
    "stdout": [
        "save: hello world in 'sqlite:///:memory:'"
    ]
}

エラーが発生した場合

エラーが発生した場合はスタックトレースがそのままstderrの中に格納されたresponseが返ってくる。CLI上での実行を模した動作を目指していたため。このあたりは本番環境で有効になっているとセキュリティ的に不安のある動作ではあるけれど、そもそも内部の閉じられた環境での一時的な機能提供をユースケースにあげていたので。

例えば雑なzero division errorを。

commands.py

def must_error():
    print("start")
    1 / 0
    print("NEVER")

実行してみる。

$ python -m monogusa.web commands.py
[F] create  $HOME/venvs/my/individual-sandbox/daily/20191225/example_monogusa/04must_error/web.py
$ python web.py

別のシェルで

$ echo '{}' | http --json POST :55555/must_error

HTTP/1.1 200 OK
content-length: 390
content-type: application/json
date: Wed, 25 Dec 2019 06:59:46 GMT
server: uvicorn

{
    "duration": 0.0005054473876953125,
    "status_code": 1,
    "stderr": [
        "Traceback (most recent call last):",
        "  File \"$HOME/venvs/my/monogusa/monogusa/web/runtime.py\", line 40, in handle",
        "    yield s",
        "  File \"./web.py\", line 19, in must_error",
        "    commands.must_error()",
        "  File \"./commands.py\", line 3, in must_error",
        "    1 / 0",
        "ZeroDivisionError: division by zero"
    ],
    "stdout": [
        "start"
    ]
}

ところでhttp statusは200のまま。responseに含まれるstatus_codeは0ではなく1になっている。

ここはhttp status 500を返すように変更しても良いかもしれないが、fastAPI側のコードとの兼ね合いなどを考えて決めあぐねている(正確に言えばopenapi docとの兼ね合いかもしれない。500のときのresponseの形状が複数になるというのがだるいかもしれないという話も含んでいるので)

web.pyの位置を変える

今までの例は提供元の関数を定義しているcommands.pyと同一の階層上にweb.pyを出力していたが、別の場所に出力することも可能。その場合はmagicalimportというパッケージを経由してimportされるようなコードが生成される。

--dst オプションで出力先を指定する。

$ python -m monogusa.web commands.py --dst /tmp
[F] update  /tmp/web.py
$ diff -u web.py /tmp/web.py

以下の様な差分が出る。

--- web.py   2019-12-25 15:58:45.844393161 +0900
+++ /tmp/web.py   2019-12-25 16:04:07.393448395 +0900
@@ -1,5 +1,5 @@
 # this module is generated by monogusa.web.codegen
-import commands
+import magicalimport
 from fastapi import (
     APIRouter,
     Depends,
@@ -10,6 +10,9 @@
 from monogusa.web import runtime
 
 
+commands = magicalimport.import_module('$HOME/venvs/my/individual-sandbox/daily/20191225/example_monogusa/04must_error/commands.py', cwd=True)

同一階層以外は全て絶対パスを埋め込んだコードを生成するような割り切った実装。この辺は柔軟に指定できるようにするか、いっそのこと出力先を固定するための設定ファイルを用意するべきかということは決めあぐねている。

まとめ

変更点は以下

  • web.pyのコードを生成できるようにした
  • DIも気にするようにした
  • エラーのときにはstderrにスタックトレースが入る
  • どこにでも出力できる。

最後に

monogusa.webの方でコード生成するようにしたが、その過程で頑張ってしまったのでCLI側でも生成するようにしても良いかもしれない。ui的には統一的に扱えるようにしたほうが良い気はしている。デフォルトでは生成するだけにして --eval オプションを用意するか、またはデフォルトは/tmp的なディレクトリに生成してしまって常にevalするかというところが悩みどころ。

ただ、今回はwebだけの更新になってしまったが、元々の提供範囲としてはcliとwebだけに限ったものではない。コードのrefineなどはゆっくりやれることではあるので、どういう機能を作るかの試行錯誤にも時間を振った方が良い気がしている。


  1. ただし、現在の形状では、例えばstderrに出力されるロガーの出力がstdout上でどの位置に表示されたかという情報が喪われてしまう。そのためresponseの形状は変更するかもしれない。

  2. positional arguments (component) の部分。ここ直接リンクを貼りたい。本当は。