Python 里的 argparse 大家都不陌生,是用来解析命令行参数的标准库,它的用法大致是这样:

import argparse

parser = argparse.ArgumentParser(description='Greet to some body')
parser.add_argument(
    '-n', '--name', default='John Doe', help='name of the person to greet')

args = parser.parse_args()
print(f'Hello, {args.name}!')

这非常地平凡,如果用 click 的话,可以更快地写出来相同的功能:

import click

@click.command()
@click.option("-n", "--name", default="John Doe", help="name of the person to greet")
def cli(name):
    print(f'Hello, {name}!')

cli()

两者的区别在于 argparse 是统一解析得到参数值再自己处理,而 click 可以直接把参数值传给装饰的函数。后者的方式更有利于代码解耦,更容易维护。

我在做 PDM 的时候最初也是选择的click,PDM 的命令行有一系列的子命令,而 click 的嵌套命令组(click.Group)也提供了强大的支持,帮助我很好地完成了这个工作。 然而当我更深入地写下去,试图加一些更复杂的功能时,我发现了 click 的不足之处,并促使我最终选择了 argparse,到目前看来 argparse 提供的能力能很好地胜任工作。

继承和扩展

假如我们已经用 click 写了下面这样的命令行界面:

# bot.py
import click

@click.group()
def cli():
    pass

@cli.command()
@click.option("-n", "--name", default="John Doe", help="name of the person to greet")
def greet(name):
    print(f'Hello, {args.name}!')

@cli.command()
@click.option("-n", "--name", default="John Doe", help="name of the person to say goodbye")
def goodbye(name):
    print(f'Goodbye, {args.name}!')

这个命令行包含两个子命令 greetgoodbye, 现在我把这个 bot 库发布了,并希望用户能在这个基础上添加新的命令,用 click,这很容易:

# test.py
from bot import cli

@cli.command()
def test():
    print('test')

cli()

现在这个命令行就新增了一个 test 子命令了。这就是 Flask CLI 的扩展方法。 但是我想在这个基础上,还想提供新增命令选项的功能,比如在原来的 greet 命令上加一个 --verbose 选项,如果为真就啰嗦地问好,否则简洁地问好。这如何做到呢? 这就涉及到给原来 greet 函数加一个参数,并改变函数的行为读取这个参数。查看 API 文档得知这个函数保存在生成的 Command 对象的 callback 属性上,那我只能写 一个新函数,替换掉它,那么如果我不想把原函数抄一遍,只想继承和扩展,那就只能把原函数保留好,并在新函数里调用它。

这整个流程,在我看来,无异于 Monkey patch,在一个支持 OOP 的语言里,本不应该如此,于是我就开始寻找其他的替代方案。 当然,最后我找到了 argparse,下面说说我是怎么用 argparse 实现 PDM 的命令行界面的。

argparse 的进击

argparse 的子命令

argparse 也是支持子命令的,而且子命令也可有自己的子命令。

parser = argparse.ArgumentParser()

subparsers = parser.add_subparsers()
greet_parser = subparsers.add_parser('greet')
greet_parser.add_argument('-n', '--name', default='John Doe', help='name of the person to greet')
goodbye_parser = subparsers.add_parser('goodbye')
goodbye_parser.add_argument('-n', '--name', default='John Doe', help='name of the person to say goodbye')
args = parser.parse_args()
...

这看上去比 click 费劲多了,而且还只是拿到解析结果,没有处理,但这个缺点也让 argparse 更加灵活,我们可以控制它如何找到对应的处理方法。 继承和扩展,这不就是 OOP 的思想吗?那么我是不是可以把这个面条型的代码改成 OOP 的呢?

argparse 的 OOP 化

原则是把每个一个子命令放到它自己的类里面,我把上面的这个代码分离一下:

# 根命令相关
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
# 子命令greet相关
greet_parser = subparsers.add_parser('greet')
greet_parser.add_argument('-n', '--name', default='John Doe', help='name of the person to greet')
# 子命令goodbye相关
goodbye_parser = subparsers.add_parser('goodbye')
goodbye_parser.add_argument('-n', '--name', default='John Doe', help='name of the person to say goodbye')
# 根命令相关
args = parser.parse_args()

可以看到中间两个子命令的写法高度一致,只有一个操作,就是 add_argument,那么我把这个方法放到要实现的子命令类里面,并利用一些 IoC 的技巧,得到:

class Command:
    """基类"""
    def add_arguments(self, parser):
        pass  # 可以不实现,即不包含任何参数

class GreetCommand(Command):
    """greet 命令实现"""
    def add_arguments(self, parser):
        parser.add_argument('-n', '--name', default='John Doe', help='name of the person to greet')

下面是根解析器中的挂载方法:

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
for name, command in subcommands.items():  # type: Dict[str, Type[Command]]
    cmd_instance = command()
    subparser = subparsers.add_parser(name)
    # subparser 是一个和 parser 一样的解析器对象
    cmd_instance.add_arguments(subparser)

这里我实例化了 command,而没有直接用 classmethod,是方便在实例化的时候传入一些根解析器相关的信息。 这样我就实现了命令解析的解耦,与子命令有关的参数在自己的类中的 add_argument 添加就可以了。

处理方法的路由

现在我们只是实现了子命令的参数添加,但还需要针对不同的子命令选择不同的处理方法。暂时还不知道怎样做,不管,先把这个方法放到 Command 类里面:

class Command:
    """基类"""
    ...
    def handle(self, args):
        pass  # 可以不实现,即不做任何处理

class GreetCommand(Command):
    ...
    def handle(self, args):
        print(f'Hello {args.name}!')

怎样在解析到这个子命令的时候路由到这个子命令的处理方法呢?这得了解 argparse 的解析过程。 argparse 是拿到 sys.argv 之后按顺序看,如果找到一个参数就把结果中对应这个参数的值赋好,如果找到一个子命令的名称则取得这个子命令的解析器 递归调用这个解析器去解析剩下的命令行参数。也就是说如果没有匹配到这个子命令是不会执行任何该子命令的相关动作,也不会把这个子命令的参数加入到解析器中。 而相同层级的子命令必然是互斥的,不可能存在同时匹配到多个子命令的情况。比如 python cli.py greet goodbye 匹配到的是 greet 命令,而 goodbye 会被当作 greet 的参数在 greet 自己的解析器中解析。

那么我们可以在匹配到这个子命令时,把它的处理方法保存到解析结果里,就可以了。只需要稍微修改一下子命令的挂载过程:

for name, command in subcommands.items():
    cmd_instance = command()
    subparser = subparsers.add_parser(cmd_instance.name)
    subparser.set_defaults(handle=cmd_instance.handle)
    cmd_instance.add_arguments(subparser)

这里通过 set_defaults 把 handle 的值设置为 cmd_instance.handle,它的作用是如果在解析完以后结果中没有 handle,则它的值为 cmd_instance.handle。 并且这个行为只有在解析到这个子命令的时候才会生效,因为它是作用在 subparser 上的。

那么最后的处理逻辑就非常自然了:

args = parser.parse_args()
if hasatter(args, 'handle'):
    args.handle(args)

参数复用

有了 OOP 的利器,我就可以来减少一些重复代码了。注意到 greetgoodbye 都有一个 -n/--name 参数,类型是一样的。添加参数是在 add_argument 里做的, 再次 IoC 一下:

class Argument:
    def __init__(self, *args, **kwargs):
        self.args = args
        self.kwargs = kwargs

    def add_to_parser(self, parser):
        parser.add_argument(*self.args, **self.kwargs)

name_option = Argument("-n", "--name", help="name of the person", default="John Doe")

再进一步,在 Command 类中添加类属性 arguments

class Command:
    arguments = [name_option]
    def add_arguments(self, parser):
        for arg in self.arguments:
            arg.add_to_parser(parser)
        self.subcommand_add_arguments(parser)

    def subcommand_add_arguments(self, parser):
        # 原来的add_arguments改名为此函数
        pass

升级后的 argparse 用法

现在回到我开始的需求,继承与扩展,如果我要新增一个子命令,只需要继承基类 Command,实现 subcommands_add_argumentshandle 方法, 再添加到 subcommands 中就可以了(添加的方法会暴露出来)。

而若要在已有的命令上做修改,只需要继承原有的命令类:

class MyGreetCommand(GreetCommand):
    def subcommand_add_arguments(self, parser):
        super().subcommand_add_arguments(parser)
        parser.add_argument("-t", "--test", action="store_true", help="run under test environment")

    def handle(self, args):
        if args.test:
            print("I'm under testing, no time to greet")
        else:
            super().handle(args)

挂载的时候还用原来的命令名 greet 就可以覆盖原有的命令了。

结语

我们利用了 Python 的动态特性,加上合理的技巧(IoC)实现了 argparse 的 OOP 化。 PDM 就是使用了这个方法实现了可扩展的命令行解析,完整的命令类在 pdm/cli/commands,命令解析的组装过程在 pdm/core.py 可以看到。其实 pipDjango 的命令行写法 也类似,只是实现不尽相同。另外,在理解了 argparse 的工作方式之后, 我也得以向 CPython 提交了我的第一个贡献