egoistで生成するCLI定義のカスタマイズ方法について

github.com

作っていたミニフレームワークの最初のバージョンをリリースしたと言う話という記事を書きました。

とはいえ、この記事だけだと何が何だか分からなかったので、作られたコマンドを拡張していきながらもう少し詳しく説明するような記事を書きました。

関数定義とCLIのコマンドの定義

definitions.pyを覗いてみましょう。このような関数定義があると思います。

@app.define_cli("egoist.generators.clikit:walk")
def hello(*, name: str) -> None:
    """hello message"""
    from egoist.generators.clikit import runtime, clikit

    with runtime.generate(clikit):
        runtime.printf("hello %s\n", name)

一度余分なコードを消してみて心の目で見てみましょう。関数定義のsignature部分だけに集中してみてください。このような形で。

def hello(*, name: str) -> None:
    """hello message"""
    ...

python上で、定義された関数を呼び出す場合は以下の様になりますよね。

hello(name="world")

心の目で見てみると、以下のようなCLIでのコマンドの実行も似たようなものだと思えてきませんか?

$ hello --name world

つまり、そういうことです。

definitions.pyの中身

それではdefinitions.pyの中身を真面目に覗いて行きましょう。egoist initで生成されるdefinitions.pyは以下のようなコードになっています(このコードは現在のバージョンでのことであり、暫定的なものである可能性があります)。

設定(settings)の"rootdir"に"cmd/"がついているので、先程のpython definitions.py generateで"cmd/hello/main.go"が生成されたというわけです。

(hereの意味やincludeの意味はとりあえず省略して次に進みます。includeは各自が機能を自作するためのhookです。)

definitions.py

from egoist.app import App, SettingsDict

settings: SettingsDict = {"rootdir": "cmd/", "here": __file__}
app = App(settings)

app.include("egoist.directives.define_cli")


@app.define_cli("egoist.generators.clikit:walk")
def hello(*, name: str) -> None:
    """hello message"""
    from egoist.generators.clikit import runtime, clikit

    with runtime.generate(clikit):
        runtime.printf("hello %s\n", name)


if __name__ == "__main__":
    app.run()

helloという名前の関数なのでhello/main.goが生成されました。またdocstringの"hello message"は先頭のヘルプメッセージとして使われています。関数のbody部分は見慣れないものがあるかもしれませんが、nameという引数を使ってprintfしているだけですね。

出力する位置を変えたい?

出力する位置を変えたい場合は関数名の __ がファイルパス上の / に対応してます。いい感じに名前をつけてください。foo__bar__boo とかそういう形で。

コアの部分はもちろんgoで

このフレームワークは、pythonの定義からgoのコードを生成することを意図してはいますが、goのコードをpythonで書くということは意図していません。これはちょっとした注意点です。

複雑なロジックやコアの機能などはもちろん素直にgoで書いてください。このフレームワークが埋めようとするのは、主に機能と機能の間をつなぐような糊となるような部分です(グルーコード)。

これは、自分で機能を自作するときにも気をつけたほうが良いところかもしれません。

各自がansibleなどのクックブックのように拡張していくことを意図しています。フリーライドせずに自分たちの力で育てていく必要があります。これは、自分たちの内部のコードと上手くつなげるためのコード生成で、かつ、自分たちの内部のコードのことは自分たちしか知らないはずなので、自然とそうなります

ちなみに、自分の好みの機能を提供することを奨励する実際には形のないフレームワークなので、実質的にはフレームワークフレームワークあるいはツールキットと呼んだほうが良いものかもしれません。

生成されたgoコード

pythonから生成されたgoのコードの一部を抜粋します。まぁそれはそうという感じですね。特筆事項はなさそうです。 一つあるとしたら、python中のname: strという引数定義が、使われている場所では opt.Name になっている点でしょうか?

func run(opt *Option) error {
    fmt.Printf("hello %s\n", opt.Name)
    return nil
}

なんとなく雰囲気がわかったので、少しだけ改変をしていこうと思います。

コマンドラインオプションを追加してみる

コマンドラインオプションを追加してみましょう。関数定義のsignatureを使っているのでなんとなくやるべき事はわかりそうですね。definitions.pyのhello()の引数部分を変えてみましょう。

app.include("egoist.directives.define_cli")
 
 
 @app.define_cli("egoist.generators.clikit:walk")
-def hello(*, name: str) -> None:
+def hello(*, name: str, age: int = 20, who: str = "foo") -> None:
     """hello message"""
     from egoist.generators.clikit import runtime, clikit
 
     with runtime.generate(clikit):
-        runtime.printf("hello %s\n", name)
+        runtime.printf("%s(%d): hello %s\n", who, age, name)
 
 
 if __name__ == "__main__":

誰が言ったかわかるようにwhoとageを追加してみました。デフォルト値もある程度は気にしてくれます。それでは生成し直してみましょう。ちなみに、実行後に再度実行してみたときにはno changesと表示されます。このときmtimeも更新されません。

$ python definitions.py generate
[F] update  cmd/hello/main.go

# 再実行
$ python definitions.py generate
[F] no change   cmd/hello/main.go

生成されたコマンドを実行してみます。このように何度も何度も定義部分を変えての生成を繰り返す点がyomancookiecutterのようなscaffoldとは違う点です。個人的にはこのあたりの挙動はインフラのデプロイに近いものなのではないか?というような感覚でいたりします。

実際に使ってみましょう。

$ go run cmd/hello/main.go --name="world"
foo(20): hello world

$ go run cmd/hello/main.go --who=F --age=90 --name="M"
F(90): hello M

なるほどと言う感じですね。ヘルプにも-age-whoが現れる様になりました。

$ go run cmd/hello/main.go --help
hello - hello message

Usage:
  -age int
        - (default 20)
  -name string
        -
  -who string
        - (default "foo")
exit status 1

ヘルプメッセージの追加

ちょっとだけ寂しいのでヘルプメッセージも追加してみましょう。ここはちょっとだけ複雑ですが、runtimeモジュールを利用します。runtime.get_cli_options()CLIのオプション部分つまりフラグの部分の値が取れるのでそこにhelpを追加してみてください。このようなdiffになります。

def hello(*, name: str, age: int = 20, who: str = "foo") -> None:
     """hello message"""
     from egoist.generators.clikit import runtime, clikit
 
+    options = runtime.get_cli_options()
+    options.name.help = "the name of target person"
+    options.age.help = "age of subject"
+    options.who.help = "name of subject"
+
     with runtime.generate(clikit):
         runtime.printf("%s(%d): hello %s\n", who, age, name)

生成後のヘルプメッセージを見てみます

$ python definitions.py generate
[F] update  cmd/hello/main.go

$ go run cmd/hello/main.go --help
hello - hello message

Usage:
  -age int
        age of subject (default 20)
  -name string
        the name of target person
  -who string
        name of subject (default "foo")
exit status 1

良い感じですね。

go側で定義した関数とつなぐ

ここまできてようやく本題です。egoistの本来の趣旨は機能と機能をつなぐ糊です。pythonで定義した部分をCLIとして露出させてもあんまり嬉しくないですね。既存のgoのコードをCLIとして扱うという方が理に叶っていそうです。

internal/helloというような形でHello部分をパッケージ化してみましょう。そしてその関数を呼ぶ形に変えていきましょう。

internal/hello パッケージ

ここは特に特別なこともないのでサクッと作ってみます。

(今回はテキトーに書いている部分ですが、真面目に利用するときには例えばclean architectureのusecaseの実行部分が使われると思います)

$ go mod init m
go: creating new go.mod: module m
$ mkdir -p internal/hello
$ e internal/hello/hello.go # エディタで編集

internal/hello/hello.go

package hello

import "fmt"

// Hello ...
func Hello(name string, age int, who string) {
    fmt.Printf("%s(%d): hello %s\n", who, age, name)
}

はい。go.modでのこのパッケージのトップレベルのパッケージ名は"m"なので"m/internal/hello"がパッケージパスです。

definitions.py

作ったhelloパッケージを利用するようにdefinitions.pyを書き換えましょう。runtime.generate()はcontext managerになっていて、内部でコードの出力に利用する中間表現を露出してくれます。prestringのModuleというオブジェクトなのですが、詳しい話は省略します。

とりあえず使い方は以下だけ覚えておいてください。

  • m.import_() -- パッケージのimport
  • m.stmt() -- コードを一行印字
def hello(*, name: str, age: int = 20, who: str = "foo") -> None:
     options.age.help = "age of subject"
     options.who.help = "name of subject"
 
-    with runtime.generate(clikit):
-        runtime.printf("%s(%d): hello %s\n", who, age, name)
+    with runtime.generate(clikit) as m:
+        hello_pkg = m.import_("m/internal/hello")
+        m.stmt(hello_pkg.Hello(name, age, who))  # m.stmtを忘れずに

m.import_("m/internal/hello") で返ってきたオブジェクトを利用すると、あたかもimport対象のパッケージで公開されている関数を呼んだかのように使えます(対応している構文は限定的です)。スタブなどを生成しているわけではないので、型の恩恵は受けられません。

生成してみましょう。

$ python definitions.py generate
[F] update  cmd/hello/main.go

go側のdiffは以下の様になります。importも追加されているのが嬉しい点です。

import (
    "fmt"
    "os"
    "log"
+   "m/internal/hello"
 )
 
 // this file is generated by egoist.generators.clikit
@@ -44,6 +45,6 @@ func main() {
 }
 
 func run(opt *Option) error {
-  fmt.Printf("%s(%d): hello %s\n", opt.Who, opt.Age, opt.Name)
+   hello.Hello(opt.Name, opt.Age, opt.Who)
    return nil
 }

実行もしてみましょう。動いてますね。

$ go run cmd/hello/main.go --name="world"
foo(20): hello world

疲れたので今日はこの辺でおしまいにします。本当はまだまだ語りたい以下の様な機能があります。

  • main.goで行うコンポーネントのDI
  • pythonのクラス定義からのstructの生成
  • 自分でinclude可能な機能を追加する方法

まとめ

長くなってきてしまいましたが、この辺でまとめを。

  • 以前から作っていたgoのみにフレームワークのプロトタイプができた
  • 今回はCLIの生成が主
  • 何度も実行されるコード生成がscaffoldとのちがい
  • 機能と機能の糊となるコードこそを生成したい
  • go側のパッケージをimportして呼び出す事ができた

gist