Python 怎么写命令行程序?argparse 有哪些坑?
简化版
argparse 是标准库的命令行参数解析器,它替你做四件手写 sys.argv 很难做好的事:解析各种参数形式(-v、--verbose、--out=a.txt、-abc 合并短选项)、类型转换与校验、自动生成 -h/--help 帮助文本、以及出错时给出友好提示并以退出码 2 退出。四步骨架:parser = argparse.ArgumentParser(description=...) → parser.add_argument(...) 逐个声明参数 → args = parser.parse_args() → 用 args.xxx 取值。核心区分:不带横杠的是位置参数(必填,add_argument("src")),带 -/-- 的是可选参数(默认可不填,add_argument("--out", default="a.txt"))。最常用的几个关键字:type=int(转换 + 校验)、default=、choices=["a","b"](限定取值)、nargs="*"/"+"/"?"(接受多个值)、action="store_true"(开关标志)、required=True(让可选参数变必填)、help=(帮助文本)。几个典型坑:① type=bool 完全无效——bool("False") 是 True,布尔开关必须用 action="store_true";② 参数名里的横杠会转成下划线(--log-level → args.log_level);③ 负数或以 - 开头的值容易被当成选项,要用 --out=-1 或 -- 分隔;④ 默认允许前缀缩写(--verb 能匹配 --verbose),加新参数时可能造成歧义,严谨的 CLI 应设 allow_abbrev=False。子命令(git commit 风格)用 add_subparsers()。核心记忆:位置参数必填、可选参数带横杠、布尔用 store_true、子命令用 subparsers。
详细版
add_argument 的核心关键字:
| 关键字 | 作用 | 例子 |
|---|---|---|
type | 转换 + 校验(可传任意可调用对象) | type=int、type=Path、type=自定义函数 |
default | 未提供时的值 | default=8080 |
choices | 限定取值范围 | choices=["dev","prod"] |
nargs | 接受几个值 | "?"(0或1) "*"(任意) "+"(至少1) 2(恰好2) |
action | 怎么存 | store_true/store_false/append/count/version |
required | 让可选参数变必填 | required=True |
dest | 存到 args 的哪个属性 | dest="log_level" |
metavar | 帮助里显示的占位名 | metavar="FILE" |
import argparse
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="mytool",
description="批量处理数据文件",
epilog="示例:mytool data/ -o out.json -vv",
allow_abbrev=False, # ★关闭前缀缩写,避免 --verb 歧义★
)
# 位置参数:必填
p.add_argument("src", type=Path, help="输入目录")
# 可选参数:带横杠
p.add_argument("-o", "--out", type=Path, default=Path("out.json"), help="输出文件")
# 布尔开关:★必须用 store_true,不能用 type=bool★
p.add_argument("--dry-run", action="store_true", help="只演练不写文件")
# 可重复计数:-v / -vv / -vvv
p.add_argument("-v", "--verbose", action="count", default=0, help="增加日志级别")
# 限定取值
p.add_argument("--env", choices=["dev", "staging", "prod"], default="dev")
# 多个值:--exclude a b c
p.add_argument("--exclude", nargs="*", default=[], help="排除的文件名")
# 可重复出现:--tag x --tag y → ["x", "y"]
p.add_argument("--tag", action="append", default=[])
# 自定义校验(type 可以是任意可调用对象)
def positive(s: str) -> int:
v = int(s)
if v <= 0:
raise argparse.ArgumentTypeError(f"必须是正整数,收到 {v}")
return v
p.add_argument("--workers", type=positive, default=4)
# 互斥:--json 和 --csv 只能选一个
g = p.add_mutually_exclusive_group()
g.add_argument("--json", action="store_true")
g.add_argument("--csv", action="store_true")
return p
# ★ 把 argv 作为参数传入 → 便于测试(不依赖真实命令行)
def main(argv=None):
args = build_parser().parse_args(argv)
print(args)
if args.dry_run:
print("演练模式,不写文件")
main(["data/", "-o", "r.json", "-vv", "--env", "prod", "--tag", "x", "--tag", "y"])
# Namespace(src=PosixPath('data'), out=PosixPath('r.json'), dry_run=False,
# verbose=2, env='prod', exclude=[], tag=['x','y'], workers=4, json=False, csv=False)
# ② 子命令(git 风格)
root = argparse.ArgumentParser(prog="mytool")
subs = root.add_subparsers(dest="cmd", required=True) # ★required 让"不给子命令"报错★
sync = subs.add_parser("sync", help="同步数据")
sync.add_argument("--force", action="store_true")
sync.set_defaults(func=lambda a: print("执行 sync", a.force)) # ★把处理函数绑上去★
show = subs.add_parser("show", help="查看状态")
show.set_defaults(func=lambda a: print("执行 show"))
a = root.parse_args(["sync", "--force"])
a.func(a) # 执行 sync True
# ③ 几个坑的现场
# p.add_argument("--flag", type=bool) # ✗ 无效:bool("False") == True
# mytool --out -1 # ✗ -1 被当成选项 → 用 --out=-1
# --log-level → args.log_level # 横杠自动转下划线
⚠️ 三个最容易出错的地方:①
type=bool是无效的。argparse把命令行的值当字符串交给type转换,而bool("False")、bool("0")都是True(非空字符串恒为真),所以--flag False反而会得到True。布尔开关的正确写法是action="store_true"(出现即为True、不出现为False);如果确实需要--flag=true/false的形式,得自己写一个转换函数(把"true"/"1"/"yes"映射为True),或用 3.9+ 的argparse.BooleanOptionalAction(自动同时提供--flag和--no-flag)。② 默认开启前缀缩写(allow_abbrev=True):只要不产生歧义,--verb就能匹配--verbose。这看起来贴心,实则是兼容性地雷——今天用户脚本里写的--ver能跑,明天你加了个--version,他的脚本就因为「歧义」而报错了。对外发布的 CLI 建议allow_abbrev=False。③parse_args()默认读sys.argv[1:],但它接受一个参数:写成parse_args(argv)并让main(argv=None)透传,单元测试就能直接main(["--out", "x"])而不必伪造sys.argv——这是让 CLI 可测试的关键写法。
完整版教学
一、为什么不手撸 sys.argv
手写解析要处理的情况(每一条都是真实需求):
① 长短选项:-o out.txt / --out out.txt / --out=out.txt
② 短选项合并:-abc 等价于 -a -b -c
③ 位置参数和可选参数★任意穿插★:mytool a.txt --out b -v c.txt
④ 类型转换 + 出错提示:--port abc → "invalid int value: 'abc'"
⑤ 必填校验、取值范围校验
⑥ -h/--help 帮助文本(还要自动对齐、自动列出默认值)
⑦ 出错时打印 usage 并以★退出码 2★结束(Unix 惯例)
⑧ 子命令、互斥组、可重复参数、`--` 之后全部当位置参数
手写版长这样(还只处理了第 1 条的一半):
args = sys.argv[1:]
out = "a.txt"
i = 0
while i < len(args):
if args[i] == "--out":
out = args[i+1] # ← 越界?值以 - 开头?重复出现?统统没处理
i += 2
...
→ 几十行之后你会写出一个 bug 更多的 argparse
argparse 一行搞定其中的绝大部分:
p.add_argument("-o", "--out", default="a.txt", help="输出文件")
标准库的三个解析器(面试可能问):
getopt —— C 风格,最原始,不推荐(要自己写循环处理返回值)
optparse —— ★已弃用★(Python 2.7/3.2 起废弃),老代码里还有
argparse —— 现役标准,支持位置参数、子命令、互斥组
写命令行工具时,「反正就几个参数,手撸 sys.argv 得了」是个典型的低估。真实的 CLI 需要处理长短选项、--out=x 和 --out x 两种写法、短选项合并(-abc)、位置参数与可选参数任意穿插、类型转换和错误提示、自动帮助文本、以及出错时按 Unix 惯例打印 usage 并以退出码 2 退出——手写到第三条就会开始出 bug。argparse 把这些压缩成一行 add_argument。顺带记住标准库里的三代解析器:getopt(C 风格,最原始)、optparse(已废弃,老代码里还能见到)、argparse(现役标准,唯一该用的)。
二、四步骨架与位置/可选参数
固定骨架:
parser = argparse.ArgumentParser(description="这个工具干嘛的")
parser.add_argument(...) # 逐个声明
args = parser.parse_args() # 解析(失败会自己报错退出)
用 args.xxx # 取值(args 是 Namespace 对象)
位置参数 vs 可选参数(★靠有没有横杠区分★):
add_argument("src") 位置参数:必填、按★顺序★匹配、帮助里显示为 src
add_argument("--out") 可选参数:默认可不填(默认值 None)、靠★名字★匹配
★ 名字转属性名的规则:
"src" → args.src
"--out" → args.out
"--log-level" → args.log_level (★横杠自动转下划线★)
"-o", "--out" → args.out (★取长选项名★)
dest="target" → args.target (显式指定,优先级最高)
让可选参数必填 / 让位置参数可选:
add_argument("--token", required=True) # 可选参数变必填
add_argument("dst", nargs="?", default=".") # 位置参数变可选(0 或 1 个)
nargs 的取值(决定"吃几个值"):
nargs=2 恰好 2 个 → 列表 [a, b]
nargs="?" 0 或 1 个 → 值本身;没给用 default;给了选项但没值用 const
nargs="*" 任意多个 → 列表(可能为空)
nargs="+" 至少 1 个 → 列表(0 个则报错)
nargs=argparse.REMAINDER 剩下的全部原样收走(用于"透传给子进程")
★ 一个高频细节:nargs="*" 的 default
add_argument("--exclude", nargs="*") # 不给时是 None(不是 [])
add_argument("--exclude", nargs="*", default=[]) # ✓ 给个空列表默认值,下游不用判 None
四步骨架是固定的,真正要理解的是位置参数与可选参数的分工:不带横杠的是位置参数——必填、按出现顺序匹配(cp src dst 里的两个路径);带 -/-- 的是可选参数——默认可不填、靠名字匹配(--out、-v)。语义上的原则是:「这个命令必须知道才能干活的东西」用位置参数,「调节行为的旋钮」用可选参数。名字到属性名的转换有三条规则要记牢:横杠自动转下划线(--log-level → args.log_level)、同时给了短长选项时取长选项名、dest= 可以显式覆盖。nargs 决定一个参数吃几个值,其中最容易踩的细节是 nargs="*" 不给默认值时得到的是 None 而不是空列表——下游 for x in args.exclude 会直接 TypeError,所以永远配上 default=[]。
三、action:值是怎么被存起来的
action 决定"匹配到这个参数后做什么":
store(默认) 存下这个值 --out a.txt → args.out = "a.txt"
store_true 出现即 True,不出现 False --dry-run → args.dry_run = True
store_false 出现即 False(默认 True) --no-cache → args.no_cache = False
store_const 出现即存某个常量 配合 const=
append ★每次出现都追加到列表★ --tag a --tag b → ["a","b"]
append_const 追加一个常量到共享列表
count ★统计出现次数★ -vvv → args.verbose = 3
version 打印版本号并退出 version="%(prog)s 1.2"
extend(3.8+) 把多个值展开追加到列表
BooleanOptionalAction(3.9+) 自动生成 --flag / --no-flag 一对
三个高频组合:
① 日志级别:
p.add_argument("-v", "--verbose", action="count", default=0)
level = {0: "WARNING", 1: "INFO"}.get(args.verbose, "DEBUG")
→ -v 是 INFO,-vv 及以上是 DEBUG
② 可重复的列表参数:
p.add_argument("--tag", action="append", default=[])
mytool --tag a --tag b → ["a", "b"]
★ append 和 nargs="*" 的区别:
append: --tag a --tag b (每次一个,可重复写)
nargs="*":--tag a b (一次给多个)
两者可以组合:action="append", nargs="*"(少见)
③ 版本号:
p.add_argument("--version", action="version", version="%(prog)s 2.1.0")
★ action="append" + default 的经典陷阱:
add_argument("--tag", action="append", default=["base"])
不传 --tag → ["base"] ✓
传 --tag x → ["base", "x"] ★默认值不会被替换,而是被追加在前面★
→ 想要"传了就完全覆盖默认",得 default=None 然后代码里 args.tag or ["base"]
action 回答的是「匹配到这个参数之后怎么存」。默认的 store 存值;布尔开关必须用 store_true(这是 type=bool 无效的正解);count 用于 -v/-vv/-vvv 这类可叠加的日志级别;append 用于「同一个参数重复出现多次」——注意它和 nargs="*" 的区别:append 是 --tag a --tag b(每次给一个、可以重复写),nargs="*" 是 --tag a b(一次给多个)。这里藏着一个非常隐蔽的陷阱:action="append" 配上非空 default 时,用户传入的值是「追加」而不是「覆盖」——default=["base"] 时传 --tag x 得到的是 ["base", "x"],几乎从来不是你想要的语义;正确做法是 default=None,然后在代码里写 tags = args.tag or ["base"]。
四、子命令:git 风格的多功能 CLI
需求:mytool sync --force / mytool show --json / mytool clean
每个子命令有自己的参数集和帮助
写法:
root = argparse.ArgumentParser(prog="mytool")
root.add_argument("--config", help="全局参数放在这一层") # 所有子命令共享
subs = root.add_subparsers(dest="cmd", required=True)
# ↑存到 args.cmd ↑★3.7+ 必须显式写,否则不给子命令时
# args.cmd 是 None 而不是报错★
sync = subs.add_parser("sync", help="同步数据")
sync.add_argument("--force", action="store_true")
sync.set_defaults(func=do_sync) # ★把处理函数绑到这个子命令上★
show = subs.add_parser("show", help="查看状态")
show.add_argument("--json", action="store_true")
show.set_defaults(func=do_show)
args = root.parse_args()
args.func(args) # ★分派:不用写一长串 if/elif★
set_defaults(func=...) 这个模式的价值:
✗ 不用它:if args.cmd == "sync": do_sync(args)
elif args.cmd == "show": do_show(args)
...(每加一个子命令改两处)
✓ 用它: args.func(args) (加子命令只改一处,且不会忘)
共享参数用 parent parser(避免每个子命令重复声明):
common = argparse.ArgumentParser(add_help=False) # ★add_help=False 必须加★
common.add_argument("-v", "--verbose", action="count", default=0)
sync = subs.add_parser("sync", parents=[common])
show = subs.add_parser("show", parents=[common])
→ 两个子命令都自动拥有 -v
★ 全局参数的位置问题(用户最容易困惑的点):
声明在 root 上的 --config 必须写在★子命令之前★:
mytool --config a.yml sync ✓
mytool sync --config a.yml ✗ unrecognized arguments
→ 想两边都支持,就把它同时声明在 parent parser 里让子命令也有一份
子命令(git commit、docker run 这种形态)用 add_subparsers()。三个要点:① dest="cmd" 记录用户选了哪个子命令,且 3.7 起必须显式写 required=True,否则用户什么子命令都不给时不会报错,只是 args.cmd 为 None(然后你在下游炸掉)。② set_defaults(func=处理函数) 是最值得掌握的模式:给每个子命令绑一个处理函数,解析完直接 args.func(args) 分派,避免写一长串 if/elif,加子命令时也只改一处。③ 共享参数用 parents=[common],其中 common 必须用 add_help=False 创建(否则会和子解析器自带的 -h 冲突报错)。最后有个用户常困惑的点:声明在根解析器上的全局参数必须写在子命令之前(mytool --config a.yml sync 对、mytool sync --config a.yml 报错),想两边都支持就把它也放进 parent parser。
五、常见坑与工程写法
坑 1:type=bool 无效(★最高频★)
add_argument("--flag", type=bool)
--flag False → bool("False") == True ← 非空字符串恒为真
✓ 开关用 action="store_true"
✓ 需要显式 true/false 时:
def str2bool(s):
if s.lower() in ("true","1","yes","y"): return True
if s.lower() in ("false","0","no","n"): return False
raise argparse.ArgumentTypeError(f"不是布尔值: {s}")
add_argument("--flag", type=str2bool)
✓ 或 3.9+:add_argument("--cache", action=argparse.BooleanOptionalAction, default=True)
→ 自动同时支持 --cache 和 --no-cache
坑 2:以 - 开头的值被当成选项
mytool --offset -1 ✗ error: expected one argument(-1 被当成选项)
✓ mytool --offset=-1 (等号形式)
✓ mytool -- -1 (-- 之后全部当位置参数/值)
坑 3:前缀缩写造成的兼容性地雷
默认 allow_abbrev=True → --verb 能匹配 --verbose
某天你加了 --version → --ver 变成歧义 → ★用户的老脚本突然报错★
✓ 对外发布的 CLI:ArgumentParser(allow_abbrev=False)
坑 4:可变默认值被共享
add_argument("--tags", nargs="*", default=[]) # 这个 [] 是解析器级别的对象
代码里 args.tags.append(...) → ★污染了 default★(同进程内再次 parse 会带上)
✓ 不要原地修改 args 里的列表,要 list(args.tags) 复制后再改
坑 5:parse_args() 硬编码读 sys.argv → 无法单元测试
✓ def main(argv=None):
args = build_parser().parse_args(argv) # None 时才读 sys.argv
→ 测试里 main(["--out", "x"]) 直接调用,不用 monkeypatch sys.argv
✓ 把 build_parser() 拆成独立函数(测试可以单独构造解析器)
坑 6:把校验全塞进 type
type 只适合"单个值的转换与校验";
★参数之间的关系校验★(--start 必须小于 --end、指定 --csv 时必须给 --sep)
应该在 parse_args() 之后做,用 parser.error("...") 报错
→ parser.error() 会打印 usage 并以退出码 2 退出(和 argparse 自身的错误一致)
坑 7:parse_known_args() 的用途
args, unknown = parser.parse_known_args()
→ 解析已知的,剩下的原样返回(用于"透传参数给下游工具",如 pytest 包装器)
工程写法模板:
def build_parser() -> argparse.ArgumentParser: ... # 只构造,方便测试
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
if args.start >= args.end:
build_parser().error("--start 必须小于 --end")
...
return 0 # ★返回退出码★
if __name__ == "__main__":
sys.exit(main()) # ★让退出码传给 shell★
这七个坑基本覆盖了 argparse 的全部事故面。type=bool 无效是最高频的(布尔开关用 action="store_true",需要显式 true/false 用自定义转换函数或 3.9+ 的 BooleanOptionalAction);以 - 开头的值要用 --offset=-1 或 -- 分隔;前缀缩写默认开启,是对外 CLI 的兼容性地雷,建议 allow_abbrev=False。工程写法上有三条值得固化成模板:把解析器构造拆成 build_parser() 函数、main(argv=None) 把 argv 透传给 parse_args(这样单元测试可以直接 main(["--out","x"]),不必伪造 sys.argv);参数之间的关系校验放在 parse_args() 之后用 parser.error() 报错(它会打印 usage 并以退出码 2 退出,和 argparse 自身的错误行为一致,而 type= 只适合校验单个值);以及 main() 返回退出码、sys.exit(main()) 把它传给 shell——命令行工具的退出码是被脚本和 CI 依赖的契约。
六、argparse vs click vs typer:怎么选
三者对比:
┌──────────┬────────────┬──────────────────┬─────────────────────┐
│ │ argparse │ click │ typer │
├──────────┼────────────┼──────────────────┼─────────────────────┤
│ 依赖 │ ★标准库★ │ 第三方 │ 第三方(基于 click) │
│ 风格 │ 命令式声明 │ 装饰器 │ ★类型注解自动推导★ │
│ 子命令 │ subparsers │ @group 嵌套 │ 函数即命令 │
│ 交互能力 │ 无 │ 提示/确认/进度条 │ 继承 click + 彩色输出 │
│ 学习成本 │ 中 │ 中 │ 低(会写函数就会) │
│ 适合 │ 脚本、无依赖 │ 复杂 CLI 工具 │ 现代项目、注解党 │
└──────────┴────────────┴──────────────────┴─────────────────────┘
typer 的写法(对比一下就知道差别):
@app.command()
def sync(src: Path, out: Path = Path("out.json"), force: bool = False):
...
→ 类型注解直接变成参数类型和校验,bool 自动变成 --force/--no-force
选择建议:
① 内部脚本、不想加依赖、参数不多 → argparse(标准库永远可用)
② 要发布的 CLI 工具、子命令多、要交互 → click / typer
③ 团队已经在用某一个 → 跟着走,别混用
★ 配置来源的优先级(写任何 CLI 都要想清楚):
命令行参数 > 环境变量 > 配置文件 > 内置默认值
实现方式:
p.add_argument("--port", type=int,
default=int(os.getenv("APP_PORT", "8080")))
→ 命令行没给就用环境变量,环境变量没有就用 8080
★ 敏感信息(token、密码)★不要★用命令行参数传★:
ps aux / /proc/<pid>/cmdline / shell history 都能看到
→ 用环境变量、配置文件(权限 600)、或从 stdin 读
选型很简单:内部脚本、不想引依赖就用 argparse(标准库,任何环境都在);要对外发布、子命令多、需要交互提示和彩色输出就上 click 或 typer(typer 基于 click,直接把函数的类型注解变成参数定义和校验,bool 参数自动生成 --force/--no-force,写起来最省事)。除了库的选择,写任何 CLI 都要先想清楚配置来源的优先级:命令行参数 > 环境变量 > 配置文件 > 内置默认值,在 argparse 里用 default=os.getenv(...) 就能实现这个层级。最后是一条安全红线:敏感信息绝不要通过命令行参数传递——ps aux、/proc/<pid>/cmdline 和 shell 历史记录都能看到明文,token 和密码应该走环境变量、权限受限的配置文件或标准输入。
记忆钩子:「argparse 四步:ArgumentParser(描述) → add_argument 逐个声明 → parse_args() → 用 args.xxx。★核心区分:不带横杠=位置参数(必填、按顺序匹配,放『必须知道才能干活的东西』)、带横杠=可选参数(可不填、按名字匹配,放『调节行为的旋钮』);名字里的横杠自动转下划线(—log-level → args.log_level)。★最高频的坑:type=bool 完全无效(bool(‘False’) 恒为 True),布尔开关必须 action=‘store_true’,要 —flag=true/false 就自己写转换函数或用 3.9+ 的 BooleanOptionalAction(自动带 —no-flag)。★其余五坑:以 - 开头的值要写成 —offset=-1 或用 — 分隔;默认开启的前缀缩写(—verb 匹配 —verbose)是对外 CLI 的兼容性地雷,设 allow_abbrev=False;nargs=’*’ 不给 default 时是 None 不是 [];action=‘append’ 配非空 default 会『追加』而非『覆盖』;参数之间的关系校验要放在 parse_args 之后用 parser.error()(type= 只管单个值)。★工程写法固化三条:build_parser() 独立成函数、main(argv=None) 把 argv 透传给 parse_args(这样测试能直接 main([‘—out’,‘x’]))、main 返回退出码并 sys.exit(main())。★子命令用 add_subparsers(dest=‘cmd’, required=True) + 每个子命令 set_defaults(func=处理函数),解析完 args.func(args) 一行分派;共享参数用 parents=[add_help=False 的 parser]。★安全红线:token/密码绝不走命令行参数(ps aux 和 shell history 都看得到)。」
七、常见误区与追问
- 误区:布尔参数写
add_argument("--flag", type=bool)就行。 完全无效。argparse把命令行上的值当字符串交给type转换,而 Python 里任何非空字符串的bool()都是True——所以--flag False得到的是True,--flag 0也是True,只有--flag ""才是False。布尔开关的正确写法是action="store_true"(出现即True、不出现即False,且不需要跟值);如果业务确实需要显式的--flag=true/false(比如配置管理场景要能显式关掉某个默认开启的功能),就自己写一个转换函数把"true"/"1"/"yes"映射为True、"false"/"0"/"no"映射为False、其余抛argparse.ArgumentTypeError;Python 3.9+ 还提供了action=argparse.BooleanOptionalAction,会自动生成--flag和--no-flag一对选项,是最优雅的方案。 - 误区:
--exclude声明成nargs="*"后,不传就会得到空列表。 不传时得到的是None,不是[]——于是下游for x in args.exclude:直接抛TypeError: 'NoneType' object is not iterable,而且这个 bug 只在「用户没传这个参数」时出现,测试很容易漏掉。正确写法是显式给default=[]。相关的另一个坑方向相反:action="append"配上非空default时,用户传入的值是「追加」而不是「覆盖」——default=["base"]时传--tag x得到["base", "x"],几乎从不是想要的语义;要实现「传了就完全覆盖默认」,应该default=None然后在代码里写tags = args.tag or ["base"]。 - 误区:
allow_abbrev默认开启是个贴心功能,不用管。 它是对外发布的 CLI 的兼容性地雷。默认情况下只要不产生歧义,--verb就能匹配--verbose——用户于是在脚本里写了缩写。某天你新增一个--version参数,--ver立刻变成歧义前缀,所有用了缩写的用户脚本在你「只是加了个新参数」之后突然报错,而这在语义化版本里通常被认为是「兼容的小改动」。所以给别人用的命令行工具应该显式设ArgumentParser(allow_abbrev=False),只接受完整参数名和你明确定义的短选项。内部一次性脚本无所谓。 - 误区:把所有校验都写在
type=里。type的职责是「单个值的转换与校验」——转成int、Path、检查是不是正数,这些都合适(在转换函数里抛argparse.ArgumentTypeError会得到漂亮的错误提示)。但参数之间的关系校验它做不到:--start必须小于--end、指定--csv时必须同时给--sep、--output存在时必须加--force才能覆盖——这些要在parse_args()之后做,并且用parser.error("消息")报错,而不是raise ValueError或print + sys.exit(1):parser.error()会打印 usage 提示并以退出码 2 退出,和 argparse 自身的错误行为完全一致,用户和调用脚本看到的表现才统一。 - 误区:命令行工具没法写单元测试,只能手动跑。 能测,关键是不要让
parse_args()硬编码去读sys.argv。标准写法是把解析器构造拆成build_parser()函数,主流程写成def main(argv=None): args = build_parser().parse_args(argv)——parse_args(None)时才回退去读sys.argv,测试里则直接main(["--out", "x", "--dry-run"])调用,完全不必 monkeypatchsys.argv。再配合main()返回退出码(return 0/return 1)而不是直接sys.exit(),测试就能断言返回值;真正的入口写if __name__ == "__main__": sys.exit(main()),把退出码传给 shell。这套写法让 CLI 的参数解析、校验、主流程都可以被正常地单元测试覆盖。 - 追问:
nargs="*"和action="append"有什么区别,各自什么时候用?nargs="*"表示「这个选项一次吃掉后面任意多个值」:--exclude a b c得到["a","b","c"]。action="append"表示「这个选项可以重复出现,每次贡献一个值」:--tag a --tag b得到["a","b"]。选择标准有两条:① 值里可能带-或和位置参数相邻时选append——nargs="*"是贪婪的,mytool --exclude a b src.txt会把本该是位置参数的src.txt一起吃掉,而append每次只吃一个值,不会有歧义;② 写起来的顺手程度:值多且都是简单词用nargs="*"更短,值少但需要明确区分(每个 tag 是独立语义)用append更清晰。工程上更推荐append,因为它对解析歧义免疫;nargs="*"记得配default=[]。 - 追问:子命令为什么要用
set_defaults(func=...)? 因为它把「哪个子命令」和「谁来处理」在声明处就绑定在一起,解析完只需args.func(args)一行分派。不用它的话就得写if args.cmd == "sync": do_sync(args) elif args.cmd == "show": ...这样的链条,每加一个子命令要改两个地方(注册解析器 + 加一条分支),漏改第二处时表现是「命令能解析但什么都不做」,很难察觉。用了set_defaults之后,新增子命令只在一处完成,结构上也更接近「命令即插件」的形态。配套要点是add_subparsers(dest="cmd", required=True)——3.7 起required必须显式写,否则用户不给任何子命令时 argparse 不报错,只是args.cmd为None、args.func属性根本不存在,然后在args.func(args)处抛AttributeError。 - 追问:什么时候该从 argparse 换成 click 或 typer? 三个信号。① 子命令层级变深:argparse 的嵌套子命令(
mytool db migrate up)写起来会很啰嗦,click 的@group嵌套更自然。② 需要交互能力:确认提示(click.confirm)、密码输入(隐藏回显)、进度条、彩色输出、分页显示——这些 argparse 一概没有,自己实现成本不低。③ 参数定义想跟着函数签名走:typer直接把函数的类型注解变成参数类型和校验(force: bool = False自动生成--force/--no-force,Path类型自动校验),代码量能少一半,对已经全面使用类型注解的项目很自然。反过来,保持 argparse 的理由也很硬:它是标准库,在任何 Python 环境里都可用,不给用户增加安装负担——写「一个文件就能跑」的运维脚本、或者被别人pip install的库里附带的小工具时,无依赖是很大的优势。
八、加强记忆
argparse 是标准库的命令行解析器,四步骨架固定:ArgumentParser(description=...) → add_argument(...) 逐个声明 → parse_args() → 用 args.xxx。核心区分是位置参数与可选参数:不带横杠的是位置参数(必填、按顺序匹配,放「命令必须知道才能干活的东西」),带 -/-- 的是可选参数(可不填、按名字匹配,放「调节行为的旋钮」);名字里的横杠自动转下划线(--log-level → args.log_level),同时给短长选项时取长选项名,dest= 可显式覆盖。最高频的坑是 type=bool 完全无效(bool("False") 恒为 True)——布尔开关必须 action="store_true",需要显式 true/false 就自己写转换函数或用 3.9+ 的 BooleanOptionalAction(自动附赠 --no-flag)。其余五个坑:以 - 开头的值要写 --offset=-1 或用 -- 分隔;默认开启的前缀缩写(--verb 匹配 --verbose)是对外 CLI 的兼容性地雷,应设 allow_abbrev=False;nargs="*" 不给 default 时是 None 不是 [];action="append" 配非空 default 会「追加」而非「覆盖」;参数间的关系校验要放在 parse_args() 之后用 parser.error()(会打印 usage 并以退出码 2 退出),type= 只管单个值。工程写法固化三条:build_parser() 独立成函数、main(argv=None) 把 argv 透传给 parse_args(这样测试能直接 main(["--out","x"]),不必伪造 sys.argv)、main() 返回退出码并 sys.exit(main())。子命令用 add_subparsers(dest="cmd", required=True)(3.7 起 required 必须显式写)配合每个子命令 set_defaults(func=处理函数),解析完 args.func(args) 一行分派;共享参数用 parents=[用 add_help=False 创建的 parser]。选型:无依赖的脚本用 argparse,子命令多/要交互/想让类型注解自动生成参数就上 click 或 typer。安全红线:token、密码绝不通过命令行参数传递(ps aux、/proc/<pid>/cmdline、shell 历史都能看到明文)。