Noninteractive confirmation of eager options in the Python click library

Viewed 395

I had a click subcommand for resetting a config file, myprog config-reset:

import click

@click.command()
@click.confirmation_option(prompt="Are you sure?")
def config_reset():
    """Reset the user configuration."""
    click.echo("Success")

The use of click.confirmation_option allowed me to pass --yes to bypass the "Are you sure?" prompt, which was useful for testing:

$ myprog config-reset --yes  # No interactive confirmation required.

For other reasons, I've now merged the config-reset subcommand into a general config subcommand, such that the user can call myprog config --reset. I achieved this by using an eager option that executes the function config_reset if --reset is present, instead of executing the main function body:

def config_reset(ctx, option, value):
    if not value or ctx.resilient_parsing:  # Copied from click docs.
        return

    click.confirm("Are you sure?", abort=True)
    # If we got this far, the config should be reset...

@click.command()
@click.option(
    "--reset",
    is_flag=True,
    callback=config_reset,
    expose_value=False,
    is_eager=True,
    help="Reset the user configuration to the default.",
)
def config():
    """Show the configuration."""
    # print config here...

The problem now, is that I don't have a way to pass a --yes option to bypass the interactive "Are you sure?" confirmation. Is there a way to implement a click.confirmation_option in combination with is_eager=True? Essentially I want to be able to write...

$ myprog config --reset --yes

...and have the config reset itself without confirmation.

2 Answers

We can use a --yes option to skip the confirmation in the eager callback by creating a custom click.Option class.

Custom Class

def confirm_eager_option(*args, confirm_skip_flag_name='yes'):

    class ConfirmEagerOption(click.Option):
        def __init__(self, param_decls, **kwargs):
            assert not param_decls, 'pass options names into confirm_eager_option()'
            self.original_callback = kwargs['callback']
            kwargs['callback'] = self.callback_handler
            kwargs['is_eager'] = True
            kwargs['is_flag'] = True
            kwargs['expose_value'] = False
            super().__init__(args, **kwargs)

        def handle_parse_result(self, ctx, opts, args):
            # grab the parsed options for later reference
            self.parse_opts = opts
            return super().handle_parse_result(ctx, opts, args)

        def callback_handler(self, ctx, option, value):
            if value and not self.parse_opts.get('help'):
                # If we have the confirm flag, then set value to None
                if self.parse_opts.get(confirm_skip_flag_name):
                    value = None
                self.original_callback(ctx, option, value)

        # build a decorator with our skip flag
        confirm_skip_option = click.option(
            f'--{confirm_skip_flag_name}', is_flag=True, expose_value=False, is_eager=True,
            help=f"Skip confirmation for {args[0]}"
        )

    return ConfirmEagerOption

Using the Custom Class:

To use the custom class, first build the class by calling confirm_eager_option() and pass it the option names, and the skip flag name like:

confirm_eager_option_cls = confirm_eager_option(
    '--reset', confirm_skip_flag_name='yes')

Then pass the class as the cls argument of the click.option decorator, and add another decorator for the skip option like::

@click.command()
@click.option(cls=confirm_eager_option_cls, callback=config_reset,
              help="Reset the user configuration to the default.")
@confirm_eager_option_cls.confirm_skip_option
def cli():
    ...

How does this work?

This works because click is a well designed OO framework. The @click.option() decorator usually instantiates a click.Option object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Option in our own class and over ride the desired methods.

In this case we override the handle_parse_result() method. The overridden method allows us to capture the other parsed options, and then look at those options in the eager callback hook to decide if skipping the confirmation is allowed.

Test Code:

confirm_eager_option_cls = confirm_eager_option(
    '--reset', confirm_skip_flag_name='yes')

def config_reset(ctx, option, value):
    if value is None or value:
        if value:
            click.echo('Confirming reset...')
            # click.confirm("Are you sure?", abort=True)
        click.echo('Resetting...')

@click.command()
@click.option(cls=confirm_eager_option_cls, callback=config_reset,
              help="Reset the user configuration to the default.")
@confirm_eager_option_cls.confirm_skip_option
def cli():
    click.echo('running command')

if __name__ == "__main__":
    commands = (
        '',
        '--reset',
        '--reset --yes',
        '--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())

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

Test Results:

Click Version: 7.1.2
Python Version: 3.8.5 (tags/v3.8.5:580fbb0, Jul 20 2020, 15:57:54) [MSC v.1924 64 bit (AMD64)]
-----------
> 
running command
-----------
> --reset
Confirming reset...
Resetting...
running command
-----------
> --reset --yes
Resetting...
running command
-----------
> --help
Usage: test_code.py [OPTIONS]

Options:
  --reset  Reset the user configuration to the default.
  --yes    Skip confirmation for --reset
  --help   Show this message and exit.

You don't have to create custom option. See the documentation:

All eager parameters are evaluated before all non-eager parameters, but again in the order as they were provided on the command line by the user.

So what you need to do is, to add --yes param, which is eager too and to provide it before the reset param. Here is an example command:

@cli.command()
@click.option('--yes', is_flag=True, is_eager=True)
@click.option(
    "--reset",
    is_flag=True,
    callback=config_reset,
    is_eager=True,
)
def my_command(reset, yes):
    pass

if you call the command that way:

$ my_command --yes --reset

You will have access to the --yes option in the callback:

def config_reset(ctx, option, value):
    print(ctx.params) # prints {'yes': True}

So, what you need to do is:

def config_reset(ctx, option, value):
    if not ctx.params.get('yes'):
        click.confirm("Are you sure?", abort=True)

And remember, that the order of your arguments does metter:

$ my_command --reset --yes

won't work for you.

Related