命令行工具(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 三种框架的选型对照
| 维度 | argparse | Click | Typer |
|---|---|---|---|
| 依赖 | 标准库 | click | click + typer |
| 类型校验 | 手写 type= | 内置类型 | 从标注推导 |
| 嵌套子命令 | 繁琐 | 简单 | 最简单 |
| 帮助生成 | 手写 | 装饰器/docstring | docstring |
| 学习成本 | 低 | 中 | 低(会类型标注即可) |
| 生态 | — | 丰富 | 复用 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 友好性这些「看不见的契约」——它们决定了工具能否被别的程序可靠地组合使用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。