pythonのslack関連のパッケージはどの辺りを見れば良いか調べた

pythonのslack関連のパッケージはどの辺りを見れば良いか調べた。古の記憶ではslackbotが有名だった。

過去の自分はえらいので以前にpytestのpluginについて調べたものと同様の方法を使えば良さそう。

pypistatsとか名前忘れていた。正直。

試す

だいたい以下の感じで進めれば良い

  1. pip search でテキトーに関連しそうなパッケージの一覧を探す
  2. pypistatsでテキトーにデータを取ってくる
  3. 月間ダウンロード数あたりでソート。
  4. 上位N件くらいを見る

はい。

実際にやってみる

手順は以下のMakefileがすべて。00から順に実行していけば良い。pypistatsとjqfpyは必要。

Makefile

# 一覧取得
00:
  pip search slack | cut -d " " -f 1 | grep -v django | tee $@.txt
  pip search slackbot | cut -d " " -f 1 | grep -v django | grep -v markov_slackbot >> $@.txt

# pypistatsにアクセス
01:
  for i in `cat 00.txt`; do pypistats recent $$i -f json; done | tee $@.json

# 月間ダウンロード数でソート
02:
  cat 01.json | jqfpy --slurp 'sorted(get(), key=lambda x: x["data"]["last_month"], reverse=True)' --squash -c | tee $@.json

# gistで見る分にはCSVが便利
03:
  echo package,download_last_month > 03.csv
  cat 02.json | jqfpy --slurp '[print(f"""{d["package"]},{d["data"]["last_month"]}""") for d in get()]; None' >> 03.csv

# markdownでみたいよね
04:
  dictknife cat -o md 03.csv | tee $@.md

はい。こういうことが10分くらいでできたので良い。

結果

結果は、slackbotはやっぱり強くてslack-webhook-cliとslack,dagster-slack, slack-webhook, celey-slack, slack-cli辺りを見ておけば十分そう。

詳しくはこういう感じ。途中で切った。

package download_last_month
slack-webhook-cli 53994
slackbot 18247
slack 9564
pytest-slack 9475
slack-logger 5790
dagster-slack 3088
slack-webhook 2077
celery-slack 1833
slack-cli 1646
fabric-slack-tools 1590
slack-entities 842
slack-client 825

追記

価値がありそうなのは以下くらいかも

  • slackbot -- Based on slack Real Time Messaging API な所だけ注意だけれど。やっぱりコレが無難そう。単にpostするだけならrequestsで十分かも。
  • python-slack-logger -- loggerと繋げてるだけだけど。場合によっては便利かもしれない。ちょっとしたhighlight付きなので。
  • dagster-slack -- そもそもdagsterが何者?という感じなので後で調べる必要はある。覗いてみても良いかもくらい。
  • slack-cli -- 人によってはCLIとして便利なことはあるかも。ちょっとした装飾を付けたい場合にも参考にするのはあり(中のコード覗いて無いけど)。

細々と想ったこと

ここからは細々とおもったこと

monthlyのダウンロードだけだとだるいかも?

けっこうgithubのstar数とはかけ離れていそう。けっこうスター数0のものがある。できれば1個は付いていて欲しい(?)。 あと、dagster-slackとか本体の方のスター数が見えるんだなー。pypi上での遷移先では。

あと、release_historyも欲しい。直近の更新がない場合は基本的に切るので(ただ月次のダウンロード数が多い状況なら安定しているのかもしれない)。

pip search?

なるほどと想ったのは、pip search slack でslackbotが含まれないこと。へー。と思ったりした。

$ pip -V
pip 19.3.1 from $HOME/venvs/my/lib/python3.8/site-packages/pip (python 3.8)
$ pip search slack | grep slackbot | wc
      0       0       0
$ pip search slackbot | grep slackbot | wc
     23     242    1965

pypistats

キャッシュは効くものの1回1秒程度は掛かる。もう少し頻繁にこの種の作業をするとしたらpypistatsを雑にCLIで使うという行為は卒業しても良いかもしれない。今はまだ大丈夫だと思っている。

URL

(追記:) こういう記事を書くのはあまり意味が無いと思ったけれど。リンク先をポチポチとクリックして遷移できるのはけっこう便利だった。

pypistatsの詳細ページのリンクもあると嬉しいかもしれない。例えば

そしてpypiにreadmeを載せていないページもあるのでホームページのリンクもあると良いかもしれない。次回の話。

gist

gist

https://gist.github.com/podhmo/b841bac22e75b6fa8acc3d8f3503ff67

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) の部分。ここ直接リンクを貼りたい。本当は。