Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mutually exclusive options with the same variable name don't work #11

Open
craigds opened this issue Jun 8, 2020 · 5 comments
Open
Labels
enhancement New feature or request help wanted Extra attention is needed incompatibility Incompatibility with other functions and plugins

Comments

@craigds
Copy link

craigds commented Jun 8, 2020

If I have two options sharing the same variable name, like this:

#!/usr/bin/env python
import click
from click_option_group import optgroup, MutuallyExclusiveOptionGroup


@click.command()
@click.pass_context
@optgroup.group('Output format', cls=MutuallyExclusiveOptionGroup)
@optgroup.option(
    "--text", "output_format", flag_value="text", default=True,
)
@optgroup.option(
    "--json", "output_format", flag_value="json",
)
def mycommand(ctx, *, output_format, **kwargs):
    print(output_format)


if __name__ == '__main__':
    mycommand()

The mutual exclusive option group doesn't work - whichever option is specified last on the command line wins:

$ python ./mycommand.py --json --text
text

This may be difficult to solve, but here's my completely custom hack for making it work: koordinates/kart#106

Maybe elements of that solution could be incorporated into this project?

@espdev espdev added the enhancement New feature or request label Jun 9, 2020
@espdev
Copy link
Member

espdev commented Jun 9, 2020

@craigds thanks for the report. I will try to investigate this behavior and your solution. I will see what I can do. :)

In any case your example looks like a very convenient way to work with mutually exclusive options. Would be great to fix/implement this.

@espdev espdev added the incompatibility Incompatibility with other functions and plugins label Jun 13, 2020
@JOJ0
Copy link

JOJ0 commented Nov 26, 2020

I was just about to report the same issue. Probably you are aware of it already but in case you are not: The behaviour that "the last option given on command line wins" is actually built into Click itself and does not have to do with click-option-group (I did not read code just tested results of Click-only-code). I am not sure if it is considered a feature in Click or a bug but at least the manual does not point out the intended behaviour exactely: https://click-docs-cn.readthedocs.io/zh_CN/latest/options.html#feature-switches

@user.command(context_settings=cont_set)
@click.pass_context
@click.argument('user_id', type=str)
...
@click.option('--deactivate', 'deactivation_state', flag_value='deactivate',
      help='''deactivate user. Use with caution! Deactivating a user
      removes their active access tokens, resets their password, kicks them out
      of all rooms and deletes third-party identifiers (to prevent the user
      requesting a password reset). See also "user deactivate" command.''')
@click.option('--activate', 'deactivation_state', flag_value='activate',
      help='''re-activate user.''')
def modify(ctx, user_id, password, password_prompt, display_name, threepid,
      avatar_url, admin, deactivation_state):
    '''create or modify a local user. Provide matrix user ID (@user:server)
    as argument.'''
...

Without posting further code details, this should best describe Click's behaviour:

$ synadm user modify userid --deactivate --activate 
Current user account settings:

User account settings after modification:
deactivation_state: activate

Are you sure you want to modify user? (y/N): 

other way round:

$ synadm user modify userid --activate --deactivate
Current user account settings:

User account settings after modification:
deactivation_state: deactivate

Are you sure you want to modify user? (y/N): 

HTH :-)

@flying-sheep
Copy link

flying-sheep commented Jan 4, 2021

I guess it could work to key them by (option.name, option.flag_value) instead of just by option.name:

def _option_memo(self, func):
func, params = get_callback_and_params(func)
option = params[-1]
self._options[func][option.name] = option

becomes

def _option_memo(self, func):
    ...
    self._options[func][option.name, option.flag_value] = option

@espdev espdev added the help wanted Extra attention is needed label Jul 16, 2022
@ulgens
Copy link
Contributor

ulgens commented Jul 31, 2024

I agree the behaviour seems weird at first look, but I don't think this is a bug, or something "broken".

So,

  • There is the target parameter output_format
  • It has multiple choices but can accept once at max
  • One of the choices are enabled by default

which roughly translates to

@click.option(
    "--output_format",
    type=click.Choice(
        ["text", "json"],
        default="text",
        case_sensitive=False,
    ),
)

I'm missing how using a mutually exclusive group is required for this case. Also, if text is enabled by default, to use the other parameter with mutex group you'd need to disable text option explicitly, which I believe another reason making the mutex group not the best choice here.

@jacky9813
Copy link

I had a vary similar issue as well.

In my case, I'm doing time range selection, to give you a short example:

#!/usr/bin/env python3
from datetime import datetime, timedelta
import click
from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup
import iso8601


def parse_timerange(ctx, param, value):
    if not value:
        return
    range_str = value.split("/")
    time_range = iso8601.parse_date(range_str[0]), iso8601.parse_date(range_str[1])
    return time_range


def parse_last_month(ctx, param, value):
    if not value:
        return
    end_time = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    start_time = (end_time - timedelta(day=1)).replace(day=1)
    return start_time, end_time


@click.command()
@optgroup.group("Time selection", cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option("--time-range", "time_range", type=click.UNPROCESSED, callback=parse_timerange)
@optgroup.option("--last-month", "time_range", is_flag=True, callback=parse_last_month)
def do_something(time_range: tuple[datetime, datetime]):
    pass

But I found a workaround by removing variable name and modifying the callback function like this:

#!/usr/bin/env python3
from datetime import datetime, timedelta
import click
from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup
import iso8601


def parse_timerange(ctx, param, value):
    if not value:
        return
    range_str = value.split("/")
    time_range = iso8601.parse_date(range_str[0]), iso8601.parse_date(range_str[1])
    ctx["tr"] = time_range  # <===== this
    return time_range


def parse_last_month(ctx, param, value):
    if not value:
        return
    end_time = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    start_time = (end_time - timedelta(day=1)).replace(day=1)
    ctx["tr"] = start_time, end_time  # <===== this
    return start_time, end_time


@click.command()
@optgroup.group("Time selection", cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option("--time-range", type=click.UNPROCESSED, callback=parse_timerange)
@optgroup.option("--last-month", is_flag=True, callback=parse_last_month)
def do_something(
    time_range: tuple[datetime, datetime] | None = None,
    last_month: bool = False,
    tr: tuple[datetime, datetime] | None = None  # <===== this
):
    if tr is None:
        raise click.UsageError("no selected time range")

Note that there is a new param tr which is not defined in the option. This is a click built-in feature that can write the same value for a parameter while having RequiredMutuallyExclusiveOptionGroup and MutuallyExclusiveOptionGroup working.

This workaround still has some minor issues:

  • The command function will have unused parameters, making it slighly uglier. **kwargs can solve cluttering issue in function parameters though.
  • Having a callback becomes a requirement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed incompatibility Incompatibility with other functions and plugins
Projects
None yet
Development

No branches or pull requests

6 participants