Python 命令行工具开发
把脚本升级成"像样的工具":规范的参数、有意义的退出码、对管道友好。
argparse是标准库方案,本篇以它为主,结尾给出 click/typer 对比与打包安装方式。全部示例在 Python 3.13 实测。环境见 Python 环境与包管理,管道与退出码的 shell 侧约定见 Shell 脚本笔记。
评价命令行工具的四条标准
- 参数:位置参数与选项分明,
--help自动生成; - 退出码:成功 0,失败非 0——调用方(脚本、CI)靠它判断(
set -e与&&都依赖它); - 流分离:结果走 stdout,日志与报错走 stderr——管道
tool | next才不会被日志污染; - 可组合:能读 stdin、能输出纯文本,就能接进 Unix 管道。
argparse:标准库全解
#!/usr/bin/env python3
"""wordcount: 统计文本中出现次数最高的词。"""
import argparse
import sys
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="wordcount",
description="统计文本中出现次数最高的词",
)
# 位置参数:必须提供
parser.add_argument("file", help="要统计的文本文件;- 表示 stdin")
# 可选参数
parser.add_argument("-n", "--top", type=int, default=10,
help="显示前 N 个(默认 %(default)s)")
parser.add_argument("-i", "--ignore-case", action="store_true",
help="忽略大小写")
parser.add_argument("-v", "--verbose", action="count", default=0,
help="提高日志详细度,可叠加(-vv)")
parser.add_argument("--sep", choices=["space", "any"], default="space",
help="分词方式(默认 space)")
parser.add_argument("--exclude", action="append", default=[],
metavar="WORD", help="排除的词,可多次提供")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) # 传入 None 时用 sys.argv[1:]
if args.file == "-":
text = sys.stdin.read()
else:
try:
with open(args.file, encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"wordcount: cannot read {args.file}: {e}", file=sys.stderr)
return 1 # 失败:非零退出码 + stderr
if args.ignore_case:
text = text.lower()
words = text.split() if args.sep == "any" else text.split(" ")
words = [w for w in words if w and w not in args.exclude]
counts: dict[str, int] = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
for w, c in sorted(counts.items(), key=lambda kv: -kv[1])[: args.top]:
print(f"{c}\t{w}") # 结果走 stdout
return 0 # 成功:0
if __name__ == "__main__":
sys.exit(main())
main(argv=None) 的写法让整个工具可以被 pytest 直接调用(传入构造好的列表,不碰真实命令行),这是可测试性的关键(见测试与调试)。
用起来是什么样
$ python wordcount.py essay.txt -n 3 # 常规用法
42 the
18 of
7 to
$ python wordcount.py missing.txt
wordcount: cannot read missing.txt: [Errno 2] No such file or directory
$ echo $?
1
$ cat essay.txt | python wordcount.py - # 管道读 stdin
$ python wordcount.py --help # 自动生成的帮助(含 prog/description/参数说明)
argparse 参数类型速查
| 写法 | 效果 |
|---|---|
add_argument("file") |
位置参数 |
add_argument("-n", "--top", type=int, default=10) |
短长双名、类型转换、默认值 |
action="store_true" |
布尔开关(出现即 True) |
action="count" |
计数开关(-vv 得 2) |
action="append" |
可重复出现,收集成列表 |
choices=["a", "b"] |
取值白名单,非法值自动报错 |
nargs="?" / nargs="*" / nargs="+" |
零或一 / 零或多 / 一或多 |
metavar="WORD" |
帮助文本里参数的显示名 |
子命令
工具长出多个动词(像 git add/commit)时用 subparsers:
parser = argparse.ArgumentParser(prog="mydb")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="添加记录")
p_add.add_argument("name")
p_ls = sub.add_parser("list", help="列出记录")
p_ls.add_argument("--all", action="store_true")
args = parser.parse_args()
if args.command == "add":
...
click / typer:装饰器风格
参数一多,argparse 的手工接线会变长;第三方库用装饰器声明:
# pip install click
import click
@click.command()
@click.argument("file", type=click.File("r"))
@click.option("-n", "--top", default=10, show_default=True)
def wordcount(file, top):
"""统计文本中出现次数最高的词。"""
counts = {}
for word in file.read().split():
counts[word] = counts.get(word, 0) + 1
for w, c in sorted(counts.items(), key=lambda kv: -kv[1])[:top]:
click.echo(f"{c}\t{w}")
if __name__ == "__main__":
wordcount()
| argparse | click | typer | |
|---|---|---|---|
| 依赖 | 标准库 | 第三方 | 第三方(基于 click,类型注解声明参数) |
| 风格 | 命令式接线 | 装饰器 | 装饰器 + 类型注解 |
| 适用 | 标准工具、无第三方依赖约束 | 参数复杂的中型工具 | 偏好类型注解的团队 |
选择依据很朴素:标准库够用就不引依赖;参数复杂或要嵌套子命令时,click/typer 的声明式写法更省心。
打包成真正的命令
写完的工具不必靠 python xxx.py 启动。在 pyproject.toml 里注册入口(文件结构见 Python 环境与包管理):
[project]
name = "wordcount"
version = "0.1.0"
dependencies = []
[project.scripts]
wordcount = "wordcount:main" # 命令名 = 模块:函数
常见错误
| 错误 | 说明 |
|---|---|
| 用 print 打日志 | 日志进 stdout 污染管道输出;报错一律 file=sys.stderr(日志库见测试与调试) |
| 失败不设退出码 | 调用方无法感知失败;sys.exit(1) 或 main 返回非零 |
| 忽略编码 | 显式 encoding="utf-8",别依赖系统默认值 |
--help 内容含糊 |
每个参数都写 help=;工具名与描述进 prog/description |
| 大输出不 flush | 输出到非终端(管道)时按块缓冲;要与远端交互时 print(..., flush=True) |
| 参数校验散落各处 | 用 type=、choices 声明式校验,交给 argparse 报错 |