← 返回题目列表

Python 怎么写命令行程序?argparse 有哪些坑?

简单 第 22 / 27 题 更新于 2026/07/31
argparse命令行CLI标准库

简化版

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-levelargs.log_level);③ 负数或以 - 开头的值容易被当成选项,要用 --out=-1-- 分隔;④ 默认允许前缀缩写--verb 能匹配 --verbose),加新参数时可能造成歧义,严谨的 CLI 应设 allow_abbrev=False。子命令(git commit 风格)用 add_subparsers()。核心记忆:位置参数必填、可选参数带横杠、布尔用 store_true、子命令用 subparsers。

详细版

add_argument 的核心关键字

关键字作用例子
type转换 + 校验(可传任意可调用对象)type=inttype=Pathtype=自定义函数
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-levelargs.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 commitdocker run 这种形态)用 add_subparsers()。三个要点:dest="cmd" 记录用户选了哪个子命令,且 3.7 起必须显式写 required=True,否则用户什么子命令都不给时不会报错,只是 args.cmdNone(然后你在下游炸掉)。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(标准库,任何环境都在);要对外发布、子命令多、需要交互提示和彩色输出就上 clicktypertyper 基于 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 的职责是「单个值的转换与校验」——转成 intPath、检查是不是正数,这些都合适(在转换函数里抛 argparse.ArgumentTypeError 会得到漂亮的错误提示)。但参数之间的关系校验它做不到:--start 必须小于 --end、指定 --csv 时必须同时给 --sep--output 存在时必须加 --force 才能覆盖——这些要在 parse_args() 之后做,并且用 parser.error("消息") 报错,而不是 raise ValueErrorprint + 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"]) 调用,完全不必 monkeypatch sys.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.cmdNoneargs.func 属性根本不存在,然后在 args.func(args) 处抛 AttributeError
  • 追问:什么时候该从 argparse 换成 click 或 typer? 三个信号。① 子命令层级变深:argparse 的嵌套子命令(mytool db migrate up)写起来会很啰嗦,click 的 @group 嵌套更自然。② 需要交互能力:确认提示(click.confirm)、密码输入(隐藏回显)、进度条、彩色输出、分页显示——这些 argparse 一概没有,自己实现成本不低。③ 参数定义想跟着函数签名走typer 直接把函数的类型注解变成参数类型和校验(force: bool = False 自动生成 --force/--no-forcePath 类型自动校验),代码量能少一半,对已经全面使用类型注解的项目很自然。反过来,保持 argparse 的理由也很硬:它是标准库,在任何 Python 环境里都可用,不给用户增加安装负担——写「一个文件就能跑」的运维脚本、或者被别人 pip install 的库里附带的小工具时,无依赖是很大的优势。

八、加强记忆

argparse 是标准库的命令行解析器,四步骨架固定ArgumentParser(description=...)add_argument(...) 逐个声明 → parse_args() → 用 args.xxx核心区分是位置参数与可选参数:不带横杠的是位置参数(必填、按顺序匹配,放「命令必须知道才能干活的东西」),带 -/-- 的是可选参数(可不填、按名字匹配,放「调节行为的旋钮」);名字里的横杠自动转下划线--log-levelargs.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=Falsenargs="*" 不给 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 历史都能看到明文)。