TyperとPythonでモダンなCLIツールを構築する:型ヒント、バリデーション、プロフェッショナルなコマンドラインインターフェース

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と理由:深夜2時のスクリプト問題

午前2時7分、オンコールアラートが鳴り響いた。誰かがメンテナンススクリプトを --mode=delete ではなく --mode=delet で実行してしまい、バッチジョブがサイレントに失敗していた。バリデーションも、エラーも何もない。ただ……何も起きなかった。3時間のデバッグの末、根本原因が判明した:文字列を一切チェックせず何でも受け付ける、手書きの argparse スクリプトだった。

あの夜を境に、場当たり的なCLIスクリプトを書くのをやめ、きちんとしたツールを構築するようになった。それ以来、2つの異なるチームで4つの社内ツールをTyperで作り直した。不正な引数によるエラーはゼロになり、--help の出力が現実と乖離することもなくなった。

TyperはClickの上に構築されたPythonライブラリだ。PythonのType Hintsを使って、CLI引数、オプション、バリデーションを自動的に定義する。型アノテーション付きの通常のPython関数を書くだけで、Typerが --help テキスト、補完スクリプト、入力バリデーションを含むインターフェースを自動生成してくれる。

argparse や素のClickとの違いを挙げると:

  • 型ヒントが引数定義そのものになる — 重複記述が不要
  • バリデーションは関数実行前に行われる — 不正な入力がロジックに届くことはない
  • bash、zsh、fishでオートコンプリートがすぐに使える
  • サブコマンドは app の下にグループ化されたPython関数に過ぎない

インストール

クリーンな仮想環境から始めよう — プロジェクト間でTyperのバージョンが混在すると、Clickとの互換性で微妙な問題が生じる:

python -m venv .venv
source .venv/bin/activate  # Windowsの場合: .venv\Scripts\activate
pip install "typer[all]"

[all] オプションを付けると、色付き出力用の rich と、シェル検出用の shellingham(オートコンプリートのインストールに必要)が一緒にインストールされる。Dockerコンテナ向けに最小限の構成で構築する場合は、pip install typer だけでも動作する。

インストールを確認する:

python -c "import typer; print(typer.__version__)"

設定:初めての本格的なCLIを構築する

おもちゃの「hello world」サンプルは忘れよう。これは実際にプロダクションで動くもの — サブコマンド、バリデーション済みオプション用のEnum、適切なエラーハンドリングを備えたデータベースメンテナンスツールだ。

基本的なアプリ構造

import typer
from enum import Enum
from pathlib import Path
from typing import Optional

app = typer.Typer(name="dbmaint", help="データベースメンテナンスツールキット")

class RunMode(str, Enum):
    dry_run = "dry-run"
    execute = "execute"
    rollback = "rollback"

@app.command()
def cleanup(
    database: str = typer.Argument(..., help="対象のデータベース名"),
    mode: RunMode = typer.Option(RunMode.dry_run, help="実行モード"),
    older_than_days: int = typer.Option(30, min=1, max=365, help="N日以上前のレコードを削除"),
    config: Optional[Path] = typer.Option(None, exists=True, help="設定ファイルのパス"),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
):
    """対象データベースから古いレコードを削除する。"""
    if verbose:
        typer.echo(f"モード: {mode.value}, DB: {database}, 日数: {older_than_days}")
    
    if mode == RunMode.dry_run:
        typer.secho("[ドライラン] 変更は行われません", fg=typer.colors.YELLOW)
    elif mode == RunMode.execute:
        typer.secho(f"{older_than_days}日以上前のレコードを削除中...", fg=typer.colors.GREEN)
        # 実際のロジックをここに記述
    
if __name__ == "__main__":
    app()

3つのバリデーションはすべて自動だ。RunMode はEnumなので、--mode=delet を渡すと有効な選択肢を一覧表示した明確なエラーが即座に表示される。older_than_daysmin=1, max=365 制約は、関数が実行される前に範囲外の整数を拒否する。設定ファイルパスの exists=True は、ファイルがディスク上に実際に存在するかどうかを確認する。

引数なしで実行すると、きれいなヘルプ出力が表示される:

python dbmaint.py cleanup --help
Usage: dbmaint cleanup [OPTIONS] DATABASE

  対象データベースから古いレコードを削除する。

Arguments:
  DATABASE  対象のデータベース名  [required]

Options:
  --mode [dry-run|execute|rollback]  実行モード  [default: dry-run]
  --older-than-days INTEGER RANGE    N日以上前のレコードを削除  [default: 30]
  --config PATH                      設定ファイルのパス
  -v, --verbose / --no-verbose
  --help                             このメッセージを表示して終了。

サブコマンドの追加

実際のツールには複数の操作が必要だ。Typerでは、各サブコマンドは @app.command() でデコレートされた個別の関数となる:

@app.command()
def backup(
    database: str = typer.Argument(...),
    output: Path = typer.Option(Path("./backups"), writable=True, help="バックアップの保存先"),
    compress: bool = typer.Option(True, help="gzipで出力を圧縮する"),
):
    """データベースのバックアップを作成する。"""
    typer.echo(f"{database} を {output} にバックアップ中")
    # バックアップロジックをここに記述

@app.command()
def restore(
    database: str = typer.Argument(...),
    backup_file: Path = typer.Argument(..., exists=True, help="復元元のバックアップファイル"),
    force: bool = typer.Option(False, "--force", "-f", help="確認プロンプトをスキップ"),
):
    """バックアップからデータベースを復元する。"""
    if not force:
        confirm = typer.confirm(f"{database} を上書きします。続行しますか?")
        if not confirm:
            raise typer.Abort()
    typer.echo(f"{backup_file} から {database} を復元中")

python dbmaint.py --help を実行すると、3つのコマンドがすべて自動的に表示される。ディスパッチテーブルを管理する必要もなく、ヘルプテキストを手動で更新する必要もない — これはコードレビューで驚くほどの時間節約になる。

複雑なツール向けのネストされたアプリ

コマンドが多いツールの場合、サブアプリにグループ化しよう:

import typer

app = typer.Typer()
db_app = typer.Typer(help="データベース操作")
user_app = typer.Typer(help="ユーザー管理")

app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")

@db_app.command("migrate")
def db_migrate(target: str = typer.Argument("head")):
    """データベースマイグレーションを実行する。"""
    typer.echo(f"マイグレーション先: {target}")

@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
    """新しいユーザーアカウントを作成する。"""
    typer.echo(f"ユーザーを作成中: {username} ({email})")

使い方は python tool.py db migrate headpython tool.py user create johndoe [email protected] となる。階層構造は各レベルの --help に表示される。

コールバックを使ったカスタムバリデーション

組み込みの制約だけでは不十分な場合もある。Typerはカスタムバリデーション用のパラメータコールバックをサポートしている:

import re

def validate_db_name(value: str) -> str:
    if not re.match(r'^[a-z][a-z0-9_]{2,63}$', value):
        raise typer.BadParameter(
            "データベース名は英字で始まり、小文字、数字、"
            "アンダースコアのみ使用でき、3〜64文字の長さである必要があります。"
        )
    return value

@app.command()
def create_db(
    name: str = typer.Argument(..., callback=validate_db_name, help="新しいデータベース名"),
):
    """新しいデータベースを作成する。"""
    typer.secho(f"データベースを作成中: {name}", fg=typer.colors.GREEN)

コールバックは関数本体の前に実行される。不正な名前を渡すと、データベース接続を試みる前に、パラメータ名、値、メッセージを含む明確なエラーが表示される。

検証とモニタリング

CLIコマンドのテスト

Typerにはサブプロセスを生成せずに出力をキャプチャするテストクライアントが付属している — 高速なユニットテストに不可欠だ:

from typer.testing import CliRunner
from myapp import app

runner = CliRunner()

def test_cleanup_dry_run():
    result = runner.invoke(app, ["cleanup", "mydb", "--mode", "dry-run"])
    assert result.exit_code == 0
    assert "ドライラン" in result.output

def test_cleanup_invalid_mode():
    result = runner.invoke(app, ["cleanup", "mydb", "--mode", "invalid"])
    assert result.exit_code != 0
    assert "Invalid value" in result.output

def test_cleanup_days_out_of_range():
    result = runner.invoke(app, ["cleanup", "mydb", "--older-than-days", "999"])
    assert result.exit_code != 0

これらのテストはそれぞれ約5msで実行される。exit_code に注目しよう — Typerは不正な引数に対してコード2、Abort() に対してコード1、成功時はコード0で終了する。CIパイプラインでは、これらのコードがリリース前にテストスイートでキャッチされるものだ。

終了コードとシェル統合

シェルスクリプトやcronジョブでTyperツールをラップする場合、終了コードが重要になる:

# cronジョブやCIスクリプト内での記述例:
python dbmaint.py cleanup production --mode execute
if [ $? -ne 0 ]; then
    echo "クリーンアップが失敗しました" | mail -s "DBアラート" [email protected]
fi

Typerはraiseされた例外から終了コードを正しく伝播させる。ロジック内から失敗を通知するには raise typer.Exit(code=1) を使用する。シェルラッパーは追加設定なしにそれを受け取れる。

シェルオートコンプリート

ツールをデプロイしたら、オートコンプリートをインストールして、チームが正しく使えるようにしよう:

# 現在のシェルに補完機能をインストールする:
python dbmaint.py --install-completion

# または生成されたスクリプトを確認する:
python dbmaint.py --show-completion

シェル設定を読み込んだ後、python dbmaint.py cleanup --mode <TAB> でタブ補完すると dry-run execute rollback が表示される。これが、深夜2時に誤ったモードで実行するのを防ぐような「発見可能性」だ。

本格的なCLIツールとしてのパッケージング

pyproject.toml のエントリーポイントにTyperを設定して、システム全体にインストールできるようにする:

[project]
name = "dbmaint"
version = "1.0.0"
dependencies = ["typer[all]>=0.12"]

[project.scripts]
dbmaint = "dbmaint.cli:app"
pip install -e .
# これでシステムコマンドとして使用可能:
dbmaint cleanup production --mode dry-run

これにより、スクリプトを直接実行するのと同じオートコンプリートとバリデーションで、ツールがシステムコマンドとして使用可能になる。

生の argparse からTyperに移行したことで、チームの運用が変わった。不正な引数はCLI層で明確に失敗する — ビジネスロジックの中でサイレントに失敗するのではなく。--help テキストはコードと自動的に同期する。新しいチームメンバーはソースを読む代わりにオートコンプリートでオプションを発見できる。あの深夜2時の事件は、CLIの引数のタイポがプロダクション問題を引き起こした最後の出来事となった。

Share: