CLI 命令行与 Python 框架实战
第 31 期 · 建议学习 4-6 小时
Python 3.10+ · 可复制运行 · 工程化实践
把 Python 脚本做成可靠、可发现、可测试的命令行工具。本章覆盖命令设计、参数解析、帮助文档,以及 argparse、Click、Typer 三套实现方式。
目录
- [CLI 是什么,何时该用](#CLI 是什么,何时该用)
- [完整 CLI 的使用样式](#完整 CLI 的使用样式)
- [Python CLI 框架选型](#Python CLI 框架选型)
- 标准库方案:argparse
- 工程常用:Click
- [新项目推荐:Typer + Rich](#新项目推荐:Typer + Rich)
- [从脚本到可安装 CLI](#从脚本到可安装 CLI)
- 质量、错误与发布
- 本期作业
1. CLI 是什么,何时该用
CLI(Command Line Interface)以命令、参数和标准输入输出驱动程序。它适合批处理、自动化、开发者工具、运维任务和 CI/CD。
| 价值 | 说明 |
|---|---|
| 可重复 | 同一命令与同一参数,可在本地、服务器和 CI 中重复执行。 |
| 可组合 | 输出可写入文件、管道,或交给另一个程序。 |
| 可自动化 | 定时任务、Shell 脚本与部署流水线都能稳定调用。 |
判断方法:如果用户要反复执行同一个动作,或该动作要被脚本、流水线、其他开发者调用,就应该定义一个清晰的 CLI 契约。一次性探索脚本不必过早包装。
2. 完整 CLI 的使用样式
bash
acme report sales.csv --format json --output result.json --verbose
# 程序名 子命令 必填输入 可选配置 行为开关
| 部分 | 示例 | 作用 |
|---|---|---|
| 程序名 | acme | 工具的安装入口。 |
| 子命令 | report | 要执行的动作。 |
| 位置参数 | sales.csv | 核心、必填的输入。 |
| 选项 | --format json | 有默认值或改变行为的配置。 |
| 标志 | --verbose | 启用或关闭某项行为。 |
参数设计规则
- 动作放在子命令:create、list、export。
- 位置参数只保留真正必需的输入:tool convert input.csv。
- 有默认值或改变行为的值用选项:--output、--limit。
- 布尔开关使用 --verbose、--dry-run,不要写 --verbose true。
- 命令与选项使用小写连字符:create-user、--dry-run。
- 危险操作先展示影响范围,要求确认或显式使用 --yes。
- 面向脚本时提供 --format json,标准输出只输出 JSON。
- 稳定支持 --help、--version 和退出码。
3. Python CLI 框架选型
先按复杂度选择。标准库适合小工具,Click 适合成熟多命令工具,Typer 适合新项目与类型注解驱动的接口。
| 方案 | 适用场景 | 优点 | 取舍 | 建议 |
|---|---|---|---|---|
| argparse | 小工具、无依赖场景 | Python 自带,理解 CLI 基础 | 复杂子命令稍冗长 | 基础必学 |
| Click | 成熟 CLI、多子命令 | 装饰器清晰,生态成熟 | 类型意图不如 Typer 直接 | 生产常用 |
| Typer | 新项目、类型注解 | 自动参数校验与帮助,基于 Click | 仍需理解命令接口设计 | 优先推荐 |
| Rich | 表格、进度条、日志 | 人类输出更易读 | 不是参数解析器 | 配合使用 |
| questionary | 交互式初始化向导 | 选择与确认体验好 | CI 需提供无交互参数 | 按需加入 |
4. 标准库方案:argparse
argparse 解析参数、生成 --help、校验枚举值,且不需要安装依赖。这是学习命令树的最佳入口。
文件:todo_argparse.py
python
from __future__ import annotations
import argparse
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="todo",
description="一个最小待办事项 CLI",
)
commands = parser.add_subparsers(dest="command", required=True)
add = commands.add_parser("add", help="新增一条待办")
add.add_argument("title", help="待办标题")
add.add_argument(
"--priority",
choices=["low", "normal", "high"],
default="normal",
)
add.add_argument("--file", type=Path, default=Path("todo.txt"))
listing = commands.add_parser("list", help="列出待办")
listing.add_argument("--file", type=Path, default=Path("todo.txt"))
return parser
def main() -> int:
args = build_parser().parse_args()
if args.command == "add":
with args.file.open("a", encoding="utf-8") as f:
f.write(f"[{args.priority}] {args.title}\n")
print(f"已写入:{args.title}")
elif args.command == "list":
print(args.file.read_text(encoding="utf-8") if args.file.exists() else "暂无待办")
return 0
if __name__ == "__main__":
raise SystemExit(main())
使用方法:
bash
python todo_argparse.py --help
python todo_argparse.py add "学习 CLI" --priority high
python todo_argparse.py list
python todo_argparse.py add "准备分享" --file work.txt
5. 工程常用:Click
Click 用装饰器声明命令。命令组、子命令、选项和上下文边界清晰,适合有多个功能的工具。
安装:
bash
python -m pip install click
文件:user_cli.py
python
import json
from pathlib import Path
import click
DATA_FILE = Path("users.json")
def load_users() -> list[dict]:
return json.loads(DATA_FILE.read_text()) if DATA_FILE.exists() else []
@click.group()
@click.option("--verbose", is_flag=True, help="输出额外执行信息")
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
"""用户管理工具。"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
@cli.command()
@click.argument("name")
@click.option(
"--role",
type=click.Choice(["viewer", "editor", "admin"]),
default="viewer",
)
@click.pass_context
def add(ctx: click.Context, name: str, role: str) -> None:
"""创建用户。"""
users = load_users()
users.append({"name": name, "role": role})
DATA_FILE.write_text(json.dumps(users, ensure_ascii=False, indent=2))
if ctx.obj["verbose"]:
click.echo(f"写入文件:{DATA_FILE.resolve()}")
click.echo(f"已创建用户:{name} ({role})")
@cli.command("list")
@click.option(
"--format",
"output_format",
type=click.Choice(["table", "json"]),
default="table",
)
def list_users(output_format: str) -> None:
"""列出用户。"""
users = load_users()
if output_format == "json":
click.echo(json.dumps(users, ensure_ascii=False))
return
for user in users:
click.echo(f"{user['name']:<16} {user['role']}")
if __name__ == "__main__":
cli()
完整使用:
bash
python user_cli.py --help
python user_cli.py add Alice --role admin
python user_cli.py --verbose add Bob
python user_cli.py list --format json
方法:ctx.obj 可向所有子命令传递公共配置,如 --verbose、配置文件路径或 API 客户端。命令函数只做参数转换和调用,业务逻辑应放在普通 Python 函数中。
6. 新项目推荐:Typer + Rich
Typer 从类型注解生成参数解析与帮助文档。与 Path、date、Enum 和 Pydantic 等类型配合很自然,Rich 用于人类可读的终端反馈。
安装:
bash
python -m pip install typer rich
文件:report_cli.py
python
from datetime import date
from enum import Enum
from pathlib import Path
from typing import Annotated
import typer
from rich.console import Console
app = typer.Typer(help="日报生成工具")
console = Console()
class OutputFormat(str, Enum):
text = "text"
json = "json"
@app.command()
def generate(
source: Annotated[
Path,
typer.Argument(exists=True, readable=True, help="原始 CSV 文件"),
],
day: Annotated[
date,
typer.Option("--day", help="YYYY-MM-DD"),
] = date.today(),
output: Annotated[
Path,
typer.Option("--output", "-o"),
] = Path("report.txt"),
output_format: Annotated[
OutputFormat,
typer.Option("--format"),
] = OutputFormat.text,
dry_run: Annotated[
bool,
typer.Option("--dry-run"),
] = False,
) -> None:
"""根据输入文件生成日报。"""
content = (
f"日报日期:{day}\n"
f"数据来源:{source.name}\n"
f"格式:{output_format.value}\n"
)
if dry_run:
console.print(f"[yellow]演练模式,不写入:{output}[/yellow]")
console.print(content)
return
output.write_text(content, encoding="utf-8")
console.print(f"[green]已生成:{output.resolve()}[/green]")
@app.command()
def status() -> None:
"""显示工具状态。"""
console.print("[green]report-cli 可用:generate, status[/green]")
if __name__ == "__main__":
app()
完整使用:
bash
python report_cli.py --help
python report_cli.py generate input.csv --day 2026-07-26 -o daily.txt
python report_cli.py generate input.csv --dry-run
python report_cli.py status
7. 从脚本到可安装 CLI
工具交给其他人使用时,用 pyproject.toml 注册入口。安装后用户运行 report-cli,不需要知道脚本路径或 Python 模块结构。
text
report-cli/
├── pyproject.toml
├── src/
│ └── report_cli/
│ ├── __init__.py
│ ├── cli.py # Typer / Click 命令层
│ └── service.py # 可复用业务逻辑
└── tests/
└── test_cli.py
文件:pyproject.toml
toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "report-cli"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["typer>=0.12", "rich>=13"]
[project.scripts]
report-cli = "report_cli.cli:app"
[tool.pytest.ini_options]
testpaths = ["tests"]
安装、运行与构建:
bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install -e .
report-cli --help
report-cli generate input.csv --dry-run
python -m pip install build
python -m build
Rich、questionary、FastAPI 的组合
- Rich:用于表格、进度条、彩色日志。面向人类的输出可以丰富,面向机器的输出必须稳定。
- questionary:适合 init 向导和危险操作确认,必须给 CI 提供参数或 --non-interactive 回退。
- FastAPI:CLI 与 API 不必二选一。把核心能力放在 service.py,Typer 命令与 FastAPI 路由各自完成输入转换,再调用同一业务函数。
8. 质量、错误与发布
退出码与安全
- 0:成功。
- 1:业务失败,例如找不到指定资源。
- 2:命令使用错误,通常由参数解析器处理。
- 危险操作先显示受影响对象,提供 --dry-run 与 --yes。
- 不要输出密码、令牌和完整密钥。仅在 --debug 下输出必要的异常细节。
- 选项优先于环境变量,环境变量优先于配置文件。
Typer CLI 测试示例
文件:tests/test_cli.py
python
from typer.testing import CliRunner
from report_cli.cli import app
runner = CliRunner()
def test_status() -> None:
result = runner.invoke(app, ["status"])
assert result.exit_code == 0
assert "report-cli" in result.stdout
def test_missing_file() -> None:
result = runner.invoke(app, ["generate", "missing.csv"])
assert result.exit_code != 0
发布前检查清单
- tool --help 能解释每个命令、参数、默认值和示例。
- 测试成功路径、非法参数、文件不存在与 --dry-run。
- 为脚本使用者提供 --format json,不要混入进度文字。
- 在新虚拟环境中完成安装、--version、基本命令与打包安装验证。
- 不在日志、错误信息、示例或截图中泄露令牌、密码和密钥。
本期作业
选择一个已有 Python 脚本,将它改造成如下形式的 CLI:
bash
your-tool action input --dry-run
交付:
- --help 的文本或截图。
- 三条真实调用命令。
- 至少一个 CLI 自动化测试。
- 一条失败场景及其退出码说明。
常见问题
Click、Typer、argparse 能混用吗?
同一条命令树建议只使用一种参数解析框架,避免帮助文本和错误处理不一致。业务逻辑、Rich 输出、Pydantic 模型和 pytest 测试可以共享。已有 Click 项目无需为了类型注解强行迁移。
什么时候要用子命令?
当工具有两个以上独立动作,例如 user add、user list、user delete 时使用子命令。只有一个动作且参数很少时,单层命令更好用。
为什么要区分人类输出和 JSON 输出?
人类输出可以有颜色、表格、进度条;脚本需要稳定字段和可预测结构。提供 --format json 后,应保持标准输出只包含 JSON,错误信息输出到标准错误。