typer icon indicating copy to clipboard operation
typer copied to clipboard

[QUESTION] How to handle mutually exclusive options

Open bsamseth opened this issue 3 years ago • 21 comments

First check

  • [X] I used the GitHub search to find a similar issue and didn't find it.
  • [X] I searched the Typer documentation, with the integrated search.
  • [X] I already searched in Google "How to X in Typer" and didn't find any information.
  • [X] I already searched in Google "How to X in Click" and didn't find any information.

Description

I often find myself making mutually exclusive options. I'm used to argparse, which has nice support for that. What is the best way to do this in Typer?

I.e. how to (best) achieve something like this argparse code:

parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument("--opt-A, action="store_true")
group.add_argument("--opt-B, action="store_true")

I found several mentions of ways to do it with Click, but none that were "built-in", nor I'm I clear on how I'd use that with Typer.

Any help would be much appreciated. I really like the feel of Typer!

bsamseth avatar Jul 15 '20 05:07 bsamseth

In some cases (like the one you have here for simple values), you might just be able to get away with an enum implementation instead. I used typer.Option here but you could make it an Argument instead. It's not entirely the same but you can probably get creative with how you handle the enum values.

import typer
from enum import Enum

app = typer.Typer()


class SomeEnum(str, Enum):
    A = "optA"
    B = "optB"


@app.command()
def main(choice: SomeEnum = typer.Option(...)):
    print(choice.value)


if __name__ == "__main__":
    app()

If you wanted to pass values to optA or optB, you could cheat like this:

import typer
from enum import Enum
from typing import Tuple, List
from itertools import chain

app = typer.Typer()

def combinedEnum(enums: List[Enum]) -> Enum:
    if not all(issubclass(e, Enum) for e in enums):
        raise Exception(f"Not all Enums: {enums}")
    return Enum("CombinedEnumOptions", [(i.name, i.value) for i in chain(*enums)])

class SomeEnum(str, Enum):
    A = "optA"
    B = "optB"

class OptAOptions(str, Enum):
    go = "go"
    stop = "stop"

class OptBOptions(str, Enum):
    red = "red"
    green = "green"

@app.command()
def main(choice: Tuple[SomeEnum,  combinedEnum([OptAOptions, OptBOptions])] = typer.Option((None, None))):
    option, arg = choice
    print(f"The exclusive option {option} has value of '{arg}'")


if __name__ == "__main__":
    app()

Is this the right way to do it? Absolutely not, but it works, and who knows when the next Typer release is? 👴🏾 Also, for your help menu, you would probably have to let the user know what the valid options by supplying the text yourself. And you'd probably also need a callback to verify any options to optA/optB are the right ones. It's messy but sometimes you gotta just do things. 🗡️

daddycocoaman avatar Dec 17 '20 11:12 daddycocoaman

Are there any new solutions available in typer for this problem yet?

nicksspirit avatar Jan 10 '21 17:01 nicksspirit

Hi all, any update on this?

ferreteleco avatar Mar 29 '21 16:03 ferreteleco

Just as an additional workaround, you might be able to use subcommands instead. E.g. if the mutually exclusive options are aaa and bbb, instead of:

foo --with-aaa --other-options arg1 arg2
foo --with-bbb --other-options arg1 arg2

you will have

foo aaa --other-options arg1 arg2
foo bbb --other-options arg1 arg2

I am not saying that this can always be used, but when choosing one of the mutually exclusive options is required, it feels quite natural.

pmav99 avatar Mar 29 '21 16:03 pmav99

Hey my fellow Pythonistas, I often comeback to this issue from time to time because it's still a problem for me so I took @daddycocoaman answer and came up with this:

import typer

app = typer.Typer()

def MutuallyExclusiveGroup(size=2):
    group = set()
    def callback(ctx: typer.Context, param: typer.CallbackParam, value: str):
        # Add cli option to group if it was called with a value
        if value is not None and param.name not in group:
            group.add(param.name)
        if len(group) > size-1:
            raise typer.BadParameter(f"{param.name} is mutually exclusive with {group.pop()}")
        return value
    return callback

exclusivity_callback = MutuallyExclusiveGroup()

@app.command()
def main(
        optA: str = typer.Option(None, callback=exclusivity_callback),
        optB: int = typer.Option(None, callback=exclusivity_callback)
    ):
    typer.echo(f"Option A is {optA} and Option B is {optB}")


if __name__ == "__main__":
    app()

Using the function MutuallyExclusiveGroup I return another function within a closure where there is state, a set called group as typer invokes the callbacks the logic will check if more than one option I have added the exclusivity_callback was called in the command line and raises an exception.

$ python cli.py --optb 3 --opta wow
Usage: cli.py [OPTIONS]

Error: Invalid value for '--opta': optA is mutually exclusive with optA

$ python cli.py --optb 3
Option A is None and Option B is 3

$ python cli.py --opta 3 --optb wow
Usage: cli.py [OPTIONS]
Try 'cli.py --help' for help.

Error: Invalid value for '--optb': wow is not a valid integer

$ python cli.py --opta wow --optb 3
Usage: cli.py [OPTIONS]

Error: Invalid value for '--optb': optB is mutually exclusive with optA

$ python cli.py --opta wow
Option A is wow and Option B is None

If you need to ensure at least one of the options are passed to the command line then manually check it in the body of your function for the command like so

@app.command()
def main(
        optA: str = typer.Option(None, callback=exclusivity_callback),
        optB: int = typer.Option(None, callback=exclusivity_callback)
    ):

    if not any([optA, optB]):
        raise typer.BadParameter("At least optA or optB is required.")
        
    typer.echo(f"Option A is {optA} and Option B is {optB}")

nicksspirit avatar Aug 14 '21 17:08 nicksspirit

I think this functionality deserves an official implementation so I suggest tagging this issue as a feature request instead of a question and then maybe @OdinTech3 can open a pull request for his work

danielbraun89 avatar Oct 20 '21 06:10 danielbraun89

I think this functionality deserves an official implementation so I suggest tagging this issue as a feature request instead of a question and then maybe @OdinTech3 can open a pull request for his work

Probably not with that exact implementation though because you wouldn't want to waste the callback parameter on it. Instead, it should be a new field and Typer should handle generating the groups on the backend.

daddycocoaman avatar Oct 20 '21 07:10 daddycocoaman

@daddycocoaman how were you envisioning the typer to create the groups?

nicksspirit avatar Oct 20 '21 09:10 nicksspirit

@OdinTech3 I think what you have there works great but shouldn't be placed under the callback parameter. It should be a new parameter (i.e., exclusive_group=) that resolves before the callback parameter does. That way the exclusivity can be checked before the whatever the user defines for processing the parameter.

I mean this in the context of a new feature since this the only way to do it now.

daddycocoaman avatar Oct 20 '21 11:10 daddycocoaman

Ah okay @daddycocoaman, I see what you are saying. I could take a crack at implementing this using exclusive_group as an argument for the typer.Option function. Then submit a PR for it.

Do you have other implementation ideas for this, that you want to share or do you think you'll have more once you see the PR?

nicksspirit avatar Oct 22 '21 15:10 nicksspirit

If there's a way to expose the functionality of click-option-group, that might be a good way to achieve this.

DanLipsitt avatar Dec 22 '21 23:12 DanLipsitt

Just adding another "I'd like to see this too" comment.

robinbowes avatar Mar 17 '22 11:03 robinbowes

My current use case: Options --quiet / -q and --verbose / -v that control how much output my cmd line tool displays: There is no point in passing / accepting them both; a way to make them mutually exclusive would help me a lot.

dd-ssc avatar Sep 28 '22 20:09 dd-ssc

@dd-ssc While I'm generally in favor of this functionality being added for other use cases, your needs might be met by taking a slightly different approach.

python itself defaults to "quiet" (minimal output to be useful), and each additional -v you pass raises the verbosity level. via python --help:

-v     : verbose (trace import statements); also PYTHONVERBOSE=x
         can be supplied multiple times to increase verbosity

You can follow this [example from the Typer docs](https://typer.tiangolo.com/tutorial/parameter- types/number/#counter-cli-options) to implement the same behavior in your CLI.

cj81499 avatar Sep 28 '22 23:09 cj81499

@cj81499: Thanks for your advice, much appreciated! 👍🏻. I use a --log-level Option now that uses an enum.

dd-ssc avatar Sep 29 '22 07:09 dd-ssc

@dd-ssc That way, you can still say quiet, default, and verbose, clever!

cj81499 avatar Sep 29 '22 17:09 cj81499

I could - but I was lazy and just copied Python logging log levels, because there is already sample code for mapping a command line argument to a log level in the Python HOWTO (below the --log=INFO code bit).

dd-ssc avatar Sep 29 '22 17:09 dd-ssc

Click has a "cls" kwarg that allows one to use custom classes in order to extend the Option class. It is really usefull to handle mutually inclusive/exclusive options (see below ⬇️), and I think it would be nice if we could access to that argument (or something similar) when using typer.Option.

import click

class Mutex(click.Option):
    """Mutually exclusive options (with at least one required)."""

    def __init__(self, *args, **kwargs):
        self.not_required_if = kwargs.pop('not_required_if')  # list
        assert self.not_required_if, '"not_required_if" parameter required'
        kwargs['help'] = (f'{kwargs.get("help", "")}  [required; mutually '
                          f'exclusive with {", ".join(self.not_required_if)}]')
        super().__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt = self.name in opts  # bool
        if current_opt:
            i = 1
        else:
            i = 0
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                i += 1
                if current_opt:
                    msg = (f'Illegal usage: "{self.name}" is mutually '
                           f'exclusive with "{mutex_opt}".')
                    raise click.UsageError(msg)
                else:
                    self.prompt = None
        if i == 0:
            signature = ' / '.join(self.opts + self.secondary_opts)
            msg = (f"Missing option '{signature}' (or any of the following "
                   f"options: {', '.join(self.not_required_if)})")
            raise click.UsageError(msg)
        return super().handle_parse_result(ctx, opts, args)

@click.command()
@click.option('--optA', type=STRING, cls=Mutex, not_required_if=('optB',))
@click.option('--optB', type=INT, cls=Mutex, not_required_if=('optA',))
def main(optA, optB):
    click.echo(f'Option A is {optA} and Option B is {optB}')

if __name__ == '__main__':
    main()

Do you guys think this is something desirable/possible ?

acoque avatar Jan 02 '23 14:01 acoque

Although there are many workarounds, none of these will give a helpful message to users about the right syntax the command will accept. Like the example below (autogenerated by argparse with a mutually exclusive group):

usage: MyCommand [-h] [-V | -v | -q] [-f | --fresh | --no-fresh] [--ptt | --no-ptt] [-p NAME]

To me this is a strong argument Typer needs to incorporate this functionality. My current solution is to check at runtime that no options violating the exclusivity constraints have been provided and error out if they have.

MrBrunoSilva avatar Mar 29 '23 01:03 MrBrunoSilva

RE: quiet/verbose flags, has anyone else run into any issues implementing as described in the docs?

I have a callback defined as follows:

@app.callback(invoke_without_command=True)
def callback(
    version: Annotated[bool, typer.Option("--version", "-t", is_eager=True)] = None,
    verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
    quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
    ):
    if version:
        from monarch_py import __version__
        typer.echo(f"monarch_py version: {__version__}")
        raise typer.Exit()
    elif verbose > 0 and quiet:
        raise typer.BadOptionUsage("--verbose", "Cannot be used with --quiet.")
    elif quiet:
        app_state["log_level"] = "ERROR"
    else:
        app_state["log_level"] = "WARN" if verbose == 0 else "INFO" if verbose == 1 else "DEBUG"
    typer.secho(f"Verbose: {verbose}\nLog Level: {app_state['log_level']}", fg=typer.colors.MAGENTA)

But when I try to run monarch --verbose test i get:

><glass@rocinante> monarch --verbose test
Usage: monarch [OPTIONS] COMMAND [ARGS]...
Try 'monarch --help' for help.
╭─ Error ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Invalid value for '--verbose': 'test' is not a valid integer.                                                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Additionally, it seems to be ignoring short options:

><glass@rocinante> monarch -v test
Usage: monarch [OPTIONS] COMMAND [ARGS]...
Try 'monarch --help' for help.
╭─ Error ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ No such option: -v                                                                                                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

However, --version still works as expected:

><glass@rocinante> monarch --version
monarch_py version: 0.9.4

glass-ships avatar Jun 13 '23 19:06 glass-ships