⌨️

Pythonの型ヒントと共に進化するコード(#14: Typerのすゝめ)

に公開
これまでの連載記事


CLI ツールを作るとき、何を使っていますか

Python でちょっとしたコマンドラインツールを作りたいとき、皆さんは何を使っているでしょうか。

標準ライブラリの argparse は追加インストール不要で手軽です。
サードパーティなら Click が定番かなと思います。

どちらも十分実用的なツールです。
ただ、引数が増えるにつれて定義と利用箇所が乖離しやすく、保守コストがじわじわ効いてくると感じる場面がありました。

argparse の場合

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("name", type=str, help="名前")
parser.add_argument("--count", type=int, default=1, help="回数")
args = parser.parse_args()
print(f"Hello {args.name}" * args.count)
print(args.typo_name)  # 存在しない属性へのアクセス

まず argparse の場合ですが、以下 3 つ書く必要があります。

  1. ArgumentParser を作る
  2. 必要な引数の分だけ add_argument() を並べる
  3. parse_args() する

当然ですが、引数が増えるたびに add_argument() が増えます。

そして厄介なのが args の型です。

args.typo_name のように存在しない属性にアクセスしても mypy は検出してくれません!
実行して初めて AttributeError で気づくことになります。

Click の場合

import click

@click.command()
@click.option("--count", default=1, help="回数")
def hello(count: str) -> None:  # default=1 なのに str と宣言
    print(count.upper())  # str のメソッドを呼ぶ

if __name__ == "__main__":
    hello()

Click は型ヒントを書けますが、デコレータで定義した型と関数シグネチャの型ヒントの整合性は検査されません。

上のコードは default=1(int)なのに count: str と宣言しています。
mypy は素通りしますが、実行すると AttributeError: 'int' object has no attribute 'upper' で落ちます…

デコレータと関数シグネチャで同じ情報を二度書くだけでなく、その整合性も自分で保証しなければなりません。

Click でも型指定やバリデーションは可能ですが、定義がデコレータ側に分散する以上、関数シグネチャとの対応関係を静的に保証するのは難しく、実装者の注意に依存しやすい設計になります。

そこで今回紹介するのが Typer です。

Typer とは

Typer は FastAPI の作者が 2020 年に公開した CLI 開発ライブラリです。内部では Click を基盤に使っているので安定性は折り紙付き。その上で、型ヒントを活用した宣言的な API を提供しています。

公式でもCLI 版の FastAPIと謳っており、FastAPI を触ったことがある人なら馴染みやすいはずです。

特徴をざっくり挙げると次のような感じです。

  • 関数の引数に型ヒントを書くだけで CLI の引数・オプションが定義される
  • エディタの補完や型チェックがそのまま効く
  • --help メッセージやシェル補完が自動生成される
  • シンプルなスクリプトから複数サブコマンドを持つアプリまで段階的に拡張できる

言葉で説明するより、実際にコード見た方が早いです。

最小構成の例

まずインストールします。

pip install "typer[all]"

[all] を付けると Rich(美しいエラー表示)や shellingham(シェル補完)も一緒に入ります。最小構成で試したいなら pip install typer だけでも動きます。

最もシンプルな Typer アプリはこうなります。

import typer


def main(name: str) -> None:
    """挨拶メッセージを表示する"""
    print(f"Hello {name}")


if __name__ == "__main__":
    typer.run(main)

typer.run(main) を呼ぶだけで、この関数がそのまま CLI になります。

$ python main.py Alice
Hello Alice

引数を渡さないと自動でエラーメッセージが出ます。

$ python main.py
Usage: main.py [OPTIONS] NAME
Try "main.py --help" for help.

Error: Missing argument 'NAME'.

--help も自動生成されます。

$ python main.py --help
Usage: main.py [OPTIONS] NAME

Arguments:
  NAME  [required]

Options:
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell.
  --help                Show this message and exit.

引数の説明やオプション一覧がコード中で特別な処理をしなくても出てきます。関数に docstring を書いておけばそれも反映されます。

型ヒントがそのまま CLI 定義になる

ここからが目玉です。
なんといっても関数の型ヒントがそのまま CLI の引数定義になるところが Typer の魅力です。

import typer


def greet(name: str, count: int = 1, formal: bool = False) -> None:
    """
    指定した名前に挨拶を繰り返す。

    --formal を付けると丁寧な挨拶になる。
    """
    greeting = f"Good day, {name}." if formal else f"Hi {name}!"
    for _ in range(count):
        print(greeting)


if __name__ == "__main__":
    typer.run(greet)
  • name: str → 必須の位置引数
  • count: int = 1--count オプション(デフォルト 1)
  • formal: bool = False--formal フラグ

ポイントは name: str にはデフォルト値がなく、型にも None が含まれていない点です。Typer はこの情報を見て省略不可の必須引数と判断します。一方、countformal にはデフォルト値があるためオプション扱いになります。

型変換も自動です。countint と書いてあるので、--count 3 と渡せば数値としてパースされます。数値に変換できない文字列を渡すとエラーになります。

$ python greet.py Alice --count 3
Hi Alice!
Hi Alice!
Hi Alice!

$ python greet.py Alice --count abc
Usage: greet.py [OPTIONS] NAME
Try "greet.py --help" for help.

Error: Invalid value for '--count': 'abc' is not a valid integer.

入力バリデーションのコードを一切書いていないのに、型が合わなければ弾いてくれる。型ヒントを書くだけでここまでやってくれるのは助かります。

複数コマンドを持つアプリ

サブコマンドを持たせたい場合は Typer() インスタンスを作って @app.command() デコレータを使います。

import typer

app = typer.Typer()


@app.command()
def hello(name: str) -> None:
    """挨拶する"""
    print(f"Hello {name}")


@app.command()
def goodbye(name: str, formal: bool = False) -> None:
    """別れの挨拶をする"""
    if formal:
        print(f"Goodbye Ms. {name}. Have a good day.")
    else:
        print(f"Bye {name}!")


if __name__ == "__main__":
    app()

これで hellogoodbye という 2 つのサブコマンドを持つ CLI になります。

$ python app.py hello World
Hello World

$ python app.py goodbye Alice --formal
Goodbye Ms. Alice. Have a good day.

--help でサブコマンド一覧も出ます。

$ python app.py --help
Usage: app.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  goodbye  別れの挨拶をする
  hello    挨拶する

関数を書き足せばコマンドが生える。このシンプルさがいいところです。

型ヒントをもっと活用する

ここまで見てきた例では strint といった基本的な型だけを使っていました。Typer はもう少し凝った型ヒントにも対応しています。

Annotated を使うと、型ヒントの中にヘルプ文字列やバリデーションルールを埋め込めます。

from typing import Annotated
from pathlib import Path
import typer


def process(
    input_file: Annotated[Path, typer.Argument(help="処理対象ファイル")],
    output_dir: Annotated[Path, typer.Option(help="出力先ディレクトリ")] = Path("."),
    limit: Annotated[int, typer.Option(min=1, max=100, help="処理件数の上限")] = 10,
) -> None:
    """ファイルを処理して結果を出力する"""
    print(f"Processing {input_file} -> {output_dir}, limit={limit}")


if __name__ == "__main__":
    typer.run(process)

Path 型を使えばファイルパスとして扱われますし、min / max で値の範囲も制限できます。型ヒントで表現したい意図がそのまま CLI の仕様になるわけです。

実際に使ってみて

弊社ではアプリケーションのエントリーポイントに argparse を使っています。
uvicorn main:app --host 0.0.0.0streamlit run app.py --server.port 8080 のように起動時に引数を渡すあのパターンです。

一方で、argparse では args.tenant_id のように引数へ動的な属性アクセスを行うため、引数名の誤りをしても静的解析では検出されません。
引数が少ないうちは問題になりにくいものの、数が増えるにつれて正しく参照できているかを実装者の注意に委ねる設計になりやすい点が気になっていました。

そこで最近になって Typer の導入をはじめました。
CLI の引数を関数シグネチャとして定義できるため、エディタ補完が自然に効き、少なくとも存在しない引数にアクセスするといった事故を未然に防げるようになります。

CLI の引数自体は頻繁に増減するものではありませんが、仮に数が増えたとしても関数シグネチャとして管理できることで安心して変更できる状態を保てると感じています。

まとめ

Typer は型ヒントを活用して CLI を宣言的に定義するライブラリです。

  • 関数の型ヒントがそのまま CLI の引数・オプション定義になる(実行時の仕様そのものになる)
  • 型変換やバリデーションが自動で行われる
  • --help やシェル補完が自動生成される
  • Click の安定性を土台にしつつ、より少ないコードで書ける

なお、冒頭でも触れましたが argparse や Click も十分実用的です。標準ライブラリだけで済ませたい場面もありますし、Click の柔軟なカスタマイズが必要な場面もあります。Typer は Click の全機能をカバーしているわけではないので、込み入った要件では Click を直接使う方がよいのかもしれません。

ただ、普段から型ヒントを書いている身としては、それがそのまま CLI の定義になるという体験は素直に気持ちいいです。

この連載では型ヒントによるコードの堅牢化をテーマにしていますが、Typer はその恩恵を CLI 開発にも広げてくれるツールだと思います。型チェッカーでコードを検証しながら CLI を作れるのは本連載の文脈にぴったりだと感じたので紹介しました。

CLI ツールを作る機会があったら一度試してみることをおすすめします!

👉 15 日目: SelfReadOnly

Discussion