PythonでCLIツールを作る3つの方法(そしてなぜ多くの人が間違った選択をするのか)
Pythonスクリプトをきちんとしたコマンドラインツールにしたいとき、繰り返し候補に上がるのが3つのアプローチです。sys.argvの手動パース、標準ライブラリのargparse、そしてClickのようなサードパーティライブラリ。私は数十の社内ツールでこの3つすべてを使ってきましたが、この選択は見た目以上に大きな影響を持ちます。
4つ目の要素——CLIを見た目よくする——はたいていの場合、完全に後回しにされます。そこで登場するのがRichです。ClickとRichを組み合わせることで、動作と表示の両方をカバーできます。チームメンバーが毎日使いたいと思うツールと、こっそりシェルエイリアスに置き換えられるツールの差は、多くの場合、進捗やエラーをどれだけわかりやすく伝えられるかにかかっています。
アプローチ比較:sys.argv vs argparse vs Click
生のsys.argv
「とにかく動かす」アプローチです。sys.argv[1:]を取得し、手動でパースしておしまい。
import sys
def main():
if len(sys.argv) < 2:
print("使い方: tool.py <ファイル名>")
sys.exit(1)
filename = sys.argv[1]
process(filename)
使い捨てスクリプトで単一の位置引数だけなら問題ありません。しかし、オプションフラグ、複数のサブコマンド、型バリデーションを追加しようとすると、たちまち辛くなります。
argparse(標準ライブラリ)
argparseは長年デファクトスタンダードでした。フラグ、位置引数、ヘルプテキスト生成、基本的な型変換を追加インストールなしで扱えます。
import argparse
parser = argparse.ArgumentParser(description="ファイルを処理する")
parser.add_argument("filename", help="入力ファイル")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
小さなツールならargparseで十分です。ただし、複数のサブコマンド、ネストしたグループ、コールバック、テストを追加していくと、ボイラープレートが積み重なりコードが読みにくくなります。
Click
ClickはPythonのデコレータを使ってCLIインターフェースを関数に直接定義します。コードは自然に読め、ツールが大きくなっても保守しやすい状態を保てます。
import click
@click.command()
@click.argument("filename")
@click.option("--verbose", is_flag=True, help="詳細出力を有効にする")
def process(filename, verbose):
"""ファイルを処理して結果を報告する。"""
if verbose:
click.echo(f"処理中: {filename}")
同じ機能でも、意図が即座に伝わります。各パラメータは宣言された場所でドキュメント化されています。テストもすっきりしています——ClickにはCliRunnerという組み込みのテストランナーが付属しています。
選択を決める前に知っておくべきメリット・デメリット
argparseのメリット・デメリット
- メリット:依存ゼロ——Pythonに同梱されている
- メリット:安定しており、ドキュメントが充実し、広く理解されている
- デメリット:サブコマンドはsubparsersによる冗長なセットアップが必要
- デメリット:テストではsys.argvをモックするかサブプロセスを起動する必要がある
- デメリット:カラーやスタイル付き出力は組み込まれていない
Clickのメリット・デメリット
- メリット:デコレータベース——インターフェースがロジックの隣に書ける
- メリット:サブコマンドは
@click.group()でファーストクラスとして扱われる - メリット:テストユーティリティ(
CliRunner)が組み込まれている - メリット:コンポーザブル——コマンドを別々のモジュールから組み立てられる
- デメリット:外部依存がある(ただし軽量で安定している)
- デメリット:一部の引数パースのエッジケースがargparseの慣習と異なる
Richのメリット・デメリット
- メリット:テーブル、プログレスバー、シンタックスハイライト、パネル——すべて組み込み
- メリット:ターミナルの自動検出(パイプ出力にカラーコードが混入しない)
- メリット:Clickとクリーンに組み合わさる——競合なし
- デメリット:依存関係に約2MBが加わる
- デメリット:一度きりで使い捨てるスクリプトにはオーバースペック
私のおすすめ:一人が時々実行する小さな社内スクリプトにはargparseを使う。チームで共有したりパッケージとして配布するものにはClick + Richを使う。httpie、black、poetryはすべてClickで作られています——偶然ではなく、これらは人々が本当に使いたいと思うツールでもあります。明確な進捗表示と読みやすいエラーメッセージこそが、信頼されるツールと避けられるツールの差を生み出します。
推奨セットアップ
依存関係のインストール
pip install click rich
プロジェクト構成
CLIレイヤーは薄く保つ。ビジネスロジックはモジュールに属するものであり、Clickデコレータの中に書くべきではありません。
mytool/
├── cli.py ← Clickコマンド
├── core.py ← 実際のロジック
├── __init__.py
pyproject.toml ← エントリポイントの設定
pyproject.tomlのエントリポイント
[project.scripts]
mytool = "mytool.cli:main"
pip install -e .後は、どのディレクトリからでもmytoolを直接実行できます——python -mのプレフィックスは不要です。
実装ガイド:実際のCLIツールを作る
ステップ1——基本的なコマンドグループ
最初からグループを使って複数のサブコマンドに対応しておく。コマンドをリリースした後からグループを追加するのは、思った以上に大変です。
import click
from rich.console import Console
console = Console()
@click.group()
@click.version_option(version="1.0.0")
def main():
"""MyTool — ファイル処理ユーティリティ。"""
pass
@main.command()
@click.argument("filepath", type=click.Path(exists=True))
@click.option("--format", type=click.Choice(["json", "csv", "table"]), default="table")
@click.option("--verbose", "-v", is_flag=True)
def analyze(filepath, format, verbose):
"""ファイルを分析して結果を表示する。"""
if verbose:
console.print(f"[dim]読み込み中: {filepath}[/dim]")
# ... ここでコアロジックを呼び出す
ステップ2——構造化出力のためのRichテーブル
普通のprint()でも動きますが、テーブルを使えば結果が一目でスキャンできるようになります。Richのテーブルはカラー、配置、行のハイライトをサポート——ボイラープレートゼロです。
from rich.table import Table
def display_results(records):
table = Table(title="分析結果", show_lines=True)
table.add_column("ファイル", style="cyan", no_wrap=True)
table.add_column("サイズ", justify="right")
table.add_column("ステータス", justify="center")
for r in records:
status_color = "green" if r["ok"] else "red"
table.add_row(
r["name"],
f"{r['size']:,} bytes",
f"[{status_color}]{r['status']}[/{status_color}]"
)
console.print(table)
ステップ3——長時間処理のためのプログレスバー
半秒以上かかる処理には視覚的なフィードバックが必要です。Richのプログレスバーはループとクリーンに統合でき、追加のスレッド処理なしで複数の同時タスクも扱えます。
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
def process_files(files):
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("{task.completed}/{task.total}"),
) as progress:
task = progress.add_task("ファイルを処理中...", total=len(files))
for f in files:
process_single(f)
progress.advance(task)
ステップ4——一貫したエラーハンドリング
ClickExceptionを発生させると、フォーマットされたエラーメッセージを表示してゼロ以外のステータスコードで終了します。Richのマークアップと組み合わせることで、エラーを視覚的に目立たせられます。
@main.command()
@click.argument("config", type=click.Path())
def run(config):
"""設定ファイルを使って実行する。"""
try:
settings = load_config(config)
except FileNotFoundError:
console.print(f"[bold red]エラー:[/bold red] 設定ファイルが見つかりません: {config}")
raise click.Abort()
except ValueError as e:
raise click.ClickException(str(e))
ステップ5——CliRunnerによるテスト
Clickの組み込みテストランナーを使えば、コマンドをプログラム的に呼び出せます。サブプロセスのオーバーヘッドもsys.argvのモックも不要——invoke()を呼び出して結果を検査するだけです。テストは高速に動作し、独立性が保たれます。
from click.testing import CliRunner
from mytool.cli import main
def test_analyze_command():
runner = CliRunner()
with runner.isolated_filesystem():
with open("sample.txt", "w") as f:
f.write("テストコンテンツ")
result = runner.invoke(main, ["analyze", "sample.txt"])
assert result.exit_code == 0
assert "Results" in result.output
大きな差を生む小さなパターン
- 生の文字列引数の代わりに
click.Path(exists=True)を使う——Clickがコードの実行前にパスを検証し、クリーンなエラーを自動表示します。 - 破壊的なコマンドには最初から
--dry-runを追加する——実行前に何が起こるかプレビューできると、ユーザーはツールをより信頼します。 - 表示には
console.print()を、パイプされる可能性のある機械可読出力にはclick.echo()を使う——RichのConsoleはstderrとstdoutを正しく分離します。 --helpのテキストは短く動詞中心に書く。「ファイルを分析する」は「このコマンドはファイルを分析するために使われます」より優れています。Clickはdocstringをそのまま表示します——ドキュメント生成ツールではなく、ユーザーのために書きましょう。
Clickを使わないほうがいいケース
CIパイプライン内で一度だけ実行され、出力が常に別のツールにパイプされるスクリプトなら、Clickのインタラクティブ性は何も加えません。--output json付きのシンプルなargparseスクリプトが正解です。Clickが真価を発揮するのは、ターミナルの向こうに人間がいるときです。
キーボードナビゲーション付きのフルスクリーンTUIが必要ですか?Textualを見てみてください——Richと同じチームが作った、まさにそのユースケースのために設計されたライブラリです。

