Python 命令行工具:argparse、Click 与 Typer

Python 命令行工具开发全解:argparse 子命令与参数校验、Click 装饰器与上下文、Typer 基于类型提示的现代写法、rich 终端输出与进度条、shell 补全、console_scripts 打包分发与 CliRunner 测试。

命令行工具(CLI,Command-Line Interface)是运维、数据管道与开发工具最常见的交付形态。它的用户界面只有三个通道:参数、标准输出、退出码——正因为约束极窄,做得好的工具和做得差的工具差距反而更明显。

Python 写 CLI 有三条主流路线:标准库 argparse(零依赖)、Click(装饰器 + 组合式)、Typer(基于类型提示)。它们不是互相替代关系,而是不同复杂度下的取舍。本文按「先定设计规范,再选框架,最后解决打包、补全、测试」的顺序,把一条完整的 CLI 工程链路讲完。

1. CLI 设计规范

1.1 三个通道的契约

通道用途反例
参数 / 环境变量 / 配置文件输入用交互式 prompt 代替参数
stdout正常结果(可被管道消费)把日志混进 stdout
stderr日志、进度、错误把结果写进 stderr
退出码成败信号出错仍返回 0

最容易踩的坑是把日志和结果都写进 stdout。一旦用户执行 mytool list | jq,混进去的日志会直接破坏下游解析。正确做法是:结果走 stdout,一切诊断信息走 stderr。

import sys

def log(msg: str) -> None:
    print(msg, file=sys.stderr)

def emit(data: str) -> None:
    print(data)  # 只有这个能被管道消费

1.2 退出码约定

退出码含义场景
0成功正常结束
1通用错误未分类的业务失败
2用法错误参数不合法(argparse 默认)
126不可执行权限问题
130被 Ctrl-C 中断KeyboardInterrupt
def main() -> int:
    try:
        run()
    except KeyboardInterrupt:
        log("interrupted")
        return 130
    except UsageError as e:
        log(f"error: {e}")
        return 2
    return 0

if __name__ == "__main__":
    sys.exit(main())

用 sys.exit(main()) 而不是裸 main(),退出码才能被 shell 与 CI 正确读取。set -e 的脚本、&& 链、CI 的步骤判定都依赖退出码。

1.3 配置优先级

成熟的 CLI 遵循「命令行 > 环境变量 > 项目配置 > 用户配置 > 内置默认」的覆盖顺序:

import os
from dataclasses import dataclass

@dataclass
class Config:
    endpoint: str = "http://localhost:8000"
    timeout: float = 30.0
    token: str | None = None

    @classmethod
    def load(cls, cli_args, config_file: dict | None = None) -> "Config":
        cfg = cls()
        cfg.endpoint = os.environ.get("MYTOOL_ENDPOINT", cfg.endpoint)
        if config_file:
            cfg.endpoint = config_file.get("endpoint", cfg.endpoint)
        if cli_args.endpoint:
            cfg.endpoint = cli_args.endpoint   # 最高优先级
        return cfg

把优先级写进文档并在 --help 里说明,能省掉大量「为什么我改了环境变量不生效」的提问。环境变量统一加工具名前缀(MYTOOL_*),避免与系统变量冲突。

2. argparse:标准库方案

2.1 基础结构

import argparse

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mytool",
        description="示例命令行工具",
        epilog="更多文档见 https://example.com",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument("path", help="输入路径")
    p.add_argument("-o", "--output", default="-", help="输出文件,- 表示 stdout")
    p.add_argument("-v", "--verbose", action="count", default=0, help="可叠加的详细度")
    p.add_argument("--timeout", type=float, default=30.0, help="超时秒数")
    return p

ArgumentDefaultsHelpFormatter 会把默认值自动附在帮助文本后,省去手写 (default: xxx)。

2.2 参数类型与校验

def positive_int(value: str) -> int:
    ivalue = int(value)
    if ivalue <= 0:
        raise argparse.ArgumentTypeError(f"{value} 必须是正整数")
    return ivalue

p.add_argument("--workers", type=positive_int, default=4)
p.add_argument("--mode", choices=["fast", "safe", "dry-run"], default="safe")
p.add_argument("--include", action="append", default=[], help="可重复指定")
p.add_argument("--no-color", action="store_true")
p.add_argument("--tags", nargs="+", default=[], help="一次接收多个值")
写法语义
type=callable解析并校验,抛 ArgumentTypeError 报错
choices=[...]枚举约束,非法值直接报用法错误
action="append"每次出现追加一个值
action="count"计数,用于 -vvv
action="store_true"布尔开关
nargs="+"消费一个或多个值
required=True强制必填(可选参数慎用)

2.3 子命令(subparsers)

def main(argv=None) -> int:
    parser = build_parser()
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="新增条目")
    add.add_argument("name")
    add.set_defaults(func=cmd_add)

    rm = sub.add_parser("remove", help="删除条目")
    rm.add_argument("name")
    rm.add_argument("-f", "--force", action="store_true")
    rm.set_defaults(func=cmd_remove)

    args = parser.parse_args(argv)
    return args.func(args)

set_defaults(func=...) 把子命令与处理函数绑定,main 只负责分发,是 argparse 里最干净的组织方式。子命令超过 5 个时,建议拆成 cli/ 包,每个子命令一个模块。

2.4 argparse 的局限

  • 帮助文本需要手写,无法从类型推导
  • 嵌套子命令(git remote add)需要手工层层构造
  • 没有内置的进度条、颜色、交互确认
  • 参数与业务逻辑容易耦合在同一个函数里

一旦出现上述痛点,就是换 Click 或 Typer 的信号。

3. Click:装饰器与组合

3.1 最小示例

import click

@click.group()
@click.option("--verbose", "-v", count=True, help="详细度")
@click.version_option()
@click.pass_context
def cli(ctx: click.Context, verbose: int) -> None:
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose

@cli.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--output", "-o", type=click.File("w"), default="-")
@click.pass_obj
def convert(obj: dict, path: str, output) -> None:
    """把 PATH 转换为目标格式。"""
    if obj["verbose"]:
        click.echo(f"reading {path}", err=True)
    output.write("...")

if __name__ == "__main__":
    cli()

@click.group() + @cli.command() 天然支持多层子命令;ctx.obj 是在命令间传递共享状态(配置、连接、日志级别)的标准位置。

3.2 类型系统

Click 内置了丰富的参数类型,校验与转换一步到位:

类型说明
click.Path(exists=True, dir_okay=False)校验路径存在性与类型
click.File("w")打开文件,- 自动映射 stdin/stdout
click.Choice(["a","b"])枚举
click.IntRange(1, 100)数值范围
click.DateTime(formats=[...])时间解析
click.Tuple([str, int])定长多值
@cli.command()
@click.option("--date", type=click.DateTime(["%Y-%m-%d"]), required=True)
@click.option("--level", type=click.IntRange(1, 9), default=5)
def report(date, level) -> None:
    ...

click.Path 与 click.File 的价值在于:把「文件是否存在」「能否写入」这类校验前移到参数解析阶段,命令体里就不用再写防御性检查,也保证错误信息统一由框架输出。

3.3 交互、确认与进度

@cli.command()
@click.confirmation_option(prompt="确定要删除全部数据吗?")
def purge() -> None:
    ...

@cli.command()
def upload() -> None:
    name = click.prompt("项目名", type=str)
    password = click.prompt("密码", hide_input=True, confirmation_prompt=True)
    with click.progressbar(range(100), label="上传中") as bar:
        for i in bar:
            ...

hide_input=True 用于密码输入,confirmation_option 用于破坏性操作的二次确认。所有交互都必须能用 --yes 之类的开关跳过,否则工具无法在 CI 中无人值守运行。

3.4 测试:CliRunner

from click.testing import CliRunner
from mytool.cli import cli

def test_convert(tmp_path):
    src = tmp_path / "in.txt"
    src.write_text("hello")
    runner = CliRunner()
    result = runner.invoke(cli, ["convert", str(src), "-o", "-"])
    assert result.exit_code == 0
    assert "hello" in result.output

def test_missing_path():
    result = CliRunner().invoke(cli, ["convert", "/nope"])
    assert result.exit_code == 2
    assert "does not exist" in result.output

CliRunner 在进程内调用命令、捕获输出、隔离环境变量与工作目录,是 Click 最被低估的特性。测试组织方式与 Python 测试与质量工程 中讨论的 fixture 策略一致——CLI 层测「参数到行为」的映射,业务逻辑仍放在可独立单测的纯函数里。

4. Typer:类型提示驱动的现代写法

4.1 从函数签名生成 CLI

import typer
from typing import Annotated, Optional
from pathlib import Path

app = typer.Typer(help="示例工具", no_args_is_help=True)

@app.command()
def convert(
    path: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
    output: Annotated[Optional[Path], typer.Option("--output", "-o")] = None,
    workers: Annotated[int, typer.Option(min=1, max=64)] = 4,
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
) -> None:
    """把 PATH 转换为目标格式。"""
    if verbose:
        typer.echo(f"workers={workers}", err=True)

if __name__ == "__main__":
    app()

Typer 本质是 Click 的上层封装:参数类型来自标注,选项名从参数名推导(下划线转连字符),帮助文本来自 docstring。用 Annotated 是当前推荐写法,比旧式的 typer.Option(...) 默认值风格更清晰,也让函数能在非 CLI 场景下被直接调用。

4.2 子命令与状态

@app.command()
def add(name: str, force: bool = False) -> None:
    ...

@app.command()
def remove(name: str, force: bool = False) -> None:
    ...

# 或者把子命令拆到独立模块再挂载
app.add_typer(user_app, name="user", help="用户管理")

Typer 支持把子应用(typer.Typer() 实例)挂到主应用上,天然形成 mytool user add 这种两级结构,比手工嵌套 argparse 子解析器省事得多。

4.3 三种框架的选型对照

维度argparseClickTyper
依赖标准库clickclick + typer
类型校验手写 type=内置类型从标注推导
嵌套子命令繁琐简单最简单
帮助生成手写装饰器/docstringdocstring
学习成本低中低(会类型标注即可)
生态—丰富复用 Click 生态
适用规模单命令小工具多子命令工具类型标注重度用户

选择建议:只有一两个参数的一次性脚本用 argparse;团队工具、多子命令、需要 Click 生态(如 click-plugins)用 Click;已有完整类型标注的现代代码库用 Typer。三者可以共存——底层核心逻辑写成普通函数,CLI 层只是薄薄一层壳,将来换框架不伤筋骨。

5. 输出、补全与用户体验

5.1 富文本输出

标准库的 print 无法处理颜色、表格、进度条。rich 是目前的事实标准:

from rich.console import Console
from rich.table import Table
from rich.progress import track

console = Console(stderr=True)   # 诊断信息走 stderr

table = Table(title="构建结果")
table.add_column("模块")
table.add_column("状态", justify="right")
table.add_row("core", "[green]ok[/green]")
table.add_row("cli", "[red]fail[/red]")
console.print(table)

for _ in track(range(50), description="处理中..."):
    ...

关键实践:富文本输出必须能关闭。当 NO_COLOR 环境变量存在或输出不是 TTY 时,应自动退化为纯文本,否则重定向到文件后会得到一堆 ANSI 转义码。

import os, sys

def use_color() -> bool:
    return sys.stdout.isatty() and "NO_COLOR" not in os.environ

5.2 shell 补全

Click 内置补全支持,无需额外代码:

# Bash
_mytool_completion() { eval "$(_MYTOOL_COMPLETE=bash_complete mytool)"; }
complete -F _mytool_completion mytool

# Zsh
eval "$(_MYTOOL_COMPLETE=zsh_source mytool)"

# Fish
_MYTOOL_COMPLETE=fish_source mytool | source

把补全安装脚本写进 README,或提供 mytool --install-completion 子命令(Typer 自带)。补全能显著降低「记不住子命令名」的摩擦,是区分业余与专业工具的标志之一。

5.3 帮助文本与文档

mytool --help
mytool convert --help
mytool --version

三件事必须做到:每个命令有 --help、有 --version(且版本号来自包元数据而非硬编码)、每个参数有说明。版本号硬编码是常见错误,正确做法是:

from importlib.metadata import version

@click.version_option(version=version("mytool"))
def cli() -> None: ...

5.4 与 Unix 工具协作

# 从 stdin 读取,支持管道
cat data.json | mytool convert --input - --output -

# 输出 JSON 供 jq 消费
mytool list --format json | jq '.[] | select(.active)'

支持 - 作为 stdin/stdout 的约定、提供 --format json 结构化输出,能让工具无缝融入 shell 管道。这也是 Shell 脚本与自动化 中最常见的组合方式:用 Python 处理复杂逻辑,用 shell 做编排。

6. 打包、分发与部署

6.1 console_scripts 入口

[project]
name = "mytool"
version = "0.4.0"
dependencies = ["click>=8.1", "rich>=13.7"]

[project.scripts]
mytool = "mytool.cli:cli"

安装后即生成 mytool 可执行文件。用户侧有三种安装方式:

# 隔离安装(推荐给终端用户,不污染全局环境)
uv tool install mytool
pipx install mytool

# 项目依赖方式
uv add mytool

# 开发模式
uv pip install -e ".[dev]"

uv tool install 与 pipx 会把工具装进独立虚拟环境并把入口软链到 ~/.local/bin,是分发 CLI 的最佳实践——避免与项目依赖冲突。这套工具链的细节可参考 Python 现代工具链 。

6.2 单文件分发

需要交付给没有 Python 环境的用户时,用 PyInstaller 打包:

pyinstaller --onefile --name mytool --strip \
    --hidden-import click \
    src/mytool/__main__.py
# src/mytool/__main__.py
from mytool.cli import cli

if __name__ == "__main__":
    cli()

注意 PyInstaller 的常见坑:动态导入的模块需要 --hidden-import 显式声明;打包体积通常 10~30MB;不同平台必须各自构建。若目标是跨平台且体积敏感,可考虑用 Go/Rust 重写核心,这与 用 Cobra 构建 Go CLI 中讨论的方案是同一类权衡。

6.3 容器化分发

FROM python:3.12-slim AS build
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

FROM python:3.12-slim
COPY --from=build /app/.venv /app/.venv
ENV PATH=/app/.venv/bin:$PATH
ENTRYPOINT ["mytool"]

容器分发的优势是环境完全可控、版本可回滚;代价是镜像体积与启动开销。对需要调用系统工具的 CLI,容器化能省掉大量「你机器上装了 ffmpeg 吗」的沟通成本。

7. 工程实践清单

7.1 设计检查表

  • 结果走 stdout,日志走 stderr
  • 所有交互都可用开关跳过(CI 友好)
  • 破坏性操作有二次确认或 --yes
  • 退出码语义明确
  • 有 --help、--version、--verbose
  • 支持 NO_COLOR 与非 TTY 降级
  • 配置优先级在文档中写明
  • 版本号来自包元数据
  • 提供 shell 补全安装方式
  • 有 --dry-run 预览将要执行的操作

7.2 测试策略

层次测什么工具
参数解析非法参数、默认值、优先级CliRunner / capsys
命令行为输入到输出的映射临时目录 + 真实调用
退出码各类失败路径断言 result.exit_code
端到端子进程真实执行subprocess.run

端到端测试能抓住进程内测试抓不到的问题(如入口点未注册、shebang 错误):

import subprocess, sys

def test_e2e_version():
    r = subprocess.run([sys.executable, "-m", "mytool", "--version"],
                       capture_output=True, text=True)
    assert r.returncode == 0
    assert "0.4.0" in r.stdout

7.3 常见反模式

反模式后果替代
把业务逻辑写进命令函数无法复用、难测试抽成纯函数,命令层只做参数转换
用 sys.argv 手工解析易错、无帮助文本交给框架
错误信息只写日志不返回非零码CI 无法感知失败sys.exit(1)
硬编码版本号与包版本漂移importlib.metadata.version
交互式输入无开关无法自动化加 --yes / --input
输出混用 stdout 与 stderr管道解析失败严格分流

小结

Python CLI 的工程化路径清晰:先用 argparse 明确「参数、stdout、退出码」三通道契约,规模上来后迁移到 Click 或 Typer,再补齐富文本输出、shell 补全与结构化输出,最后通过 console_scripts + uv tool install 分发。真正拉开差距的不是框架选择,而是退出码、stderr/stdout 分流、CI 友好性这些「看不见的契约」——它们决定了工具能否被别的程序可靠地组合使用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. Python 桌面 GUI 应用开发
  2. Python 正则与文本处理进阶
  3. Python GraphQL API:Strawberry 与 Schema 设计