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

534 Views Asked by At

Background

Following examples in the Click documentation (specifically custom multi commands, multi command pipelines and managing resources) I've written a CLI application similar to the Image Pipeline example only it operates on 3D mesh scenes via the FBX SDK instead of images.

Abstracting that particular detail aside, I am struggling to understand how and when Click's parsing of the command line occurs. The TLDR version of my problem is that command line usage errors seem to only be surfaced after the context exits and importantly, after any context managed resources are closed. Ideally I would like to establish if the command line is valid before even entering the context manager, or at least be able to react to a usage error before the manager exits.

Minimal example as follows:

  • a chained multicommand is setup with options for input and output filepaths.
  • a context manager is used to load/save the data (for this example I'm just using a dict and json file as standins for 3D scene data and FBX file).
  • this context manager is used with Clicks ctx.with_resource method and stored on the contexts user object.
  • subcommands are all passed the context and have their own arbitrary options (for this example they all just edit dict values and are in the same script, in the actual implementation they are implemented as plugins as per the custom multi command example)
  • all subcommands are generators and yield the context, these generators are consumed by the process function run as a result callback by Click. (similar to the multi command pipeline example)

Example code

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()

Testing

So this gives us a cli where we can call:
> python -m click_test.py -i model_in.json -o model_out.json translate -t 1.0 scale -s 0.5

which reads in - model_in.json

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

and writes out - model_out.json

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

Working as designed so far, model_out.json is either created or modified if it already existed.

However any command line syntax errors (at the subcommand level) will still result in an "output" file being written out, the managed resource is still opened and closed. So calling:
> python -m click_test.py -i model_in.json -o model_out.json translate -t 1.0 scale -foobar 0.5
with an error in the options to scale will still write out - model_out.json
No changes are made to the file, we've not processed the translate subcommand.

I am trying to determine how or where I can catch any subcommand usage errors, before the context exits, so that I can access the FileResource and prevent a save.

eg ctx.obj.outfile = None would achieve this, I just don't know where I can detect usage errors in order to call it.

1

There are 1 best solutions below

1
On BEST ANSWER

You can test the program exit code in your resource handler __exit__() like:

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__()")

Full Example:

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))

Test code:

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

Results:

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