您如何根据单个选项在 Python 中的单击 CLI 中定义控制流并将其余部分传递给另一个命令?

How do you define control flow in a click CLI in Python based on a single option and pass the rest to another command?

这个问题是关于 Python click 包的,并且与基于传递给 CLI 的参数的控制流有关。

我正在尝试构建一个主 CLI 以位于我的存储库的顶级目录中,它将从一个中央位置控制许多不同的操作模式。当您调用此命令时,它可能需要 n 个选项,其中第一个选项将确定要调用哪个模块,然后将 n-1 args 传递给另一个模块。但我希望每个模块的命令和选项都在其各自的模块中定义,而不是在主 CLI 控制器中定义,因为我试图保持主控制器简单并且每个模块都很好地抽象出来。

这是我想要的一个简单示例:

import click


@click.command()
@click.option('--foo-arg', default='asdf')
def foo(foo_arg):
    click.echo(f"Hello from foo with arg {foo_arg}")


@click.command()
@click.option('--bar-arg', default='zxcv')
def bar(bar_arg):
    click.echo(f"Hello from bar with arg {bar_arg}")


@click.command()
@click.option('--mode', type=click.Choice(["foo", "bar"]))
def cli(mode):
    if mode == "foo":
        foo()

    if mode == "bar":
        bar()


if __name__ == '__main__':
    cli()

在这个例子中,foo()bar() 应该被认为是隐藏在 repo 中的模块,它们可能还需要大量的 CLI 选项,我不希望这样使主 cli.py 超载。出于上下文原因,我觉得 foo() 的 CLI 选项应该位于 foo 中。以下是我希望它如何工作。

示例 1:

python -m cli --mode foo --foo-arg asdf

应该生产

Hello from foo with arg asdf

示例 2:

python -m cli --mode bar --bar-arg zxcv

应该生产

Hello from bar with arg zxcv

示例 3:

python -m cli --mode foo --bar-arg qwer

应该会失败,因为 foo() 没有 --bar-arg 选项。

免责声明:我知道我可以将 foobar 注册为单独的命令(通过 python -m cli foo --foo-arg asd 调用,即使用 foo 而不是 --foo ).但是,由于超出此问题范围的原因,我需要 foobar--mode 选项标识符指定。这是与我的应用程序交互的工具的限制,不幸的是,这是我无法控制的。

有没有一种方法可以解析 args 并基于 args 的子集使控制流成为可能,然后将其余的传递给后续模块,同时不将每个模块的选项定义为 def cli() 上的装饰器?

您可以使用回调进行验证:https://click.palletsprojects.com/en/7.x/options/#callbacks-for-validation

此外,使用多链接命令,因此每个参数都将传递给它需要的命令。

https://click.palletsprojects.com/en/7.x/commands/#multi-command-chaining

使用选项调用子命令可以通过使用 click.MultiCommand 和自定义 parse_args() 方法来实现,例如:

自定义Class

def mode_opts_cmds(mode_opt_name, namespace=None):

    class RemoteCommandsAsModeOpts(click.MultiCommand):

        def __init__(self, *args, **kwargs):
            super(RemoteCommandsAsModeOpts, self).__init__(*args, **kwargs)
            self.mode_opt_name = '--{}'.format(mode_opt_name)
            opt = next(p for p in self.params if p.name == mode_opt_name)
            assert isinstance(opt.type, click.Choice)
            choices = set(opt.type.choices)
            self.commands = {k: v for k, v in (
                namespace or globals()).items() if k in choices}
            for command in self.commands.values():
                assert isinstance(command, click.Command)

        def parse_args(self, ctx, args):
            try:
                args.remove(self.mode_opt_name)
            except ValueError:
                pass
            super(RemoteCommandsAsModeOpts, self).parse_args(ctx, args)

        def list_commands(self, ctx):
            return sorted(self.commands)

        def get_command(self, ctx, name):
            return self.commands[name]

    return RemoteCommandsAsModeOpts

使用自定义 Class

要使用自定义 class,请调用 mode_opts_cmds() 函数创建自定义 class,然后使用 cls 参数传递 class 到 click.group() 装饰器。

@click.group(cls=mode_opts_cmds('mode'))
@click.option('--mode', type=click.Choice(["foo", "bar"]))
def cli(mode):
    """My wonderful cli"""

这是如何工作的?

之所以可行,是因为 click 是一个设计良好的 OO 框架。 @click.group() 装饰器通常实例化一个 click.Group 对象,但允许使用 cls 参数覆盖此行为。因此,在我们自己的 class 中继承 click.Group 并覆盖所需的方法是一件相对容易的事情。

在这种情况下,我们超越了 click.Group.parse_args(),这样当解析命令行时,我们只需删除 --mode,然后命令行就像 normal[=50] 一样解析=] 子命令。

使它成为一个库函数

如果自定义 class 将驻留在库中,则需要传入命名空间而不是使用库文件中的默认值 globals()。自定义 class creator 方法然后需要用类似的东西调用:

@click.group(cls=mode_opts_cmds('mode', namespace=globals()))

测试代码

import click

@click.command()
@click.option('--foo-arg', default='asdf')
def foo(foo_arg):
    """the foo command is ok"""
    click.echo(f"Hello from foo with arg {foo_arg}")


@click.command()
@click.option('--bar-arg', default='zxcv')
def bar(bar_arg):
    """bar is my favorite"""
    click.echo(f"Hello from bar with arg {bar_arg}")


@click.group(cls=mode_opts_cmds('mode'))
@click.option('--mode', type=click.Choice(["foo", "bar"]))
def cli(mode):
    """My wonderful cli command"""


if __name__ == "__main__":
    commands = (
        '--mode foo --foo-arg asdf',
        '--mode bar --bar-arg zxcv',
        '--mode foo --bar-arg qwer',
        '--mode foo --help',
        '',
    )

    import sys, time

    time.sleep(1)
    print('Click Version: {}'.format(click.__version__))
    print('Python Version: {}'.format(sys.version))
    for command in commands:
        try:
            time.sleep(0.1)
            print('-----------')
            print('> ' + command)
            time.sleep(0.1)
            cli(command.split())

        except BaseException as exc:
            if str(exc) != '0' and \
                    not isinstance(exc,
                                   (click.ClickException, SystemExit)):
                raise

测试结果

Click Version: 7.0
Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
-----------
> --mode foo --foo-arg asdf
Hello from foo with arg asdf
-----------
> --mode bar --bar-arg zxcv
Hello from bar with arg zxcv
-----------
> --mode foo --bar-arg qwer
Usage: test.py foo [OPTIONS]
Try "test.py foo --help" for help.

Error: no such option: --bar-arg
-----------
> --mode foo --help
Usage: test.py foo [OPTIONS]

  the foo command is ok

Options:
  --foo-arg TEXT
  --help          Show this message and exit.
-----------
> 
Usage: test.py [OPTIONS] COMMAND [ARGS]...

  My wonderful cli command

Options:
  --mode [foo|bar]
  --help            Show this message and exit.

Commands:
  bar  bar is my favorite
  foo  the foo command is ok