『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

Click で実装した CLI の実行時の共通処理の実装

Python には Click という最小限のコードで、構成可能な方法で美しいコマンドラインインターフェースを作成するためのパッケージがあります。*1

有名どころでは blackFlask などが現在使用しています。今知ったんですが、 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):
  (省略)

内部の実装コードはとりあえず関係ないので省略しましたが、上記のインターフェースを実装していました。 loginlintdownload にそれぞれ任意のオプション --profile を受け取る実装を追加しました。(実装コードは省略します。)

受け取る例外は新しく定義する UnableLocateCredentialsError とします。
プロファイルは以下の条件下で発生するように設計しています。

  • そもそも認証情報をストアしているものがない
  • ストアしているものがあるが指定したプロファイルに対応する認証情報が存在しない

実装コードはこちらを参照してください。

https://github.com/zenproducts/shodo-python/blob/7527b12f16853e9c2d92f71bb7b00f94a491481c/shodo/conf.py#L39-L57

雑な方法

とりあえず雑に対応をしようと思えば、コマンドの実装に必要なところに 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 さんから許可を得ています。

執筆:@nnsnodnb
Shodoで執筆されました

*1:https://click.palletsprojects.com/en/stable/ 公式ドキュメント和訳

*2:GitHub の Star はないものの前者は現在約850/month、後者は約2.1k/monthのダウンロードがあるようです。多謝!

*3:GitHub Star くださいの意⭐️⭐️⭐️

*4:意図せず宣伝になってしまった