プロジェクトが大きくなるにつれてargparseが重荷になる理由
最初の自動化プロジェクトはわずか200行で、argparseを使ったいくつかの基本コマンドがメインだった。しかし数ヶ月後、コードが2000行近くに膨れ上がると、CLI管理だけで約300行を占めるようになった——add_argument、type=int、help=...の繰り返しばかりだ。新しいコマンドを追加するたびに正しい場所を探し回り、既存のコマンドを壊さないか不安になる。
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し、動作してパターンに慣れてから、少しずつ移行していく。一度に全部リファクタリングしようとしてはいけない。

