Python Click - 即使在组命令中引发异常,也要显示子命令的帮助信息

3
我正在编写一个命令行脚本mycli,其中包括两个子命令:
  • mkcli init(用于初始化空项目并生成 .configrc文件)
  • mkcli run(运行脚本的主要逻辑)。
通常情况下,如果在工作目录中找不到.configrc文件,则mycli run将无法工作。但是,我的用户应该能够查看run帮助信息:
$ mycli run --help
Usage: mycli run [OPTIONS]

Options:
  --dryrun  Run in read-only mode
  --help    Show this message and exit.

然而,如果不存在.configrc文件,则无法工作,因为在组命令cli中引发FileNotFoundError(并且永远不会到达run)。我可以使用ctx.invoked_subcommand(见下文)在找到.configrc文件之前启动init子命令,但我看不到确保始终启动run子命令的方法,即使它使用--help调用。
如果用户运行mkcli run并且找不到.configrc文件,则我的脚本将退出,并显示run "mycli init" first。但是,即使没有.configrcmycli run --help也应该正常工作。我该如何做到这一点?或者有人能建议更好的处理init的方法吗?
@click.group()
@click.pass_context
def cli(ctx):

    ctx.obj = {}
    if ctx.invoked_subcommand != "init":
        config = yaml.load(open(".configrc").read())
        ctx.obj.update({key: config[key] for key in config})

@cli.command()
@click.pass_context
def init(ctx):
    print("Initialize project.")

@cli.command()
@click.option("--dryrun", type=bool, is_flag=True, help="Run in read-only mode")
@click.pass_context
def run(ctx, dryrun):
    print("Run main program here.")
1个回答

2
我建议更改您的初始化代码运行顺序。可以通过以下方式完成...

自定义类:

class LoadInitForCommands(click.Group):

    def command(self, *args, **kwargs):

        def decorator(f):
            # call the original decorator
            cmd = click.command(*args, **kwargs)(f)
            self.add_command(cmd)
            orig_invoke = cmd.invoke

            def invoke(ctx):
                # Custom init code is here
                ctx.obj = {}
                if cmd.name != "init":
                    config = yaml.load(open(".configrc").read())
                    ctx.obj.update({key: config[key] for key in config})

                # call the original invoke()
                return orig_invoke(ctx)

            # hook the command's invoke
            cmd.invoke = invoke
            return cmd

        return decorator

使用自定义类:

使用cls参数将自定义类传递给click.group(),例如:

@click.group(cls=LoadInitForCommands)
def cli():
    """"""

这是如何工作的?

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

在这种情况下,我们挂钩command()装饰器,并在该挂钩中重写命令的invoke()。这允许在已经处理完--help标志后读取init文件。

请注意,此代码旨在使许多命令易于使用,在读取init之前即可使用--help。 在问题的示例中,只有一个需要初始化的命令。 如果始终如此,则此答案可能会更好。

测试代码:

import click
import yaml

@click.group(cls=LoadInitForCommands)
def cli():
    """"""

@cli.command()
@click.pass_context
def init(ctx):
    print("Initialize project.")


@cli.command()
@click.option("--dryrun", type=bool, is_flag=True,
              help="Run in read-only mode")
@click.pass_context
def run(ctx, dryrun):
    print("Run main program here.")


if __name__ == "__main__":
    commands = (
        'init',
        'run --help',
        'run',
        '--help',
    )

    import sys, time

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

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

结果:

Click Version: 6.7
Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
-----------
> init
Initialize project.
-----------
> run --help
Usage: test.py run [OPTIONS]

Options:
  --dryrun  Run in read-only mode
  --help    Show this message and exit.
-----------
> run
Traceback (most recent call last):
  File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1741, in <module>
    main()
  File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1735, in main
    globals = debugger.run(setup['file'], None, None, is_module)
  File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\pydevd.py", line 1135, in run
    pydev_imports.execfile(file, globals, locals)  # execute the script
  File "C:\Users\stephen\AppData\Local\JetBrains\PyCharm 2018.3\helpers\pydev\_pydev_imps\_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "C:/Users/stephen/Documents/src/testcode/test.py", line 77, in <module>
    cli(cmd.split(), obj={})
  File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 697, in main
    rv = self.invoke(ctx)
  File "C:\Users\stephen\AppData\Local\Programs\Python\Python36\lib\site-packages\click\core.py", line 1066, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "C:/Users/stephen/Documents/src/testcode/test.py", line 26, in invoke
    config = yaml.load(open(".configrc").read())
FileNotFoundError: [Errno 2] No such file or directory: '.configrc'

哇 - 这太棒了,谢谢!我尝试添加一个选项到 init (init --with-examples),这也可以正常工作(应该是这样)。但我不明白为什么它能工作。decorator(f) 是如何被调用的,f 又从哪里来的? - Tom Baker
如果你没有经常使用装饰器,它们可能会让人感到困惑。当你执行@cli.command()时,会调用decorator(f)。简而言之:cli.command()返回decorator,然后@调用decorator(f),其中f是被装饰的函数。这个链接可能会有所帮助:https://www.learnpython.org/en/Decorators - Stephen Rauch
源代码(https://github.com/pallets/click/blob/master/click/core.py#L1227)几乎让这一点清晰明了。我发现两件事令人困惑:1)“装饰器”函数的任意名称(“foobar”同样有效),2)`click.command(*args, **kwargs)(f)的语法 - 我从未见过带有两组括号的函数调用(并且仍然不确定它是如何解析的)。我是否正确理解每次运行Group.command()`时,“装饰器”函数都会将被装饰的函数作为参数“f”传递?感谢您将答案与测试打包 - 非常有帮助! - Tom Baker
  1. 正如您所指出的,名称是任意的,并且只持续足够长的时间来返回函数,那么为什么不称其为实际名称呢?如果Python具有像其他一些语言一样更丰富的lambda思维方式,那么在定义时可能可以简单地返回一个匿名函数。
  2. 您的理解是正确的。
  3. 测试用例的原因是因为我已经回答了相当多的click问题,正如您所指出的,仅通过阅读代码很难理解。能够运行与我相同的确切代码可以帮助将示例转换为现实。
- Stephen Rauch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接