Python には Click という最小限のコードで、構成可能な方法で美しいコマンドラインインターフェースを作成するためのパッケージがあります。*1
有名どころでは black や Flask などが現在使用しています。今知ったんですが、 Click と Flask って同じ Organization が作ってるんですね。
今回は、そんな Click のコマンド実行時の共通処理についてを取り上げます。
余談ですが、私も以前ブログで紹介した2つのプロジェクトにおいて Click を使用しています。もし興味があれば合わせて読んでいただければ幸いです。*2 *3
結論
この先長くなる可能性があるので最終的な結果を書いておきます。
前提コードは次の本題に書いています。
Click には Group
というクラスがあります。これを継承したサブクラスを実装し、共通の処理を実装できます。
import click class ClickCommonGroup(click.Group): def __call__(self, *args, **kwargs): click.echo("共通処理です!!") super().__call__(*args, **kwargs) @click.group(cls=ClickCommonGroup) def cli(): ... @click.command() def login(): (省略)
というように __call__
をオーバーライドし、 click.echo
を実装しコマンドを実行時に毎回 共通処理です!!
と表示されるようにしました。
また、 click.group
のデコレータ cls
にこの ClickCommonGroup
を渡してあげれば完了です。
今回の記事で紹介するのは例外処理に付いてですが、他の CLI 自体のアップデート確認処理など他のタスク実行にも使える手法かと思います。
本題
弊社で運営・開発している Shodo のプロダクトには Shodo Python CLI(以下 Shodo CLI)があります。*4
Shodo CLI では Shodo で公開している AI 校正 API と投稿のダウンロード機能を現在提供していおり、ユーザーが複数組織や複数プロジェクトにまたがり所属していることを考慮し、 AWS CLI のようなプロファイル機能を提供することになりました。
そこで、まんまパクリになってしまうのですが、 AWS CLI のようなエラー表示を実装することとしました。
Shodo CLI を例に出して、説明します。
import click @click.group() def cli(): ... @click.command(help="Login to Shodo API.") def login(): (省略) @click.command(help="Lint Japanese text.") @click.argument("filename", required=False, type=click.Path(exists=True, dir_okay=False)) @click.option("--html", help="Specify if the input is HTML.", default=False, is_flag=True) @click.option("--output", help="Output format.", default="text", type=click.Choice(["text", "json"])) def lint(filename, html, output): (省略) @cli.command(help="Download all of Markdown posts and images.") @click.option("--target", help="Target directory to save files.", default="docs", type=click.Path(file_okay=False, writable=True)) @click.option("--in-tree", help="Download only files with task Folder.", default=False, is_flag=True) def download(target, in_tree): (省略)
内部の実装コードはとりあえず関係ないので省略しましたが、上記のインターフェースを実装していました。 login
、 lint
、 download
にそれぞれ任意のオプション --profile
を受け取る実装を追加しました。(実装コードは省略します。)
受け取る例外は新しく定義する UnableLocateCredentialsError
とします。
プロファイルは以下の条件下で発生するように設計しています。
- そもそも認証情報をストアしているものがない
- ストアしているものがあるが指定したプロファイルに対応する認証情報が存在しない
実装コードはこちらを参照してください。
雑な方法
とりあえず雑に対応をしようと思えば、コマンドの実装に必要なところに try-except
を書けば解決します。
import sys from shodo.conf import UnableLocateCredentialsError (中略) def login(): try: (省略) except UnableLocateCredentialsError as e: click.echo(e.msg) sys.exit(255) def lint(): try: (省略) except UnableLocateCredentialsError as e: click.echo(e.msg) sys.exit(255)
このように、コマンドの処理にどんどん書きまくる方法は正攻法です。ただこの方法だとネストが無条件で1段深くなってしまいあまり嬉しくはありません🥺
Click 自体のエラーハンドリング
調べる前に上記で挙げられている誤った使用法を試していましたね
考えることは同じだった...ドキュメントを見よう!(戒め)
Click’s main error handling is happening in BaseCommand.main(). In there it handles all subclasses of ClickException as well as the standard EOFError and KeyboardInterrupt exceptions. The latter are internally translated into an Abort.
引用:https://click.palletsprojects.com/en/stable/exceptions/
書かれている通り、 BaseCommand.main()
において主なエラーハンドリングは行われるようです。
具体的にどう処理が行われてるのか全く見られていませんが、いい感じにできてるようです。(知らんけど)
解決法に向けて
以前から click.group
デコレータ周りが共通的な処理に関することをやっていそうということは思っていて、なんやかんやカクカクシカジカあって click.Group
にたどり着きました。
click.Group
の docstring にもありますが、
A group allows a command to have subcommands attached. This is the most common way to implement nesting in Click.
引用:https://click.palletsprojects.com/en/stable/api/#click.Group
This is the most common way to implement nesting in Click.
この部分、なるほど。よくわからんのでとりあえず使ってみよう。
結果
class ClickCatchExceptions(click.Group): def __call__(self, *args, **kwargs): try: self.main(*args, **kwargs) except UnableLocateCredentialsError as e: click.echo(e.msg) sys.exit(255) @click.group(cls=ClickCatchExceptions) def cli(): ... @click.command() def login(): (省略) @click.command() (中略) def lint(...): (省略) @click.command() (中略) def download(...): (省略)
というような感じで結論でも書いたような実装ができました。
ただ、この実装の欠点はすべて ClickCatchExceptions
に例外キャッチをさせるとどんどん肥大化してしまうことかなと思います。
おまけ
shodo-pythonの実装を例にして記事書いてくれて良いっすよ。オープンですし
ky さんから許可を得ています。