Pythonの型ヒントと共に進化するコード(#14: Typerのすゝめ)
これまでの連載記事
- 1 日目: なぜ Recustomer が型を語るのか
- 2 日目: イントロダクション
- 3 日目: 脆いコードお披露目
- 4 日目: 辞書にスキーマを与える
TypedDict - 5 日目: 「ないこともある」を表現する
UnionとOptional - 6 日目: ドメインの意図を込める
NewTypeとTypeAlias - 7 日目:
ABCで「契約」を定義する - 8 日目:
Protocolで柔軟性を得る - 9 日目: 責務を分ける
- 10 日目:
dataclassesとClassVar - 11 日目: コレクション抽象型(
Mapping) - 12 日目:
Finalで定数を保護する - 13 日目:
from __future__ import annotations
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 つ書く必要があります。
-
ArgumentParserを作る - 必要な引数の分だけ
add_argument()を並べる -
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 はこの情報を見て省略不可の必須引数と判断します。一方、count や formal にはデフォルト値があるためオプション扱いになります。
型変換も自動です。count に int と書いてあるので、--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()
これで hello と goodbye という 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 挨拶する
関数を書き足せばコマンドが生える。このシンプルさがいいところです。
型ヒントをもっと活用する
ここまで見てきた例では str や int といった基本的な型だけを使っていました。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.0 や streamlit 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 ツールを作る機会があったら一度試してみることをおすすめします!
Discussion