强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

20 - CLI 开发

第 20 章:CLI 开发

使用 Python 构建专业的命令行工具,从 argparse 到 Typer。


20.1 argparse(标准库)

20.1.1 基本用法

import argparse

def main():
    parser = argparse.ArgumentParser(description="文件处理工具")
    parser.add_argument("input", help="输入文件路径")
    parser.add_argument("-o", "--output", help="输出文件路径")
    parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
    parser.add_argument("--count", type=int, default=1, help="处理次数")

    args = parser.parse_args()

    if args.verbose:
        print(f"处理文件: {args.input}")
        print(f"次数: {args.count}")

if __name__ == "__main__":
    main()
$ python cli.py data.txt -o result.txt -v --count 3
$ python cli.py --help

20.1.2 子命令

import argparse

def main():
    parser = argparse.ArgumentParser(prog="tool")
    subparsers = parser.add_subparsers(dest="command", help="子命令")

    # create 子命令
    create_parser = subparsers.add_parser("create", help="创建资源")
    create_parser.add_argument("name", help="资源名称")
    create_parser.add_argument("--type", default="default", help="资源类型")

    # list 子命令
    list_parser = subparsers.add_parser("list", help="列出资源")
    list_parser.add_argument("--all", action="store_true", help="显示全部")

    args = parser.parse_args()

    if args.command == "create":
        print(f"创建 {args.type}: {args.name}")
    elif args.command == "list":
        print(f"列出资源 (all={args.all})")

if __name__ == "__main__":
    main()

20.2 Click

20.2.1 基本用法

import click

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """文件处理工具。"""
    pass

@cli.command()
@click.argument("input", type=click.Path(exists=True))
@click.option("-o", "--output", type=click.Path(), help="输出文件")
@click.option("-v", "--verbose", is_flag=True, help="详细输出")
def process(input, output, verbose):
    """处理文件。"""
    if verbose:
        click.echo(f"处理: {input}")
    with open(input) as f:
        data = f.read()
    click.echo(f"读取 {len(data)} 字符")

@cli.command()
@click.argument("names", nargs=-1)
def greet(names):
    """问候用户。"""
    for name in names:
        click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    cli()

20.2.2 Click 高级特性

import click

@click.command()
@click.option("--name", prompt="Your name", help="你的名字")
@click.option("--count", default=1, type=click.IntRange(1, 10), help="次数")
@click.option("--color", type=click.Choice(["red", "green", "blue"]), default="red")
@click.password_option()
def hello(name, count, color, password):
    """带交互的 CLI。"""
    for _ in range(count):
        click.secho(f"Hello, {name}!", fg=color)

# 进度条
@click.command()
def process():
    """带进度条的处理。"""
    items = range(100)
    with click.progressbar(items, label="处理中") as bar:
        for item in bar:
            pass  # 处理每个项目

if __name__ == "__main__":
    hello()

20.3 Typer(推荐)

20.3.1 基本用法

import typer
from typing import Optional

app = typer.Typer(help="文件处理工具")

@app.command()
def process(
    input: str = typer.Argument(..., help="输入文件"),
    output: Optional[str] = typer.Option(None, "-o", "--output", help="输出文件"),
    verbose: bool = typer.Option(False, "-v", "--verbose", help="详细输出"),
    count: int = typer.Option(1, "--count", min=1, max=10, help="处理次数"),
):
    """处理文件。"""
    if verbose:
        typer.echo(f"处理: {input}")

    for i in range(count):
        typer.echo(f"第 {i + 1} 次处理完成")

if __name__ == "__main__":
    app()
$ python cli.py process data.txt -v --count 3
$ python cli.py --help
$ python cli.py process --help

20.3.2 子命令

import typer

app = typer.Typer()
users_app = typer.Typer()
app.add_typer(users_app, name="users")

@users_app.command("create")
def users_create(name: str, email: str = typer.Option(...)):
    """创建用户。"""
    typer.echo(f"创建用户: {name} ({email})")

@users_app.command("list")
def users_list(all: bool = typer.Option(False, "--all")):
    """列出用户。"""
    typer.echo(f"列出用户 (all={all})")

@app.command()
def version():
    """显示版本。"""
    typer.echo("v1.0.0")

if __name__ == "__main__":
    app()

20.3.3 Typer 美化输出

import typer

app = typer.Typer()

@app.command()
def demo():
    # 颜色输出
    typer.secho("成功!", fg=typer.colors.GREEN, bold=True)
    typer.secho("警告!", fg=typer.colors.YELLOW)
    typer.secho("错误!", fg=typer.colors.RED)

    # 确认
    if typer.confirm("是否继续?"):
        typer.echo("继续执行")
    else:
        raise typer.Abort()

    # 选择
    options = ["Python", "Go", "Rust"]
    choice = typer.prompt("选择语言", type=typer.Choice(options))
    typer.echo(f"选择了: {choice}")

    # 启动/失败指示器
    with typer.progressbar(range(100)) as progress:
        for value in progress:
            pass
    typer.echo("完成!")

if __name__ == "__main__":
    app()

20.4 Rich(终端美化)

20.4.1 基本输出

from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import track
import time

console = Console()

# 彩色输出
console.print("[bold red]错误[/bold red]: 文件不存在")
console.print("[green]成功[/green]: 操作完成")

# 表格
table = Table(title="用户列表")
table.add_column("ID", style="cyan")
table.add_column("姓名", style="magenta")
table.add_column("城市", style="green")
table.add_row("1", "Alice", "北京")
table.add_row("2", "Bob", "上海")
console.print(table)

# 面板
console.print(Panel("Hello, World!", title="欢迎", border_style="blue"))

# 进度条
for step in track(range(100), description="处理中..."):
    time.sleep(0.02)

20.4.2 Rich + Typer

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

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

@app.command()
def list_users():
    """列出用户。"""
    table = Table(title="用户列表")
    table.add_column("ID", style="cyan")
    table.add_column("姓名", style="bold")
    table.add_column("状态", style="green")

    users = [("1", "Alice", "活跃"), ("2", "Bob", "离线")]
    for user in users:
        table.add_row(*user)

    console.print(table)

if __name__ == "__main__":
    app()

20.5 交互式输入

import typer
from InquirerPy import inquirer

app = typer.Typer()

@app.command()
def setup():
    """交互式配置。"""
    name = inquirer.text(message="项目名称:").execute()
    framework = inquirer.select(
        message="选择框架:",
        choices=["FastAPI", "Flask", "Django"],
    ).execute()
    features = inquirer.checkbox(
        message="选择特性:",
        choices=["数据库", "认证", "缓存", "日志"],
    ).execute()

    typer.echo(f"\n配置: {name}, {framework}, {features}")

if __name__ == "__main__":
    app()

20.6 框架选型

框架学习曲线类型注解自动帮助推荐场景
argparse手动简单脚本
Click复杂 CLI
Typer推荐首选
Fire自动快速原型

20.7 注意事项

🔴 注意

  • CLI 工具要有清晰的帮助文档
  • 参数要有合理的默认值
  • 失败时返回非零退出码
  • 输出要适合管道处理(stdout 只输出数据)

💡 提示

  • Typer 基于 Click 构建,支持类型注解
  • Rich 可以美化任何终端输出
  • 使用 typer.Exit(1) 返回错误码
  • 使用 click.get_text_stream() 处理管道输入

📌 业务场景

import typer
from rich.console import Console
from pathlib import Path

app = typer.Typer(help="日志分析工具")
console = Console()

@app.command()
def analyze(
    logfile: Path = typer.Argument(..., help="日志文件路径", exists=True),
    level: str = typer.Option("ERROR", "--level", "-l", help="最低日志级别"),
    output: Path = typer.Option(None, "--output", "-o", help="输出文件"),
    top: int = typer.Option(10, "--top", "-n", help="显示前 N 条"),
):
    """分析日志文件。"""
    levels = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
    min_level = levels.get(level, 3)

    errors = []
    with open(logfile) as f:
        for line in f:
            for lvl, priority in levels.items():
                if lvl in line and priority >= min_level:
                    errors.append(line.strip())
                    break

    console.print(f"[bold]找到 {len(errors)}{level}+ 日志[/bold]")
    for err in errors[:top]:
        if "ERROR" in err:
            console.print(f"  [red]{err}[/red]")
        elif "WARNING" in err:
            console.print(f"  [yellow]{err}[/yellow]")
        else:
            console.print(f"  {err}")

if __name__ == "__main__":
    app()

20.8 扩展阅读