monogusaというパッケージを作りはじめた

github.com

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:

この辺りは以下の機能を参考にして作られていた

この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

f:id:podhmo:20191217000728p:plain
swagger ui

ここまでを自動でできれば格好良いのだけれど。まだこの辺りは手書きする必要がある。現状は以下の様な形への変換が必要になる。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を用意することはより制限を緩和化したゆるい関係にも機能を提供することにほかならない (民主化と言えば良いのかわからないがそういうような雰囲気の何か)。その辺りまで手軽に使える様になったら良いのかなーと思ったりしている。


  1. 元々はapistar (現 starlette ) 由来の機能。そして現在starlette自体にこの機能は無い。このあたりにはそこそこ複雑な経緯がある。