3. 全局选项与局部选项分离
为什么需要分离全局选项与局部选项
在子命令路由已经建立之后,CLI 会同时面对两类选项:一类属于整个程序,例如 --verbose、--config;另一类只属于某个子命令,例如 deploy --dry-run、logs --follow。如果把它们混在同一个解析器里,就会出现两个典型问题:
- 冲突:全局的
--verbose与某个子命令的--verbose含义不同,解析器无法判断该用哪一个。 - 越界:
deploy --config a.json中的--config被误当成 deploy 的局部选项,导致全局配置失效。
解决思路是:先切分,再分别解析。在进入子命令之前,先把全局选项从参数列表中剥离出来;剩余参数交给子命令自己的解析器。这样两套选项在解析阶段互不干扰。
参数切分规则
我们约定一个明确的切分边界:第一个非选项参数(即子命令名)之前的部分属于全局区,之后的部分属于局部区。全局区只允许出现全局选项及其值;局部区只允许出现该子命令的局部选项和位置参数。
| 输入 | 全局区 | 子命令 | 局部区 |
|---|---|---|---|
--verbose deploy --dry-run | --verbose | deploy | --dry-run |
--config a.json logs --follow | --config a.json | logs | --follow |
deploy --config a.json | (空) | deploy | --config a.json → 报错 |
最后一行是关键约束:--config 是全局选项,出现在子命令之后应被拒绝,而不是被 deploy 悄悄接受。这正是“隔离”要保证的行为。
实现:先切分,再分别解析
下面用 Python 标准库 argparse 实现一个可运行的最小示例。核心是 split_argv 函数:它扫描参数列表,找到第一个不以 - 开头的 token 作为子命令位置,把之前的参数归为全局区,之后的归为局部区。
import argparse
import sys
GLOBAL_OPTIONS = {"--verbose", "--config"}
def split_argv(argv):
"""返回 (global_args, subcommand, local_args)。"""
for i, token in enumerate(argv):
if not token.startswith("-"):
return argv[:i], token, argv[i + 1:]
return argv, None, []
def build_global_parser():
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--verbose", action="store_true")
p.add_argument("--config", default=None)
return p
def build_deploy_parser():
p = argparse.ArgumentParser(prog="deploy", add_help=False)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--replicas", type=int, default=1)
return p
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
global_args, subcommand, local_args = split_argv(argv)
gp = build_global_parser()
gns, unknown = gp.parse_known_args(global_args)
if unknown:
print(f"全局区存在未知选项: {unknown}", file=sys.stderr)
return 2
if subcommand is None:
print("请指定子命令,例如 deploy", file=sys.stderr)
return 2
if subcommand == "deploy":
dp = build_deploy_parser()
dns, unknown = dp.parse_known_args(local_args)
if unknown:
print(f"deploy 子命令存在未知选项: {unknown}", file=sys.stderr)
return 2
print(f"verbose={gns.verbose} config={gns.config} "
f"dry_run={dns.dry_run} replicas={dns.replicas}")
return 0
print(f"未知子命令: {subcommand}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
验证隔离效果
用四组输入验证切分与拒绝逻辑是否符合预期:
| 命令 | 预期输出 / 行为 |
|---|---|
--verbose deploy --dry-run --replicas 3 | verbose=True config=None dry_run=True replicas=3 |
--config a.json deploy --replicas 2 | verbose=False config=a.json dry_run=False replicas=2 |
deploy --config a.json | 报错:deploy 子命令存在未知选项: ['--config', 'a.json'],退出码 2 |
--verbose --dry-run deploy | 报错:全局区存在未知选项: ['--dry-run'],退出码 2 |
关键点:切分边界是“第一个非选项参数”,而不是“第一个已知子命令”。这样即使输入了未知子命令,也能先完成全局解析,再给出“未知子命令”的友好报错,而不是把全局选项误判为局部选项。
常见陷阱:如果使用
parse_known_args 后不检查 unknown,全局解析器会默默忽略 --dry-run,导致它被错误地当作全局选项吞掉。必须显式检查 unknown 并报错,隔离才真正生效。
可复用的检查清单
- 全局选项集合是否显式定义,且与子命令局部选项无交集?
- 切分函数是否以“第一个非选项参数”为边界,而非硬编码子命令名?
- 全局解析与局部解析是否都检查了
unknown并返回非零退出码? - 全局选项出现在子命令之后时,是否被局部解析器拒绝?
- 局部选项出现在子命令之前时,是否被全局解析器拒绝?