2

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.

Stephen Rauch
  • 40,722
  • 30
  • 82
  • 105
  • You should ask about your issue rather than your solution, it will help to make it clear what you're trying to achieve – Sayse Apr 26 '19 at 19:46
  • Well, I'm not having an issue, per say; I just want to know if there's a way to make the code simpler, such as by using a keyword argument in the option decorator, because at the moment this is kind of difficult wrapping my head around as it is. – ShadowRylander Apr 26 '19 at 19:54
  • Would making the options a [tuple](https://click.palletsprojects.com/en/7.x/options/#tuples-as-multi-value-options) be suitable? The way you've described it, it doesn't sound like they're really seperate command line args – Sayse Apr 26 '19 at 20:01
  • But wouldn't having them as separate make them more verbose? Although I suppose it would make sense, given they're in the same category as it were... It would, however, make it confusing as to which element in the tuple is which value. – ShadowRylander Apr 26 '19 at 20:07
  • It would make it more verbose but I can't think of any command line ive used when an argument relied on another. But then looking at your examples, do you really need to? You could determine the directory from the file or vice versa – Sayse Apr 26 '19 at 20:12
  • It's more of a copy and paste issue; I don't want users to have to constantly enter the directory along with the file; in addition, there are default directories as well, which are problematic to account for when entering the file. – ShadowRylander Apr 26 '19 at 20:26
  • So the goal is to group options? – Stephen Rauch Apr 27 '19 at 02:10
  • To a certain extent; group options into _not_ a sub-command, as that would make that the sub-command of a sub-command of a command. – ShadowRylander Apr 27 '19 at 06:10

2 Answers2

2

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
Stephen Rauch
  • 40,722
  • 30
  • 82
  • 105
  • Thank you kindly for this! I will test it out as soon as possible; giving credits to you may I use this in my programs? – ShadowRylander Apr 28 '19 at 00:06
  • 1
    Everything on StackOverflow is [CCASA licensed](https://stackoverflow.com/help/licensing). You are free to use as you want, but if you republish you are expected to attribute. – Stephen Rauch Apr 28 '19 at 00:09
  • Thank you again! (Last time) I wanted to test it out first; I'll do so and mark the answer correct! – ShadowRylander Apr 28 '19 at 00:50
1

You could use Cloup. It adds option groups and allows to define constraints on any group of parameters. It includes an all_or_none constraints that does exactly what you want.

Disclaimer: I'm the author of Cloup.

from cloup import command, option, option_group
from cloup.constraints import all_or_none


@command()
@option_group(
    'My peculiar options',
    option('--opt1'),
    option('--opt2'),
    option('--opt3'),
    constraint=all_or_none,
)
def cmd(**kwargs):
    print(kwargs)

The help:

Usage: cmd [OPTIONS]

My peculiar options [provide all or none]:
  --opt1 TEXT
  --opt2 TEXT
  --opt3 TEXT

Other options:
  --help       Show this message and exit.

The error message:

Usage: cmd [OPTIONS]
Try 'cmd --help' for help.

Error: either all or none of the following parameters must be set:
--opt1, --opt2, --opt3

This error message is probably not the best possible, so I'll probably change it in next releases. But you can easily do it yourself very easily in Cloup, e.g.:

provide_all_or_none = all_or_none.rephrased(
    error='if you provide one of the following options, you need to provide '
          'all the others in the list:\n{param_list}'
)

Then you get:

Error: if you provide one of the following options, you need to provide all the others in the list:
--opt1, --opt2, --opt3
janluke
  • 1,319
  • 1
  • 9
  • 14