引言
这是第九章「综合项目:命令行工具」的第 3 篇。第 2 篇建立的 ArgumentParser 三步曲解决了”参数长什么样”的问题,但真实工具还有几个硬需求没有答案:用户传的 "42" 怎么变成数字?怎么限定参数只能取几个合法值?一个参数怎么接收任意多个文件?一个程序怎么像 git 那样拆成 git init、git status、git commit 多个模式?本篇全部解决:type= 类型转换、choices 与 default、nargs 数量控制、子命令 add_subparsers、互斥组 add_mutually_exclusive_group,最后用”同一功能的手写版与 argparse 版”做一次全面对比,彻底回答”为什么值得把参数解析交给库”。
本篇是参数解析的收尾篇,也是第 8 篇待办工具 todo 的预告:届时 init、add、done、status 这些命令正是用本篇的子命令机制实现的——本篇文章里的 todo_demo.py 就是它的骨架预演。异常方面的 SystemExit 机制第 2 篇已讲透,本篇只引用不重复。
概念与原理
type=:把词元变成值的转换流水线
第 1 篇讲过,sys.argv 里的一切都是字符串。type= 参数的作用是给 argparse 加一道转换流水线:每收到一个词元,先调用 type 指定的函数,把返回值存入命名空间。type=int 等价于”每个参数值都过一遍 int()”;type 也可以是任意可调用对象——函数、类、lambda 都行,这让”校验+转换”合二为一。
转换失败的语义有讲究:转换函数抛 ValueError/TypeError 时,argparse 捕获它并包装成统一的错误信息(“invalid int value: ‘abc‘“这种格式),再走第 2 篇讲过的 SystemExit(2) 通道;如果是自定义转换器,推荐抛 argparse.ArgumentTypeError,它的消息会被原样采用,最利于给出中文可读的报错。反之,转换函数里抛出其他类型的异常(比如 RuntimeError)不会被包装,会带着整条 traceback 原样冒出来——这是自定义转换函数最容易踩的坑(见陷阱二)。这套”先尝试、失败再聚焦报错”的写法正是第四章 EAFP 风格在参数层的体现。
choices 与 default:声明的校验与兜底
choices=[...] 声明该参数的合法取值集合,转换完成后校验,不在集合内就报错,错误信息里自动附带全部合法值——用户不用查文档就能知道该填什么。注意 choices 比较的是转换后的值:type=int 配 choices=[1,2,3] 时用整数比较,"1" 先转成 1 再比对。
default= 在参数根本没有出现时生效,它是声明式兜底:不写分支、不写 if,缺省行为就写在声明里。两个细节值得记住:其一,default 的值可以是任何对象(None、列表、字符串皆可),且只有字符串会被拿去走 type 转换;其二,出现了参数但没有给值(如 nargs="?" 的场景)与”完全没出现”是两种状态,default 只对后者负责。
nargs:一个参数对应 N 个词元
默认情况下一个参数消费一个词元。nargs 用长度模式改变这个数量,取值与正则表达式量词语义一致,很好记:? 表示 0 或 1 个(返回单个值,缺省时用 default);* 表示 0 个或多个(返回列表,即使一个都没给也返回空列表 []);+ 表示 1 个或多个(返回列表,至少给一个,否则报”required”错误);也可以写具体数字(如 2)表示固定个数。? 与 * 的区别暗藏一个经典陷阱:* 返回 [] 而 ? 返回 None,两者在 if 判断里都是假值,但类型不同,混用会埋雷(见陷阱三)。
子命令 add_subparsers:一个程序多个模式
当工具的命令动词多于一个时,git 给出了范式:git init、git status、git commit -m "..." ——第一个词是动词(子命令),后续是这个动词自己的参数。add_subparsers() 负责把主解析器改造成”命令路由器”:它返回一个子解析器注册器,add_parser("init") 为每个子命令造一个独立的 ArgumentParser,每个子解析器可以有自己的位置参数、选项、默认值,各自的 --help 也自动生成。
解析结果是一个合并后的命名空间:主解析器登记的 dest="command" 记录用户敲了哪个子命令,子解析器登记的参数(如 args.text)与它同处一个对象。想让子命令必选,先 add_subparsers(dest="command") 再传 required=True(Python 3.7+),缺省时报”the following arguments are required: command”。忘记 required=True 时,缺省子命令不会报错,args.command 只是 None——业务代码若没处理 None,会静默地什么都不做(见陷阱四)。
互斥组:表达”要么 A 要么 B”
add_mutually_exclusive_group() 返回一个分组容器,组内登记的选项互相排斥,同时给出时直接报错(“not allowed with”)。典型场景:--upper/--lower 二选一、--json/--human 输出格式二选一。互斥组可以再配 required=True 实现”必须且只能选一个”(函数签名里的必填多选一),但组内默认值与 required 的交互容易出歧义,工程上更常见的是”都可省、省了用默认行为”。
argparse 与手写 sys.argv 解析:全面对比
第 1 篇的手写解析器在参数少于三个、格式固定时完全够用,但规模一上来,手写版的成本呈多项式增长:要自己处理”选项夹在位置参数中间”(prog a.log --verbose b.log)、要自己实现 -- 分隔符、要自己拼 help 文本、要自己保证报错措辞统一。argparse 把这些全部内置:--verbose 出现在任何位置都能正确归位;-- 之后的词元一律视为位置参数;help、usage、错误信息同源生成。两者的取舍一句话:一次性脚本手写,会被反复使用、面向用户的工具交给 argparse——本篇最后的实测对比会把每一项差异摆在眼前。
操作与实现
第一步:type=int 的基础转换
add_calc.py——两个位置参数强制转整数:
import argparse
parser = argparse.ArgumentParser(description="两个整数求和")
parser.add_argument("a", type=int, help="第一个整数")
parser.add_argument("b", type=int, help="第二个整数")
args = parser.parse_args()
print(f"{args.a} + {args.b} = {args.a + args.b}")
python add_calc.py 3 4
3 + 4 = 7
类型错误交给库:
python add_calc.py 3 abc
usage: add_calc.py [-h] a b
add_calc.py: error: argument b: invalid int value: 'abc'
退出码: 2
注意:转换发生在 a 成功之后、b 失败即止,错误消息指名道姓”argument b”,退出码照例是 2(SystemExit 机制详见第 2 篇)。
第二步:自定义 type 函数与 ArgumentTypeError
ratio.py——把 16:9 这样的比例字符串转换为浮点数 16/9,再算出目标分辨率高度:
import argparse
def parse_ratio(text):
"""把 '16:9' 这样的比例字符串解析为浮点数 16/9。"""
try:
first, second = text.split(":")
return float(first) / float(second)
except (ValueError, ZeroDivisionError):
raise argparse.ArgumentTypeError(f"无法解析比例: {text!r}")
parser = argparse.ArgumentParser(description="按宽高比计算目标分辨率的高度")
parser.add_argument("ratio", type=parse_ratio, help="宽高比,形如 16:9")
parser.add_argument("--width", type=int, default=1920, help="宽度像素")
args = parser.parse_args()
height = int(args.width / args.ratio)
print(f"{args.width} x {height}")
python ratio.py 16:9
1920 x 1080
16:9 → 16/9 ≈ 1.7778,1920 除以它取整得 1080——比例换算在工程里(分辨率、缩略图、视频编码)是高频需求。非法输入时,ArgumentTypeError 的消息原样进入错误信息:
python ratio.py 16x9
usage: ratio.py [-h] [--width WIDTH] ratio
ratio.py: error: argument ratio: 无法解析比例: '16x9'
退出码: 2
对比 type=int 的英文模板,自定义消息把”期望什么格式”直接告诉用户,CLI 的可用性就是这样一点点积累的。
第三步:choices 与 default 组合
deploy.py——部署工具的环境白名单:
import argparse
parser = argparse.ArgumentParser(description="一键部署工具")
parser.add_argument("--env", choices=["dev", "staging", "prod"], default="dev", help="目标环境")
parser.add_argument("--retries", type=int, default=3, help="失败后的重试次数")
args = parser.parse_args()
print(f"部署到 {args.env},失败重试上限 {args.retries} 次")
三种调用方式覆盖三个知识点:
python deploy.py
python deploy.py --env prod
python deploy.py --env test
部署到 dev,失败重试上限 3 次
部署到 prod,失败重试上限 3 次
usage: deploy.py [-h] [--env {dev,staging,prod}] [--retries RETRIES]
deploy.py: error: argument --env: invalid choice: 'test' (choose from dev, staging, prod)
退出码: 2
注意两处:usage 行里 {dev,staging,prod} 出现了两次——声明自动进入了签名与错误信息;第二次调用 --env prod 时 --retries 没出现,命中默认值 3——default 与 choices 各司其职,一个管缺省、一个管合法。
第四步:nargs 的 ?/*/+ 三种形态
先看 ? 与 *(nargs_demo.py):
import argparse
parser = argparse.ArgumentParser(description="nargs 三种形态对比")
parser.add_argument("src", help="源文件")
parser.add_argument("dst", nargs="?", default="out.txt", help="目标文件(可省)")
parser.add_argument("--tags", nargs="*", help="任意多个标签")
args = parser.parse_args()
print("源:", args.src, "| 目标:", args.dst, "| 标签:", args.tags)
python nargs_demo.py a.txt
python nargs_demo.py a.txt b.txt --tags 重要 紧急
python nargs_demo.py a.txt --tags
源: a.txt | 目标: out.txt | 标签: None
源: a.txt | 目标: b.txt | 标签: ['重要', '紧急']
源: a.txt | 目标: out.txt | 标签: []
三行输出对照出两个结论:? 的位置参数可省,省了用 default(“out.txt”);--tags 是 *,给两个词元收两个,一个都不给时返回空列表 [] 而不是 None——记住这个差异,陷阱三里它会咬人。
再看 +(bulk.py)——至少要有一个文件:
import argparse
parser = argparse.ArgumentParser(description="批量压缩指定文件")
parser.add_argument("files", nargs="+", help="至少一个要压缩的文件")
parser.add_argument("--out-dir", default="out", help="输出目录")
args = parser.parse_args()
print("输出目录:", args.out_dir)
print("待压缩文件:", args.files)
python bulk.py a.log b.log c.log
python bulk.py
输出目录: out
待压缩文件: ['a.log', 'b.log', 'c.log']
usage: bulk.py [-h] [--out-dir OUT_DIR] files [files ...]
bulk.py: error: the following arguments are required: files
退出码: 2
usage 行里的 files [files ...] 是 + 的签名写法——“至少一个,允许更多”。+ 与必填位置参数的区别正是后者只接受恰好一个词元。
第五步:子命令 add_subparsers(todo 骨架预演)
todo_demo.py——四个子命令 init/add/done/status,正是第 8 篇待办工具的骨架:
import argparse
parser = argparse.ArgumentParser(description="待办工具演示版")
sub = parser.add_subparsers(dest="command", required=True, help="可用子命令")
p_init = sub.add_parser("init", help="初始化待办存储")
p_add = sub.add_parser("add", help="新增一条待办")
p_add.add_argument("text", help="待办内容")
p_done = sub.add_parser("done", help="标记某条待办为完成")
p_done.add_argument("index", type=int, help="待办编号")
p_status = sub.add_parser("status", help="查看待办统计")
args = parser.parse_args()
print("执行的子命令:", args.command)
if args.command == "init":
print("初始化完成")
elif args.command == "add":
print(f"新增待办: {args.text}")
elif args.command == "done":
print(f"标记第 {args.index} 条为完成")
elif args.command == "status":
print("共 0 条待办,0 条已完成")
主帮助自动生成了子命令索引:
python todo_demo.py --help
usage: todo_demo.py [-h] {init,add,done,status} ...
待办工具演示版
positional arguments:
{init,add,done,status}
可用子命令
init 初始化待办存储
add 新增一条待办
done 标记某条待办为完成
status 查看待办统计
options:
-h, --help show this help message and exit
每个子命令独立运行,注意同一个命名空间的合并:主解析器写入 args.command,子解析器写入 args.text/args.index:
python todo_demo.py init
python todo_demo.py add 写周报
python todo_demo.py done 2
python todo_demo.py status
执行的子命令: init
初始化完成
执行的子命令: add
新增待办: 写周报
执行的子命令: done
标记第 2 条为完成
执行的子命令: status
共 0 条待办,0 条已完成
required=True 生效时缺省子命令直接报错:
python todo_demo.py
usage: todo_demo.py [-h] {init,add,done,status} ...
todo_demo.py: error: the following arguments are required: command
退出码: 2
每个子命令还能有自己的 --help(usage 行里的程序名变成 todo_demo.py add,说明子解析器是独立的名字空间):
python todo_demo.py add --help
usage: todo_demo.py add [-h] text
positional arguments:
text 待办内容
options:
-h, --help show this help message and exit
这套模式就是第 8 篇 todo 工具的种子:届时 add 写周报 会真的把 “写周报” 写进 JSON 存储文件,done 2 会真的把第 2 条标记完成——参数层面的所有机制,本篇到此已经全部具备。
第六步:互斥组 add_mutually_exclusive_group
case_tool.py——大小写转换二选一:
import argparse
parser = argparse.ArgumentParser(description="大小写转换工具")
group = parser.add_mutually_exclusive_group()
group.add_argument("--upper", action="store_true", help="转成大写")
group.add_argument("--lower", action="store_true", help="转成小写")
parser.add_argument("text", help="要转换的文本")
args = parser.parse_args()
if args.upper:
result = args.text.upper()
elif args.lower:
result = args.text.lower()
else:
result = args.text
print(result)
python case_tool.py --upper "hello world"
python case_tool.py "Keep It"
python case_tool.py --upper --lower "hi"
HELLO WORLD
Keep It
usage: case_tool.py [-h] [--upper | --lower] text
case_tool.py: error: argument --lower: not allowed with argument --upper
退出码: 2
usage 行里 [--upper | --lower] 的竖线就是互斥的签名记号;两个开关同时给出时,报错并退出 2。互斥不限于开关,带值的选项同样适用。
第七步:与手写解析的实测对比
第 1 篇的手写版 manual_parse.py 处理”详细模式 + 文件列表”:
import sys
def parse_argv(argv):
verbose = False
files = []
for arg in argv[1:]:
if arg == "--verbose":
verbose = True
elif arg.startswith("--"):
print(f"未知选项: {arg}", file=sys.stderr)
sys.exit(2)
else:
files.append(arg)
return verbose, files
verbose, files = parse_argv(sys.argv)
print("详细模式:", verbose)
print("文件列表:", files)
python manual_parse.py a.log --verbose b.log
详细模式: True
文件列表: ['a.log', 'b.log']
同样的功能,argparse 版 arg_parse2.py:
import argparse
parser = argparse.ArgumentParser(description="参数解析对比:argparse 版")
parser.add_argument("--verbose", action="store_true", help="详细模式")
parser.add_argument("files", nargs="+", help="文件列表")
args = parser.parse_args()
print("详细模式:", args.verbose)
print("文件列表:", args.files)
python arg_parse2.py --verbose a.log b.log
详细模式: True
文件列表: ['a.log', 'b.log']
输出完全一致,分水岭在”另一条路上”。手写版把 --verbose 夹在位置参数之间也能工作,但已经要靠循环里逐个判断才能做到;未知选项时两者都退出 2,报错质量却差了一档:
python manual_parse.py --all
python arg_parse2.py --all
未知选项: --all
退出码: 2
usage: arg_parse2.py [-h] [--verbose] files [files ...]
arg_parse2.py: error: unrecognized arguments: --all
退出码: 2
argparse 版额外给了完整签名供自查。还有一个手写版很难做对的能力:-- 分隔符——python add_calc.py -- 3 4 中 -- 之后的词元一律按位置参数处理(本机实测得到 3 + 4 = 7),手写版若想支持(常见于”文件名以横线开头”的场景)又要加一截特殊逻辑。结论很清楚:功能 5 行以内手写,超过就交给 argparse 的三步曲。
易错点与陷阱
陷阱一:把 help 文本写进 type 想做的校验里
type 的职责是”转换+格式校验”,choices 的职责是”取值校验”,help 只负责给人看——三者各司其职。常见的错误是把范围判断写进函数体再 raise 一个普通异常,或者用 if/else 手工检查后在 parse_args 之后 sys.exit,绕过了库的统一错误通道,报错格式立刻分裂。正确姿势:格式问题抛 ArgumentTypeError(或让 ValueError 被自动包装),取值问题交给 choices。
陷阱二:自定义 type 函数里抛出非 ValueError 的异常
转换函数抛 ValueError/TypeError 会被包装成 “invalid xxx value” 的规范错误;但抛 RuntimeError、KeyError 这类异常时,argparse 不包装,直接原样冒出完整 traceback。本机实测:parse_ratio 里对 1:0 抛 RuntimeError("除数不能为零"),程序带着 traceback 退出而不是给出友善报错。写转换函数时要么捕获后抛 ArgumentTypeError(消息自拟),要么保证只让 ValueError 家族漏出去。
陷阱三:混淆 nargs=”?” 与 ”*” 的缺省值
同样”一个都没给”:? 的结果是 None,* 的结果是空列表 []。二者布尔值都是假,但在 json 序列化(None 变 null)、列表拼接(None + [] 直接 TypeError)等场景行为天差地别。工程建议:位置/选项语义上”可缺省的单值”用 ?,“可缺省的多值”用 *,并在文档里写清缺省形态。
陷阱四:子命令忘记 required=True
add_subparsers(dest="command") 不带 required=True 时,python todo_demo.py 不会报错,args.command 只是 None,随后 if args.command == "init" 之类全落空,程序”静默成功”——比报错更危险。Python 3.7 之前这是默认行为,很多旧教程因此没写 required;3.7+ 请务必显式传 required=True,让”没有子命令”成为用法错误。
小结
本篇把 argparse 从”会解析”推到”会表达约束”:type= 让词元在入库前完成类型转换与格式校验(自定义函数配 ArgumentTypeError 得到可读报错);choices/default 分别声明合法集与缺省值;nargs 用 ?/*/+(借自正则的量词语义)控制词元数量,注意 ? 给 None、* 给 [] 的缺省差异;add_subparsers 把工具拆成分工明确的子命令模式,命名空间由主、子解析器合并共享,required=True 保证命令必选;互斥组用一行声明表达”二选一”约束;与第 1 篇手写版对比,argparse 在位置/选项混排、-- 分隔符、报错质量与 help 生成上全面胜出。至此参数层彻底收官——第 4 篇起,我们把输出交给 logging,让工具学会”说话”。
练习与思考题
- 给
todo_demo.py的add子命令增加--priority选项:choices=["low", "mid", "high"]、default="mid"。运行python todo_demo.py add 买牛奶 --priority high与不带该选项的版本,验证args.priority的取值。 - 把
ratio.py的parse_ratio改为在缺少:时直接raise ValueError(不捕获),运行python ratio.py 16x9,对照陷阱二解释它为什么还能得到规范报错;再把内部的ZeroDivisionError改成直接抛出,观察差异。 - 写一个
check_env.py:用互斥组实现--json/--text两种输出格式(互斥、默认 text),--json时用json.dumps输出一个字典,--text时逐行输出”键: 值”。 - 思考题:
add_subparsers的dest与子命令参数在同一个命名空间里,为什么args.command一定是字符串、而args.text的类型取决于子命令声明?如果要实现”同一参数名在不同子命令里类型不同”,这套机制需要额外处理什么?