目次
33 章

CLI ツールの作成 (Typer)

argparse ではなく Typer で型ヒント優先の CLI を作る方法。サブコマンド、自動補完、Rich 出力との統合まで扱います。

本書の最終章です。5部(運用・パッケージング・テスト)の締めくくりとして、型ヒント優先 で CLI を作る方法を扱います。本章の成果物を 第32章 の流れで PyPI にリリースすれば、CLI の実装から配布までがつながります。

道具は Typer です — FastAPI と同じ作者が作った CLI フレームワークで、関数のシグネチャが即 CLI インターフェース という同じ哲学を共有しています。

argparse の限界 #

標準ライブラリの argparse があるのに Typer を選ぶ理由は次のとおりです。

argparse で同じことをする
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 は同じコードを次のように縮めます。

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 typer
hello.py
import 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=30 optional。

bool のデフォルトは False が自然で、自動的に --flag / --no-flag の 2 つの形が作られます。

def main(verbose: bool = False) -> None: ...
$ mycli --verbose
$ mycli --no-verbose

Annotated パターン — メタデータの分離 #

基本形は綺麗ですが、ヘルプ、短い名前、値検証などを加えるには Annotated が標準パターンです。

annotated_example.py
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-verbose

Annotated[X, typer.Option(...)]型はそのままにメタデータだけ追加 します。このパターンが本書全体で見た — Pydantic、FastAPI、Typer が共有する — 一貫したスタイルです。

サブコマンド — Typer アプリツリー #

git のように mycli initmycli buildmycli publish のようなサブコマンドを作るには typer.Typer インスタンスを作って @app.command() を付けます。

src/swcli/cli.py
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.com

git の git remote add、kubectl の kubectl config set-context のように深いツリーを作れます。

Rich との統合 — 色・進捗・テーブル #

Typer は内部的に Rich を使っています — typer.echo の代わりに直接 Rich の ConsoleProgressTable を使うと 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 でテストします。

tests/test_cli.py
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 はデフォルト値があるので OK

mix_stderr=False にすると stderr を別途検証できます。

Click との関係 #

Typer は内部的に Click を使っています — Typer = Click の 型ヒント優先 wrapper です。Click の豊富な機能(Contextpass_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 がパッケージとしてインストールされます。

pyproject.toml — 第33章 + 第32章
[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 initswcli 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 にまとまります。

練習問題 #

  1. swcli のリリース手順を実体験 — 本章の swcli をそのまま作って 第32章 の流れで TestPyPI に 0.0.1 としてリリースしてみてください。インストール後、swcli --install-completion まで動作するか確認してください。どこで詰まりましたか?
  2. サブコマンドツリーの拡張 — 自身の日常作業の一つ(例: 「git repo 整理」、「写真ファイル整理」、「API レスポンスキャッシュ削除」)を選び、Typer でサブコマンドツリーを持つ CLI として作ってみてください。自動補完と環境変数マッピングを活用すると、日常コマンドはどれだけ短くなりますか?
  3. CliRunner の限界CliRunner は in-process なので os.exit のような一部の動作は捕まえられません。自身の CLI のうち、本物のプロセスを立ち上げないと検証できない動作(signal 処理、subprocess 呼び出し)があれば、subprocess.run(["swcli", ...]) ベースのテストと CliRunner ベースのテストをどう分けますか?
注記
一行まとめ — Typer は関数のシグネチャが即 CLI インターフェースという哲学で argparse の boilerplate をなくします。Annotated でメタデータを分離し、Rich で出力を豊かにし、CliRunner で in-process テストをしたあと、第32章の流れで PyPI にリリースすれば、制作から配布までつながります。

本書の最終章です。5部の最初の章 第30章 型チェッカ設定と CI 統合 から本章まで、一本の線でつながります — コードの正確性を自動化し(第30章)、運用中に何が起きているかを見えるようにし(第31章)、作ったコードを世に出し(第32章)、ユーザーが手に取って使うインターフェースを作る(第33章)。本書のすべての章がこの一本の線で出会います。

次はあなた自身のプロジェクトです。本書がその出発点になっていることを願います。

X