跳转至

Python 命令行工具开发

把脚本升级成"像样的工具":规范的参数、有意义的退出码、对管道友好。argparse 是标准库方案,本篇以它为主,结尾给出 click/typer 对比与打包安装方式。全部示例在 Python 3.13 实测。环境见 Python 环境与包管理,管道与退出码的 shell 侧约定见 Shell 脚本笔记。

评价命令行工具的四条标准

  1. 参数:位置参数与选项分明,--help 自动生成;
  2. 退出码:成功 0,失败非 0——调用方(脚本、CI)靠它判断(set -e 与 && 都依赖它);
  3. 流分离:结果走 stdout,日志与报错走 stderr——管道 tool | next 才不会被日志污染;
  4. 可组合:能读 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"        # 命令名 = 模块:函数
uv tool install .                    # 安装为独立命令(隔离环境)
wordcount essay.txt -n 3             # 任何目录直接可用

常见错误

错误 说明
用 print 打日志 日志进 stdout 污染管道输出;报错一律 file=sys.stderr(日志库见测试与调试)
失败不设退出码 调用方无法感知失败;sys.exit(1) 或 main 返回非零
忽略编码 显式 encoding="utf-8",别依赖系统默认值
--help 内容含糊 每个参数都写 help=;工具名与描述进 prog/description
大输出不 flush 输出到非终端(管道)时按块缓冲;要与远端交互时 print(..., flush=True)
参数校验散落各处 用 type=、choices 声明式校验,交给 argparse 报错

评论