Typer PythonでモダンなCLIを構築する:Type Hints、Auto-completion、自動バリデーション

Python tutorial - IT technology blog
Python tutorial - IT technology blog

プロジェクトが大きくなるにつれてargparseが重荷になる理由

最初の自動化プロジェクトはわずか200行で、argparseを使ったいくつかの基本コマンドがメインだった。しかし数ヶ月後、コードが2000行近くに膨れ上がると、CLI管理だけで約300行を占めるようになった——add_argumenttype=inthelp=...の繰り返しばかりだ。新しいコマンドを追加するたびに正しい場所を探し回り、既存のコマンドを壊さないか不安になる。

argparse自体は間違っていないが、type hintsが存在しない時代のPythonから生まれたものだ。clickはより優れているが、各パラメータにデコレータを個別に宣言する必要がある。そこでTyperが登場し、CLIの書き方を根本から変えた。

Typerとは何か、argparse/clickとの違い

TyperはClickをベースに構築されたCLIライブラリだが、デコレータでパラメータを宣言する代わりに、Python関数のtype hintsを直接読み取る。つまり:

  • type hintsを使った通常のPython関数を書くだけでよい
  • TyperがCLIインターフェース、データバリデーション、ヘルプテキストを自動生成する
  • Auto-completionはコマンド一つだけでout-of-the-boxで動作する
  • コードが短く、読みやすく、最初からtype-safeになる

簡単に言えば:type hintsはコードのドキュメントであると同時に、TyperがCLIを構築するための「スキーマ」でもある——二か所に二回宣言する必要がない。

インストールと最初のコマンド

rich outputとauto-completionを含む全機能でTyperをインストールする:

pip install "typer[all]"

main.pyファイルを作成して最初のコマンドを書いてみよう:

import typer

app = typer.Typer()

@app.command()
def greet(name: str, count: int = 1):
    """ユーザーに挨拶する。"""
    for _ in range(count):
        typer.echo(f"こんにちは、{name}!")

if __name__ == "__main__":
    app()

さっそく実行してみよう:

python main.py --help
python main.py World
python main.py "山田太郎" --count 3

--helpの出力はtype hintsから自動生成される——nameはデフォルト値がないためrequired引数、countはデフォルト1のオプション引数だ。追加で何も書く必要はない。

Type Hintsによる自動バリデーション

これが最大の強みだ。count: intと宣言すれば、ユーザーが誤った値を入力したときにTyperが自動でバリデーションし、明確なエラーを返す:

python main.py World --count abc
# Error: Invalid value for '--count': 'abc' is not a valid integer.

Pathのようなより複雑なデータ型も使用できる:

from pathlib import Path
from typing import Optional
import typer

app = typer.Typer()

@app.command()
def process_file(
    input_file: Path = typer.Argument(..., help="処理する入力ファイル"),
    output_dir: Path = typer.Option(Path("."), help="結果を保存するディレクトリ"),
    verbose: bool = False,
    max_lines: Optional[int] = None,
):
    if not input_file.exists():
        typer.echo(f"エラー:ファイル {input_file} が存在しません", err=True)
        raise typer.Exit(1)

    typer.echo(f"処理中:{input_file} → {output_dir}")
    if verbose:
        typer.echo(f"上限:{max_lines or '制限なし'} 行")

Path型は有効なパスを自動でバリデーションする。Optional[int]はintまたはNoneを受け付ける。typer.Argument(...)...はrequiredを意味する——デフォルト値がない。

Enum — 有効な値のセットを制限する

if format not in ["json", "csv"]で手動バリデーションする代わりに、Enumを使ってTyperに任せてしまおう:

from enum import Enum
import typer

app = typer.Typer()

class OutputFormat(str, Enum):
    json = "json"
    csv = "csv"
    text = "text"

@app.command()
def export(
    data: str,
    format: OutputFormat = OutputFormat.json,
):
    typer.echo(f"データをエクスポート:形式 {format.value}")
python main.py export "data" --format csv
python main.py export "data" --format xml
# Error: Invalid value for '--format': 'xml' is not one of 'json', 'csv', 'text'.

ボーナス:ターミナルでTabキーを押したとき、auto-completionもこの3つの値を正確に候補表示する。

Subcommands — コマンドをグループで整理する

プロジェクトが大きくなると、単一のコマンドでは不十分になる。Typerはadd_typer()でネストされたサブコマンドをすっきりサポートする:

import typer
from pathlib import Path

app = typer.Typer()
db_app = typer.Typer()
user_app = typer.Typer()

app.add_typer(db_app, name="db", help="データベースを管理する")
app.add_typer(user_app, name="user", help="ユーザーを管理する")

@db_app.command("migrate")
def db_migrate(version: str = "latest"):
    typer.echo(f"バージョン {version} へのマイグレーションを実行中")

@db_app.command("backup")
def db_backup(output: Path = Path("backup.sql")):
    typer.echo(f"データベースをバックアップ → {output}")

@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
    role = "admin" if admin else "user"
    typer.echo(f"{role}を作成:{username} ({email})")

if __name__ == "__main__":
    app()

コマンド構造が明確になり、拡張しやすくなる:

python main.py db migrate --version 1.5
python main.py db backup --output /tmp/backup.sql
python main.py user create john [email protected] --admin
python main.py --help  # すべてのコマンドグループを表示

コマンド一つでAuto-completionを有効化する

これが私が最も気に入っている機能だ——argparseにはなく、clickでは複雑な設定が必要なものだ:

# Bash
python main.py --install-completion bash

# Zsh
python main.py --install-completion zsh

# Fish
python main.py --install-completion fish

# 有効化するためにシェルをリロード
source ~/.bashrc

その後、コマンドを入力しながらTabキーを押せば——Typerがサブコマンド、オプション、Enumの値を自動で候補表示する。多くのコマンドを持つツールでは、この機能がユーザーの時間を大幅に節約してくれる。

Rich Output — ターミナルでの表とカラー表示

Typerにはrichがバンドルされているため、追加インストールなしで表、プログレスバー、カラー表示が使える:

from rich.console import Console
from rich.table import Table
import typer

console = Console()
app = typer.Typer()

@app.command()
def list_tasks():
    """タスク一覧を表示する。"""
    table = Table(title="タスク一覧")
    table.add_column("ID", style="cyan", width=6)
    table.add_column("名前", style="white")
    table.add_column("ステータス", style="green")

    table.add_row("1", "Backup database", "✓ 完了")
    table.add_row("2", "Deploy staging", "⟳ 実行中")
    table.add_row("3", "Send report", "✗ エラー")

    console.print(table)

プロジェクトが大きくなったときのコード整理

2000行のプロジェクトから得た痛い教訓:すべてのコマンドを一つのファイルに詰め込まないこと。私が使っているパターンは、コマンドグループごとに独立したモジュールに分割することだ:

mycli/
├── __init__.py      # メインapp + add_typer()
├── commands/
│   ├── __init__.py
│   ├── db.py        # db_app = typer.Typer()
│   ├── user.py      # user_app = typer.Typer()
│   └── report.py    # report_app = typer.Typer()
main.py              # entry point

mycli/__init__.pyファイル:

import typer
from mycli.commands.db import db_app
from mycli.commands.user import user_app
from mycli.commands.report import report_app

app = typer.Typer(name="mycli", help="プロジェクト管理ツール")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
app.add_typer(report_app, name="report")

各ファイルは独立したtyper.Typer()インスタンスをエクスポートする。新しいコマンドグループを追加するには、新しいファイルを作成して__init__.pyに2行追加するだけ——既存のコードには触れない。

argparseを使っていたころと比べると、Typerに移行後、CLIの部分が約300行から約150行に減り、以前は見落としがちだった数十の小さなバリデーションバグも同時に排除できた。

まとめ

Typerはマジックではない——あなたがすでに書き慣れているもの(function + type hints)を活用して、バリデーション、ヘルプテキスト、auto-completion、rich outputを備えた完全なCLIを生成するだけだ。argparseを使っていてCLIコードが散らかってきたと感じているなら、試してみる絶好の機会だ。

実践的なアプローチ:まず小さなコマンド一つをconvertし、動作してパターンに慣れてから、少しずつ移行していく。一度に全部リファクタリングしようとしてはいけない。

Share: