pallets/click

Ability to create mutually exclusive option groups.

robhague opened this issue ยท 37 comments

Perhaps I'm missing something, but I can't find a way to create mutually exclusive option groups in Click. For example, I have a command that can take input from either a socket or a file, and I'd like the following syntax:

cmd [-file <filename> | -stream <address>] ...

At most one (or, in other examples, exactly one) of the options may be specified. Specifying more than one is a parse error caught by the library.

Is it possible to implement this in click at present? If not, would it be feasible to add it? I've not quite got my head around the general architecture yet. Would this feature be a reasonable fit, or does it go against the overall design in some way I've missed?

This is something I'd like as well which is currently not supported.

Perhaps it could look something like this:

@click.command()
@click.option_group(Option('-file'), Option('-stream'))
def command(file=None, stream=None):
    pass

Or perhaps option group could allow for mutally inclusive options, i.e -foo must be called with -bar. Inclusive seems the sensible default so there could be a param for exclusive such as:

@click.option_group(Option('-file'), Option('-stream'), exclusive=True)

@mitsuhiko thoughts? I'd be happy to put together a PR for this feature.

I don't really see how this works from an API point of view. I would rather see some sort of post validation utility that can check that only one is set or something.

Definitely a strong -1 on the option_group thing.

post validation utility

Not clear on what you mean @mitsuhiko, "utility" in the context of click appears to mean methods like click.echo? If this is the case how would this allow click to generate a useful help message on --help?

More than happy for you to suggest an API for me to go implement and submit a PR.

Not clear on what you mean @mitsuhiko, "utility" in the context of click appears to mean methods like click.echo? If this is the case how would this allow click to generate a useful help message on --help?

Pretty much yes. I don't see how click would generate a nice looking help page for such things anyways. This gets too complex quickly. For instance some options might only conflict in relation to a specific setting of another option.

I'm with @mitsuhiko in that I'm not sure how this would work in Click, but for what it's worth I have a similar use case.

The full use case is described in #329, but more simply: I have a tool that launches stuff into various cloud providers like AWS, GCE, and so forth. The choice of provider is an option.

Depending on what --provider is chosen, additional provider-specific options are required.

Personally, I don't care that the help page capture these kinds of dependencies between options. (e.g. If --provider is set to ec2, then --ec2-region must be specified.)

I care more that the dependency be captured in the parser (or perhaps in some nice utility method) and that helpful error message be provided.

My use case is quite complex, though. Perhaps it is a symptom of a complex design that Click shouldn't try to support.

@nchammas have you found a solution for dynamically making --ec2-region a required option?

@alanhamlett - Not yet, but when I have the time I am planning to write a utility function that captures simple dependencies like this. If it works well for my use case I will post it here as a proposed solution to this issue.

I just used a callback function for now:
wakatime/wakadump@fc88de6

๐Ÿ‘

I'm going to close this. There is no likely API for this and you can already manually check on this in callbacks.

you can already manually check on this in callbacks

I don't know if we are talking about the same thing, I don't understand as I use callback functions to create mutually exclusive option groups, and I am not familiar with the click's code, but I have rehearsed the following code for my project:

https://github.com/OSMBrasil/paicemana/blob/master/paicemana/clickexclusive.py

You can store data on ctx.obj in one callback and check in the other callback if that data is there. If it is there, the user has used both options and you can raise an error to enforce the exclusivity of these two options.

The programation of the callback functions can become very complicated with many parameters. I am in favor of something using annotations, but I can not to perform the changes.

I don't think it's complicated at all.

How would be an example for cli.py command -a|-b|-c|-d|-e|-f? No need to show it if it is not simple.

Are you talking about the problematic formatting of such options in the help page?

Why not use an option of type Choice?

On 23 July 2015 17:59:10 CEST, Alexandre Magno notifications@github.com wrote:

How would be an example for cli.py command -a|-b|-c|-d|-e|-f? No need
to show it if it is not simple.


Reply to this email directly or view it on GitHub:
#257 (comment)

Sent from my phone. Please excuse my brevity.

Parameters of different meanings, names, types and requirements.

Do you know of any pracical example that features so many mutually exclusive parameters with different types?

On 23 July 2015 18:20:04 CEST, Alexandre Magno notifications@github.com wrote:

Parameters of different meanings, names and types.


Reply to this email directly or view it on GitHub:
#257 (comment)

Sent from my phone. Please excuse my brevity.

OSMBrasil/paicemana#15. See the github-* commands.

I strongly suspect those should be implemented as subcommands, though I'm not really trusting Google Translate.

Semantically I prefer to reserve the universe of commands for program expansion. Anyway, I think this is a decision very "personal" and that the click should not impose such restriction. But I know that I can use Choice options from refactoring...

Let's say I or other interest in studying and implementing something like the #257 (comment) example, would you be open to receive pull requests in this regard?

Semantically I prefer to reserve the universe of commands for program expansion.

I don't know what you mean by that sentence. I was thinking about a command-line like this:

  • Instead of paicemana osmf -g, do paicemana osmf get
  • Instead of paicemana osmf -p, do paicemana osmf put
  • Instead of paicemana osmf -s, do paicemana osmf sync

Anyway, I think this is a decision very "personal" and that the click should not impose such restriction.

Click imposes such restrictions all the time. Limiting ones "personal freedoms" is Click's spirit, so to speak, to achieve a certain consistency across command-line applications.

Sorry, I had not given me of that paicemana osmf get was possible. I'll get to learn it. Yes, this Click's spirit is appropriate. Thanks.

Like this:

@click.group()
def paicemana():
    pass

@paicemana.group()
def osmf():
    pass

@osmf.command()
def put():
    pass

Oh! This is very good. Thank you very much. I haven't found this at documentation.


Update

Now I am reading here:

@untitaker, is possible to show a full help (commands and subcommands)?

No, not at the moment, feel free to open a new issue.

Quick update for people: I put together a few utility methods for my project Flintrock that enforce option dependencies like we've been discussing here.

The utility methods help you enforce the following kinds of requirements:

  • Option A requires all of options B, C, and D to be set.
  • Option A requires any of options B, C, or D to be set.
  • Option A requires all of options B, C, and D to be set, and any of options E, F, G to also be set.
  • Option A has the same requirements as in any of the previous examples, except that these requirements are conditional on Option A having a value of V.
  • Options A, B, and C are mutually exclusive. Only 1 of them can be set.

I've added these utility methods to my project in this PR: nchammas/flintrock#74

Here are a few example invocations to illustrate:

option_requires(
    option='--install-hdfs',
    requires_all=['--hdfs-version'],
    scope=locals())

option_requires(
    option='--install-spark',
    requires_any=[
        '--spark-version',
        '--spark-git-commit'],
    scope=locals())

mutually_exclusive(
    options=[
        '--spark-version',
        '--spark-git-commit'],
    scope=locals())

option_requires(
    option='--provider',
    conditional_value='ec2',
    requires_all=[
        '--ec2-key-name',
        '--ec2-identity-file',
        '--ec2-instance-type',
        '--ec2-region',
        '--ec2-ami',
        '--ec2-user'],
    scope=locals())

This is pretty hacky with the passing of locals() and the janky lookup of option values by converting from "option names" to "variable names", but the API is very readable.

I'm not proposing this for addition to Click -- I don't think this API would work for most people -- but I thought I'd share my work since others have been looking for a way to enforce these kinds of requirements.

I needed to do this too. Here's what I came up with; not perfect (for each option, you need to give the list of other conflicting options; there's no nice global definition), but it works well enough for what I need.

https://gist.github.com/jacobtolar/fb80d5552a9a9dfc32b12a829fa21c0c

Example usage:

@command(help="Run the command.")
@option('--jar-file', cls=MutuallyExclusiveOption, help="The jar file the topology lives in.", mutually_exclusive=["other_arg"])
@option('--other-arg', cls=MutuallyExclusiveOption, help="Another argument.", mutually_exclusive=["jar_file"])
def cli(jar_file, other_arg):
    print "Running cli."
    print "jar-file: {}".format(jar_file)
    print "other-arg: {}".format(other_arg)
listx commented

I think this topic should be addressed in the official docs (and state why Click does not support this feature), under the "Choice Options" (http://click.pocoo.org/6/options/#choice-options) heading, because the idea is very similar.

listx commented

@jacobtolar FWIW I made this revision that allows you to just define a single list of allowed choices for each option. https://gist.github.com/listx/e06c7561bddfe47346e41a23a3026f33

The usage remains identical, except instead of passing in disparate lists for each argument, you pass in the same list each time.

I just hacked together a quick and dirty, differently-styled solution to this problem, which makes it easy to specify multiple different exclusive relationships.
https://gist.github.com/thebopshoobop/51c4b6dce31017e797699030e3975dbf

But what about options that are required depending on the value of another option?
Like for SNMP:
--version [ 1 | 2c | 3 ]
Depending on the value of "version" I need different further options:
"1 | 2c" require a "community string" only

"3" requires security context information and passphrases like -l -u -a -A -x -X

Any idea how to implement that with click?

For finding this post and wondering if there is a better solution for simple mutex options here is my slightly changed version from https://stackoverflow.com/questions/44247099/click-command-line-interfaces-make-options-required-if-other-optional-option-is using a custom class to validate the options:

import click

class Mutex(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if:list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = (kwargs.get("help", "") + "Option is mutually exclusive with " + ", ".join(self.not_required_if) + ".").strip()
        super(Mutex, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt:bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError("Illegal usage: '" + str(self.name) + "' is mutually exclusive with " + str(mutex_opt) + ".")
                else:
                    self.prompt = None
        return super(Mutex, self).handle_parse_result(ctx, opts, args)

Use it like this:

@click.group()
@click.option("--username", prompt=True, cls=Mutex, not_required_if=["token"])
@click.option("--password", prompt=True, hide_input=True, cls=Mutex, not_required_if=["token"])
@click.option("--token", cls=Mutex, not_required_if=["username","password"])
def login(ctx=None, username:str=None, password:str=None, token:str=None) -> None:
	print("...do what you like with the params you got...")

I have created the project: https://github.com/espdev/click-option-group
I would be glad if it is useful to someone. :)

@mitsuhiko what do you think about such API?