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:
input and output filepaths.dict and json file as standins for 3D scene data and FBX file).ctx.with_resource method and stored on the contexts user object.dict values and are in the same script, in the actual implementation they are implemented as plugins as per the custom multi command example)process function run as a result callback by Click. (similar to the multi command pipeline example)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()
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:
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With