Search code examples
pythoncommand-line-interfacepython-click

Use several options together or not at all


I want to use several options together, or not at all, as the title says, but my methods seem relatively ugly, and I was wondering if there was a cleaner way to implement this. I have, in addition, looked at this, about how it might be done in argparse, but I would like to implement it in click if possible (I am trying to avoid using nargs=[...]).

So far, this is what I have:

@click.group(invoke_without_command=True, no_args_is_help=True)
@click.option(
    "-d",
    "--group-dir",
    type=click.Path(),
    default="default",
    help='the directory to find the TOML file from which to run multiple jobs at the same time; defaults to the configuration directory of melmetal: "~/.melmetal" on Unix systems, and "C:\\Users\\user\\.melmetal" on Windows',
)
@click.option("-f", "--group-file", help="the TOML file name")
@click.option(
    "-n", "--group-name", help="name of the group of jobs"
)
@click.option(
    "--no-debug",
    is_flag=True,
    type=bool,
    help="prevent logging from being output to the terminal",
)
@click.pass_context
@logger.catch
def main(ctx, group_dir, group_file, group_name, no_debug):

    options = [group_file, group_name]
    group_dir = False if not any(options) else group_dir
    options.append(group_dir)

    if not any(options):
        pass
    elif not all(options):
        logger.error(
            colorize("red", "Sorry; you must use all options at once.")
        )
        exit(1)
    else:
        [...]

And a second example:

if any(createStuff):
    if not all(createStuff):
        le(
            colorize("red", 'Sorry; you must use both the "--config-dir" and "--config-file" options at once.')
        )
        exit(1)
elif any(filtered):
    if len(filtered) is not len(drbc):
        le(
            colorize("red", 'Sorry; you must use all of "--device", "--repo-name", "--backup-type", and "--config-dir" at once.')
        )
        exit(1)
else:
    ctx = click.get_current_context()
    click.echo(ctx.get_help())
    exit(0)

How do I get the help text to show when no sub-commands are given? As I understand it, this is supposed to happen automatically, but for my code it automatically goes to the main function. An example of my workaround is in the second example, i.e. under the else statement.


Solution

  • You can enforce using all options in a group by building a custom class derived from click.Option, and in that class over riding the click.Option.handle_parse_result() method like:

    Custom Option Class:

    import click
    
    class GroupedOptions(click.Option):
        def __init__(self, *args, **kwargs):
            self.opt_group = kwargs.pop('opt_group')
            assert self.opt_group, "'opt_group' parameter required"
            super(GroupedOptions, self).__init__(*args, **kwargs)
    
        def handle_parse_result(self, ctx, opts, args):
            if self.name in opts:
                opts_in_group = [param.name for param in ctx.command.params
                                 if isinstance(param, GroupedOptions) and
                                 param.opt_group == self.opt_group]
    
                missing_specified = tuple(name for name in opts_in_group
                                          if name not in opts)
    
                if missing_specified:
                    raise click.UsageError(
                        "Illegal usage: When using option '{}' must also use "
                        "all of options {}".format(self.name, missing_specified)
                    )
    
            return super(GroupedOptions, self).handle_parse_result(
                ctx, opts, args)
    

    Using Custom Class:

    To use the custom class, pass the cls parameter to click.option decorator like:

    @click.option('--opt1', cls=GroupedOptions, opt_group=1)
    

    In addition give an option group number with the opt_group parameter.

    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 overridden 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 over ride click.Option.handle_parse_result() and check that other options in our group were specified.

    Note: This answer was inspired by this answer

    Test Code:

    @click.command()
    @click.option('--opt1', cls=GroupedOptions, opt_group=1)
    @click.option('--opt2', cls=GroupedOptions, opt_group=1)
    @click.option('--opt3', cls=GroupedOptions, opt_group=1)
    @click.option('--opt4', cls=GroupedOptions, opt_group=2)
    @click.option('--opt5', cls=GroupedOptions, opt_group=2)
    def cli(**kwargs):
        for arg, value in kwargs.items():
            click.echo("{}: {}".format(arg, value))
    
    if __name__ == "__main__":
        commands = (
            '--opt1=x',
            '--opt4=a',
            '--opt4=a --opt5=b',
            '--opt1=x --opt2=y --opt3=z --opt4=a --opt5=b',
            '--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
    

    Results:

    Click Version: 6.7
    Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
    -----------
    > --opt1=x
    Error: Illegal usage: When using option 'opt1' must also use all of options ('opt2', 'opt3')
    -----------
    > --opt4=a
    Error: Illegal usage: When using option 'opt4' must also use all of options ('opt5',)
    -----------
    > --opt4=a --opt5=b
    opt4: a
    opt5: b
    opt1: None
    opt2: None
    opt3: None
    -----------
    > --opt1=x --opt2=y --opt3=z --opt4=a --opt5=b
    opt1: x
    opt2: y
    opt3: z
    opt4: a
    opt5: b
    -----------
    > --help
    Usage: test.py [OPTIONS]
    
    Options:
      --opt1 TEXT
      --opt2 TEXT
      --opt3 TEXT
      --opt4 TEXT
      --opt5 TEXT
      --help       Show this message and exit.
    -----------
    > 
    opt1: None
    opt2: None
    opt3: None
    opt4: None
    opt5: None