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
andoutput
filepaths. - a context manager is used to load/save the data (for this example I'm just using a
dict
andjson
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.
You can test the program exit code in your resource handler
__exit__()
like:Full Example:
Test code:
Results: