最近のgoでCLIのコマンドを作るときの雛形のメモ (updated)

昔にも似たような記事を書いていたみたいだった。最近のgoでのCLIツールを書く際の心境の変化があったのでその辺をメモしておく。

心境の変化

心境の変化は2つ

  • コマンドラインオプションをstructで囲む理由が特になくなった
  • サブコマンドがやっぱりほしい

コマンドラインオプションをstructで囲む理由が特になくなった

昔はわざわざコマンドラインオプションをstructで囲んで、Run()関数に渡していたのだけれど。そもそもRun()関数が長々と書かれていたらそれはmain.goの記述が長すぎる。 一度しか使われないものはグローバル変数で良いということでグローバルの変数としてポインタを保持する形式になった。

Run()に対して引数として渡す必要もなくて、dereferenceしてあげればおしまい。それ以前の段階で引数のvalidationは通っているはずなので。 Run()がエラー値を返すのはそのまま。

サブコマンドがやっぱりほしい

サブコマンドのものとコマンドが1つだけのものをほとんど同様の記述で書きたい。そういうわけで少し不思議なコードになっている。 (どの辺が不思議かというと、例えば Cmd = kingpin.CommandLine のあたり)

コマンドが1つだけの場合

以下の様な感じで書く。kingpinを使うのは惰性なので他のライブラリが便利ならそれを使っても良い。

package main

import (
    "fmt"
    "log"
    "os"

    kingpin "gopkg.in/alecthomas/kingpin.v2"
)

var (
    // Cmd ...
    Cmd = kingpin.CommandLine

    debug = Cmd.Flag("debug", "Enable debug mode.").Bool()
    name  = Cmd.Arg("name", "name").Required().String()
)

func init() {
    Cmd.Name = "hello world"
    Cmd.Help = "hello world example"
    Cmd.Version("0.0.1")
}

func main() {
    _, err := Cmd.Parse(os.Args[1:])
    if err != nil {
        Cmd.FatalUsage(fmt.Sprintf("\x1b[33m%+v\x1b[0m", err))
    }
    if err := run(); err != nil {
        log.Fatalf("!!%+v", err)
    }
}

func run() error {
    name := *name

    fmt.Println("hello", name)
    return nil
}

実行結果

ヘルプメッセージ

usage: hello world [<flags>] <name>

hello world example

Flags:
  --help     Show context-sensitive help (also try --help-long and --help-man).
  --debug    Enable debug mode.
  --version  Show application version.

Args:
  <name>  name

実行結果

$ go run 00onefile/main.go world
hello world

サブコマンド対応

(手元で動かすためにgo.modやimport pathにhackが入っているけれどその辺りは読み替えてほしい)

サブコマンドを定義するときにはだいたい以下の様な構造にしている。結構何も考えずにパッケージを分けちゃったほうがあとあと楽という結論になった。

この例では以下2つのサブコマンドを定義している

  • register
  • post
$ tree -P "*.go" 01multifiles/
01multifiles/
├── main.go
├── post
│   └── cmd.go
└── register
    └── cmd.go

go.mod

module m

go 1.13

require (
    github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
    github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
    gopkg.in/alecthomas/kingpin.v2 v2.2.6
)

ポイントはサブコマンドとしての定義とそうではない場合の1つのコマンドとしての定義の記述が概ね同じような形になるようにしている点。あと気持ちの問題でヘルプメッセージに色を付けている。

main.go

package main

import (
    "fmt"
    "log"
    post "m/01multifiles/post"
    register "m/01multifiles/register"
    "os"

    kingpin "gopkg.in/alecthomas/kingpin.v2"
)

var (
    // Cmd ...
    Cmd = kingpin.CommandLine
)

func init() {
    Cmd.Name = "multi commands"
    Cmd.Help = "multi commands example"
    Cmd.Version("0.0.1")
}

func main() {
    command, err := Cmd.Parse(os.Args[1:])
    if err != nil {
        Cmd.FatalUsage(fmt.Sprintf("\x1b[33m%+v\x1b[0m", err))
    }

    switch command {
    // Register user
    case register.Cmd.FullCommand():
        err = register.Run()
    // Post message
    case post.Cmd.FullCommand():
        err = post.Run()
    }

    if err != nil {
        log.Fatalf("!!%+v", err)
    }
}

register/cmd.go

package register

import (
    "fmt"

    kingpin "gopkg.in/alecthomas/kingpin.v2"
)

var (
    // Cmd ...
    Cmd = kingpin.Command("register", "Register a new user.")

    nickname = Cmd.Flag("nickname", "Nickname for user.").Default("anonymous").String()
    name     = Cmd.Arg("name", "Name of user.").Required().String()
)

// Run ...
func Run() error {
    name := *name
    nickname := *nickname
    fmt.Println("register, name=", name, "nickname=", nickname)
    return nil
}

post/cmd.go

package post

import (
    "fmt"

    kingpin "gopkg.in/alecthomas/kingpin.v2"
)

var (
    // Cmd ...
    Cmd = kingpin.Command("post", "Post a message to a channel.")

    image   = Cmd.Flag("image", "Image to .").File()
    channel = Cmd.Arg("channel", "Channel to  to.").Required().String()
    text    = Cmd.Arg("text", "Text to .").Strings()
)

// Run ...
func Run() error {
    image := *image
    channel := *channel

    fmt.Println("post, image=", image, "channel=", channel)
    return nil
}

実行結果

ヘルプメッセージ (全体)

usage: multi commands [<flags>] <command> [<args> ...]

multi commands example

Flags:
  --help     Show context-sensitive help (also try --help-long and --help-man).
  --version  Show application version.

Commands:
  help [<command>...]
    Show help.

  post [<flags>] <channel> [<text>...]
    Post a message to a channel.

  register [<flags>] <name>
    Register a new user.

ヘルプメッセージ (サブコマンド)

usage: multi commands register [<flags>] <name>

Register a new user.

Flags:
  --help                  Show context-sensitive help (also try --help-long and
                          --help-man).
  --version               Show application version.
  --nickname="anonymous"  Nickname for user.

Args:
  <name>  Name of user.

実行結果

$ go run 01multifiles/main.go register world
register, name= world nickname= anonymous

monogusaを使ってただのpythonの関数をslackやdiscord上のコマンドとして公開できるようになった

今までただの関数をCLIとして利用できたり、ちょっとしたwebAPIとして利用できるようにしていたけれど。 今度はmonogusaを使ってただのpythonの関数をslackやdiscord上のコマンドとして公開できるようになった。

簡単な例

例えば以下の様な関数がある。本当にただの関数。

02hello.py

def hello(*, name: str) -> None:
    print(f"hello {name}")


def byebye() -> None:
    print("byebye")

これを以下の様な形で呼ぶとbotになる。

$ python -m monogusa.chatbot.slackcli 02hello.py

slack上での表示

slackbotの場合にはthreadとして実行結果を返す。インターフェイス自体はほぼほぼCLI上のコマンドのまま。 丁寧な進捗表示やコンソールの同期のようなリッチな機能は存在していない。

デフォルトでは $app で始まる投稿に反応する。これは実行時の --name オプションで変えられる。 とりあえずヘルプメッセージを表示してみる。

f:id:podhmo:20200104204232p:plain

helloとbyebyeが公開されている。試しにhelloを実行してみる。引数が足りなかった。

f:id:podhmo:20200104204246p:plain

--name=world を追加してみる。良さそう。

f:id:podhmo:20200104204300p:plain

そういう感じ。

tokenの設定方法

注意点としてtokenの設定をしないと以下の様なエラーが出る。この辺はもう少し親切にするかもしれない。

$ python -m monogusa.chatbot.slackcli 02hello.py
Traceback (most recent call last):
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 193, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/__main__.py", line 8, in <module>
    def run(
  File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/__init__.py", line 30, in as_command
    return call(fn, level=level, argv=argv)
  File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/__init__.py", line 25, in call
    return driver.run(fn, argv)
  File "$HOME/vboxshare/venvs/my/lib/python3.8/site-packages/handofcats/driver.py", line 44, in run
    return fn(**params)
  File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/__main__.py", line 22, in run
    token = token or component.api_token()
  File "$HOME/vboxshare/venvs/my/monogusa/monogusa/chatbot/slackcli/component.py", line 17, in api_token
    return os.environ[envvar]
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/os.py", line 673, in __getitem__
    raise KeyError(key) from None
KeyError: 'SLACKCLI_API_TOKEN'

tokenの設定方法は以下の2つ

  • .envに書く
  • オプションで渡す

.envには以下の様に書く(cwdの.envを見る)。

SLACKCLI_API_TOKEN = xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxXXxXxxxxXxXxXXxxxxxxxx

オプションで渡す場合は--tokenに。

$ python -m monogusa.chatbot.slackcli --token <TOKEN> <FILE>

discord

slackしかこの記事では触れなかったけれど。discord上にも同様の手順で公開できる。

full examples

もう少しまともな利用例は以下を見ると良い。sqliteを使ったmini todo app的な例がある。

f:id:podhmo:20200104204803p:plain