Python 单击:在具有上下文资源的链式 MultiCommand 中处理 cli 使用异常

Python Click: handling cli usage Exceptions in a chained MultiCommand with context resource

背景

Click 文档中的以下示例(具体来说 custom multi commands, multi command pipelines and managing resources) I've written a CLI application similar to the Image Pipeline example 仅它通过 FBX SDK 而不是图像在 3D 网格场景上运行。

抛开那个特定的细节,我很难理解 Click 对命令行的解析是如何发生的以及何时发生的。我的问题的 TLDR 版本是命令行使用错误似乎只在上下文退出后出现,重要的是,在关闭任何上下文管理的资源后。理想情况下,我想在进入上下文管理器之前确定命令行是否有效,或者至少能够在管理器退出之前对使用错误做出反应。

最小示例如下:

示例代码

import click
import json

@click.command
@click.option("-t", "value", type=float)
@click.pass_context
def translate(ctx, value):
    click.echo(f"modifying: 'translate' by {value}")
    ctx.obj.data["translate"] += value
    yield ctx

@click.command
@click.option("-r", "value", type=float)
@click.pass_context
def rotate(ctx, value):
    click.echo(f"modifying: 'rotate' by {value}")
    ctx.obj.data["rotate"] += value
    yield ctx

@click.command
@click.option("-s", "value", type=float)
@click.pass_context
def scale(ctx, value):
    click.echo(f"modifying: 'scale' by {value}")
    ctx.obj.data["scale"] += value
    yield ctx

@click.command
@click.pass_context
def report(ctx):
    click.echo(f"object data: {ctx.obj.data}")
    click.echo(f"object in: {ctx.obj.infile}")
    click.echo(f"object out: {ctx.obj.outfile}")
    yield ctx

class EditModelCLI(click.MultiCommand):
    def list_commands(self, ctx):
        return ["translate", "rotate", "scale", "report"]

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

class FileResource:
    def __init__(self, infile, outfile=None):
        self.infile = infile
        self.outfile = outfile
        self._dict = {}

    @property
    def data(self):
        return self._dict

    def __enter__(self):
        if self.infile:
            with open(self.infile) as f:
                click.echo(f"loading {self.infile}")
                self._dict = json.load(f)
                return self

    def __exit__(self, exc_type, exc_value, tb):
        if self.outfile:
            with open(self.outfile, "w") as f:
                click.echo(f"saving {self.outfile}")
                json.dump(self._dict, f)

@click.option("-i", "--input", type=click.Path(exists=True, dir_okay=False))
@click.option("-o", "--output", type=click.Path(), default=None)
@click.group(cls=EditModelCLI, chain=True, no_args_is_help=True)
@click.pass_context
def main(ctx, input, output):
    click.echo("starting process")
    ctx.obj = ctx.with_resource(FileResource(input, output))

@main.result_callback()
def process(subcommands, **kwargs):
    for cmd in subcommands:
        result = next(cmd)
        click.echo(f"processed ... {result.info_name}")

if __name__ == "__main__":
    click.echo("called from commandline")
    main()

测试

所以这给了我们一个 cli,我们可以在其中调用:
> python -m click_test.py -i model_in.json -o model_out.json translate -t 1.0 scale -s 0.5

读入 - model_in.json

{"translate": 1.0, "rotate": 1.0, "scale": 1.0}

并写出 - model_out.json

{"translate": 2.0, "rotate": 1.0, "scale": 1.5}

到目前为止按设计工作,如果 model_out.json 已经存在,则创建或修改它。

然而,任何命令行语法错误(在子命令级别)仍会导致“输出”文件被写出,托管资源仍会打开和关闭。所以调用:
> python -m click_test.py -i model_in.json -o model_out.json translate -t 1.0 scale -foobar 0.5
scale 的选项中有错误仍会写出 - model_out.json
没有对文件进行任何更改,我们还没有处理 translate 子命令。

我正在尝试确定如何或在何处可以在上下文退出之前捕获任何子命令使用错误,以便我可以访问 FileResource 并防止保存。

eg ctx.obj.outfile = None 可以实现这个,我只是不知道在哪里可以检测到使用错误以便调用它。

您可以在资源处理程序 __exit__() 中测试程序退出代码,例如:

def __exit__(self, exc_type, exc_value, tb):
    sys_exc = sys.exc_info()[1]
    if isinstance(sys_exc, click.exceptions.Exit) and sys_exc.exit_code == 0:
        # Only execute this on a successful exit
        click.echo(f"Executing {type(self).__name__} __exit__()")

完整示例:

import click


@click.command
@click.option("--value", type=float)
@click.pass_context
def command(ctx, value):
    click.echo(f"command got {value}")
    yield ctx


class OurCLI(click.MultiCommand):
    def list_commands(self, ctx):
        return ["command"]

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


class OurResource:
    def __init__(self, ctx):
        self.ctx = ctx

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, tb):
        sys_exc = sys.exc_info()[1]
        if isinstance(sys_exc, click.exceptions.Exit) and sys_exc.exit_code == 0:
            # Only execute this on a successful exit
            click.echo(f"Executing {type(self).__name__} __exit__()")


@click.group(cls=OurCLI, chain=True)
@click.pass_context
def main(ctx):
    click.echo("main")
    ctx.obj = ctx.with_resource(OurResource(ctx))

测试代码:

if __name__ == "__main__":
    commands = (
        'command --value 5',
        'command --value XX',
        '--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)
            main(cmd.split())

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

结果:

Click Version: 8.1.3
Python Version: 3.7.8 (tags/v3.7.8:4b47a5b6ba, Jun 28 2020, 08:53:46) [MSC v.1916 64 bit (AMD64)]
-----------
> command --value 5
main
Executing OurResource __exit__()
-----------
> command --value XX
main
Usage: test_code.py command [OPTIONS]
Try 'test_code.py command --help' for help.

Error: Invalid value for '--value': 'XX' is not a valid float.
-----------
> --help
Usage: test_code.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...

Options:
  --help  Show this message and exit.

Commands:
  command