3. 全局选项与局部选项分离

全局选项与局部选项分离 学习卡片

为什么需要分离全局选项与局部选项

在子命令路由已经建立之后,CLI 会同时面对两类选项:一类属于整个程序,例如 --verbose、--config;另一类只属于某个子命令,例如 deploy --dry-run、logs --follow。如果把它们混在同一个解析器里,就会出现两个典型问题:

  • 冲突:全局的 --verbose 与某个子命令的 --verbose 含义不同,解析器无法判断该用哪一个。
  • 越界:deploy --config a.json 中的 --config 被误当成 deploy 的局部选项,导致全局配置失效。

解决思路是:先切分,再分别解析。在进入子命令之前,先把全局选项从参数列表中剥离出来;剩余参数交给子命令自己的解析器。这样两套选项在解析阶段互不干扰。

参数切分规则

我们约定一个明确的切分边界:第一个非选项参数(即子命令名)之前的部分属于全局区,之后的部分属于局部区。全局区只允许出现全局选项及其值;局部区只允许出现该子命令的局部选项和位置参数。

输入全局区子命令局部区
--verbose deploy --dry-run--verbosedeploy--dry-run
--config a.json logs --follow--config a.jsonlogs--follow
deploy --config a.json(空)deploy--config a.json → 报错

最后一行是关键约束:--config 是全局选项,出现在子命令之后应被拒绝,而不是被 deploy 悄悄接受。这正是“隔离”要保证的行为。

全局选项与局部选项的切分与解析隔离 原始 argv --verbose --config a.json deploy --dry-run --replicas 3 按第一个非选项参数切分 全局区(子命令之前) --verbose --config a.json → 由全局解析器处理 局部区(子命令之后) deploy --dry-run --replicas 3 → 由 deploy 子解析器处理 全局解析器 只认识 --verbose / --config 遇到未知全局选项 → 报错退出 deploy 子解析器 只认识 --dry-run / --replicas 遇到 --config → 报错“未知选项” 冲突防护:全局选项出现在子命令之后 → 子解析器拒绝;局部选项出现在子命令之前 → 全局解析器拒绝

实现:先切分,再分别解析

下面用 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 3verbose=True config=None dry_run=True replicas=3
--config a.json deploy --replicas 2verbose=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 并返回非零退出码?
  • 全局选项出现在子命令之后时,是否被局部解析器拒绝?
  • 局部选项出现在子命令之前时,是否被全局解析器拒绝?