CLI ツールの作成 (Typer)
argparse ではなく Typer で型ヒント優先の CLI を作る方法。サブコマンド、自動補完、Rich 出力との統合まで扱います。
本書の最終章です。5部(運用・パッケージング・テスト)の締めくくりとして、型ヒント優先 で CLI を作る方法を扱います。本章の成果物を 第32章 の流れで PyPI にリリースすれば、CLI の実装から配布までがつながります。
道具は Typer です — FastAPI と同じ作者が作った CLI フレームワークで、関数のシグネチャが即 CLI インターフェース という同じ哲学を共有しています。
argparse の限界 #
標準ライブラリの argparse があるのに Typer を選ぶ理由は次のとおりです。
import argparse
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("name", type=str)
parser.add_argument("--age", type=int, default=0)
parser.add_argument("--greet", action="store_true")
args = parser.parse_args()
if args.greet:
print(f"Hello, {args.name}! You are {args.age}.")問題は 3 つあります。
- 型ヒントがない —
args.nameの型を IDE が知りません。 - boilerplate が多い — 関数のすることよりパーサ設定コードのほうが長くなります。
- 検証を自分でやる — Enum、Path、範囲などは追加コード。
Typer は同じコードを次のように縮めます。
import typer
def main(name: str, age: int = 0, greet: bool = False) -> None:
if greet:
print(f"Hello, {name}! You are {age}.")
if __name__ == "__main__":
typer.run(main)型ヒントが即検証であり、default が即オプションのデフォルト値であり、bool が即フラグです。
インストールと初実行 #
uv add typerimport typer
def main(name: str) -> None:
print(f"Hello, {name}!")
if __name__ == "__main__":
typer.run(main)$ uv run python hello.py World
Hello, World!
$ uv run python hello.py --help
Usage: hello.py [OPTIONS] NAME
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ * name TEXT [default: None] [required] │
╰────────────────────────────────────────────────────────────────────────────╯--help も自動生成されます。
オプション vs 引数 #
ルールは単純です。
- デフォルト値がなければ引数 (argument) — 呼び出し時に位置で渡す。
mycli hello World。 - デフォルト値があればオプション (option) —
--name=Worldまたは--name World。
def main(name: str, age: int = 0) -> None:
...name→ required positional argument。age→--age=30optional。
bool のデフォルトは False が自然で、自動的に --flag / --no-flag の 2 つの形が作られます。
def main(verbose: bool = False) -> None: ...$ mycli --verbose
$ mycli --no-verboseAnnotated パターン — メタデータの分離 #
基本形は綺麗ですが、ヘルプ、短い名前、値検証などを加えるには Annotated が標準パターンです。
from typing import Annotated
import typer
def main(
name: Annotated[str, typer.Argument(help="名前を教えてください。")],
age: Annotated[int, typer.Option("--age", "-a", min=0, max=150)] = 0,
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
) -> None:
if verbose:
print(f"name={name}, age={age}")
print(f"Hello, {name}!")$ uv run python annotated_example.py --help
...
-a, --age INTEGER RANGE [0<=x<=150]
-v, --verbose / --no-verboseAnnotated[X, typer.Option(...)] で 型はそのままにメタデータだけ追加 します。このパターンが本書全体で見た — Pydantic、FastAPI、Typer が共有する — 一貫したスタイルです。
サブコマンド — Typer アプリツリー #
git のように mycli init、mycli build、mycli publish のようなサブコマンドを作るには typer.Typer インスタンスを作って @app.command() を付けます。
import typer
app = typer.Typer(help="Schoolofweb sample CLI")
@app.command()
def init(path: str = ".") -> None:
"""プロジェクトの初期化。"""
typer.echo(f"init at {path}")
@app.command()
def build() -> None:
"""プロジェクトのビルド。"""
typer.echo("building...")
@app.command()
def publish(prod: bool = False) -> None:
"""デプロイ。"""
target = "PRODUCTION" if prod else "STAGING"
typer.echo(f"publishing to {target}")
if __name__ == "__main__":
app()$ swcli --help
Commands
init プロジェクトの初期化。
build プロジェクトのビルド。
publish デプロイ。
$ swcli publish --prod
publishing to PRODUCTIONネスト — サブ-サブコマンド #
auth_app = typer.Typer(help="認証関連コマンド。")
app.add_typer(auth_app, name="auth")
@auth_app.command()
def login(email: str) -> None:
typer.echo(f"logging in as {email}")
@auth_app.command()
def logout() -> None:
typer.echo("logged out")$ swcli auth login me@example.com
logging in as me@example.comgit の git remote add、kubectl の kubectl config set-context のように深いツリーを作れます。
Rich との統合 — 色・進捗・テーブル #
Typer は内部的に Rich を使っています — typer.echo の代わりに直接 Rich の Console、Progress、Table を使うと UX がより豊かになります。
Console #
from rich.console import Console
console = Console()
console.print("[bold green]Success![/]")
console.print("[red]Failed:[/] connection refused")Progress #
import time
from rich.progress import Progress
@app.command()
def crawl(pages: int = 100) -> None:
with Progress() as progress:
task = progress.add_task("[cyan]Crawling...", total=pages)
for _ in range(pages):
time.sleep(0.05)
progress.advance(task)Progress が自動的にパーセンテージ・ETA・スピナーを描画してくれます。
Table #
from rich.table import Table
@app.command()
def users() -> None:
table = Table(title="Users")
table.add_column("ID", justify="right", style="cyan")
table.add_column("Email", style="magenta")
table.add_column("Active", justify="center")
table.add_row("1", "alice@example.com", "✔")
table.add_row("2", "bob@example.com", "✘")
console.print(table)コンソールが色をサポートしない環境(CI ログ、パイプ)では Rich が自動で色を切ってくれます。
環境変数の自動マッピング #
オプションに envvar= を渡すと、CLI 引数がないとき環境変数から自動で読み取ります。
@app.command()
def deploy(
api_key: Annotated[
str,
typer.Option(envvar="SWCLI_API_KEY", help="API key"),
],
) -> None:
typer.echo(f"deploying with {api_key[:4]}***")$ export SWCLI_API_KEY=secret123
$ swcli deploy
deploying with secr***CI 環境で secret を環境変数として渡す標準的な流れと自然に合います。
自動補完 (bash・zsh・fish) #
Typer は一つのコマンドでシェルの自動補完をインストールします。
swcli --install-completionインストール後 swcli p<TAB> とすれば publish が自動補完されます。サブコマンドのオプション(--prod)も自動補完されます — ユーザーの学習コストを大きく減らします。
終了コードとエラー処理 #
CLI の慣習は 0 = 成功、非 0 = 失敗 です。Typer では typer.Exit を投げます。
@app.command()
def lint(path: str) -> None:
errors = run_lint(path)
if errors:
typer.secho(f"{len(errors)} errors", fg="red", err=True)
raise typer.Exit(code=1)
typer.secho("OK", fg="green")err=True→ stderr に出力(ユーザーが stdout をファイルにリダイレクトしてもエラーメッセージは見える)。raise typer.Exit(code=1)→ 終了コードを設定。
スクリプトと組み合わせるツールであれば stdout と stderr の分離が重要です。
テスト — CliRunner #
Typer は click の CliRunner をそのまま露出します。実際にプロセスを立ち上げず、in-process でテストします。
from typer.testing import CliRunner
from swcli.cli import app
runner = CliRunner()
def test_hello() -> None:
result = runner.invoke(app, ["init", "/tmp/myproj"])
assert result.exit_code == 0
assert "init at /tmp/myproj" in result.stdout
def test_publish_prod() -> None:
result = runner.invoke(app, ["publish", "--prod"])
assert result.exit_code == 0
assert "PRODUCTION" in result.stdout
def test_missing_required_arg() -> None:
result = runner.invoke(app, ["init"])
assert result.exit_code == 0 # init の path はデフォルト値があるので OKmix_stderr=False にすると stderr を別途検証できます。
Click との関係 #
Typer は内部的に Click を使っています — Typer = Click の 型ヒント優先 wrapper です。Click の豊富な機能(Context、pass_context、callback)はそのまま Typer でも動作します。
既存の Click コードベースに新しいコマンドを Typer で追加したり、Typer で作ったツールの一部に Click の高度な機能を混ぜたりすることも可能です。
よくある落とし穴 #
typer.Option のデフォルト位置
#
Annotated を使うときデフォルトは 関数シグネチャの = の後ろ に来ます。
# OK
def f(x: Annotated[int, typer.Option()] = 0) -> None: ...
# 非推奨 (旧式スタイル)
def f(x: int = typer.Option(0)) -> None: ...旧式スタイルでも動作しますが、pyright の strict モードでデフォルトの型推論が不便になります。
モジュールを entry-point として露出しない #
第32章 で見た project.scripts の右辺が誤っていると、インストール後にコマンドが見つかりません。
[project.scripts]
swcli = "swcli.cli:app" # OK — src/swcli/cli.py の app オブジェクトインストール後 which swcli で確認します。
bool オプションの --no- prefix が意図と違う
#
use_cache: bool = True と書くと自動で --use-cache / --no-use-cache が作られます。--no-use-cache が不自然なら明示的にオプション名を与えます。
def f(
no_cache: Annotated[bool, typer.Option("--no-cache")] = False,
) -> None: ...print vs typer.echo vs console.print
#
print— 最も一般的。通常 OK。typer.echo— click の echo。Windows の colorama 互換を自動処理。console.print— Rich のマークアップ([bold red])対応。
複雑な出力なら console.print、単純なメッセージなら print で十分です。1 つのプロジェクト内では 1 つに統一します。
大きなコマンドツリーでの import コスト #
@app.command() が付いたモジュールをすべて import すると CLI 起動が遅くなります。あまり使わないコマンドは lazy import するか、click の LazyGroup パターンを適用します。
第32章とのセット — リリース手順までつなげる #
pyproject.toml に次の内容を追加するだけで、本章の CLI がパッケージとしてインストールされます。
[project]
name = "swcli"
version = "0.1.0"
dependencies = [
"typer>=0.12,<1",
"rich>=13,<15",
]
[project.scripts]
swcli = "swcli.cli:app"第32章の流れどおりに uv build && uv publish すれば、ユーザーは pip install swcli のあと、swcli init、swcli publish --prod のようなコマンドを手にできます。
本書の終わりに #
ここまで来た方に一度まとめておきます。
- 1部 (1〜7章) で変数・型ヒント・制御フロー・コレクション・関数・エラー・pyproject を見ました。
- 2部 (8〜14章) で dataclass・Generic・Protocol・コンテキストマネージャ・generator・decorator・パターンマッチ・asyncio 入門を扱いました。
- 3部 (15〜21章) でマジックメソッド・descriptor・metaclass・asyncio の深さ・GIL・typing 上級・性能まで踏み込みました。
- 4部 (22〜29章) で FastAPI・Pydantic・SQLAlchemy・認証・バックグラウンドジョブ・テストとデプロイ・総合実習まで、一つの Web サービスを最初から最後まで作りました。
- 5部 (30〜33章) で静的検証・観測性・ライブラリリリース・CLI まで、運用可能なツールに仕上げる方法を見ました。
これらすべてが一つの言語 — Python — の中で一貫したメンタルモデルでつながっています。型ヒントは検証・ドキュメント・自動補完になり、関数のシグネチャはルートになり CLI になり、async は web・バックグラウンド・DB を同じパターンで束ね、ツールチェーンは一つの pyproject.toml にまとまります。
練習問題 #
swcliのリリース手順を実体験 — 本章のswcliをそのまま作って 第32章 の流れで TestPyPI に 0.0.1 としてリリースしてみてください。インストール後、swcli --install-completionまで動作するか確認してください。どこで詰まりましたか?- サブコマンドツリーの拡張 — 自身の日常作業の一つ(例: 「git repo 整理」、「写真ファイル整理」、「API レスポンスキャッシュ削除」)を選び、Typer でサブコマンドツリーを持つ CLI として作ってみてください。自動補完と環境変数マッピングを活用すると、日常コマンドはどれだけ短くなりますか?
- CliRunner の限界 —
CliRunnerは in-process なのでos.exitのような一部の動作は捕まえられません。自身の CLI のうち、本物のプロセスを立ち上げないと検証できない動作(signal 処理、subprocess 呼び出し)があれば、subprocess.run(["swcli", ...])ベースのテストとCliRunnerベースのテストをどう分けますか?
本書の最終章です。5部の最初の章 第30章 型チェッカ設定と CI 統合 から本章まで、一本の線でつながります — コードの正確性を自動化し(第30章)、運用中に何が起きているかを見えるようにし(第31章)、作ったコードを世に出し(第32章)、ユーザーが手に取って使うインターフェースを作る(第33章)。本書のすべての章がこの一本の線で出会います。
次はあなた自身のプロジェクトです。本書がその出発点になっていることを願います。