fastapi / typer

Typer, build great CLIs. Easy to code. Based on Python type hints.
https://typer.tiangolo.com/
MIT License
15.76k stars 671 forks source link

[QUESTION] How to handle mutually exclusive options #140

Open bsamseth opened 4 years ago

bsamseth commented 4 years ago

First check

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!

daddycocoaman commented 3 years ago

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. 🗡️

nicksspirit commented 3 years ago

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

ferreteleco commented 3 years ago

Hi all, any update on this?

pmav99 commented 3 years ago

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.

nicksspirit commented 3 years ago

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}")
danielbraun89 commented 3 years ago

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

daddycocoaman commented 3 years ago

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.

nicksspirit commented 3 years ago

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

daddycocoaman commented 3 years ago

@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.

nicksspirit commented 3 years ago

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?

DanLipsitt commented 2 years ago

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

robinbowes commented 2 years ago

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

dd-ssc commented 2 years ago

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.

cj81499 commented 2 years ago

@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.

dd-ssc commented 2 years ago

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

cj81499 commented 2 years ago

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

dd-ssc commented 2 years ago

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).

acoque commented 1 year ago

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 ?

MrBrunoSilva commented 1 year ago

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.

glass-ships commented 1 year ago

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
Tazoeur commented 4 months ago

Hi all, is there any update on this topic?

AchilleSoulieID commented 2 months ago

A workaround I found, based on the proposition of @DanLipsitt to use click-option-group, leverages the ability to use a click app in a typer app (see official doc Including a Click app in a Typer app):

import click
import typer
from click_option_group import RequiredMutuallyExclusiveOptionGroup, optgroup

app = typer.Typer()

@app.command()
def top():
    """
    Top level command, form Typer
    """
    print("The Typer app is at the top level")

@app.callback()
def callback():
    """
    Typer app, including Click subapp
    """

@click.command()
@optgroup.group(
    "Exclusive options",
    cls=RequiredMutuallyExclusiveOptionGroup,
    help="Exclusive options, choose wisely.",
)
@optgroup.option("--optA", type=str, help="Option A")
@optgroup.option("--optB", type=str, help="Option B")
def hello(**params):
    print(params)

typer_click_object = typer.main.get_command(app)

typer_click_object.add_command(hello, "hello")

if __name__ == "__main__":
    typer_click_object()

If you run this, you get the following outputs :

The only downside I saw at the moment was that the output of the click app --help has lost the typer formatting :

>   python test.py hello --help                         
Usage: test.py hello [OPTIONS]

Options:
  Exclusive options: [mutually_exclusive, required]
                                  Exclusive options, choose wisely.
    --optA TEXT                   Option A
    --optB TEXT                   Option B
  --help                          Show this message and exit.

vs

> python test.py top --help  

 Usage: test.py top [OPTIONS]                                                                                                                                                            

 Top level command, form Typer                                                                                                                                                           

╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                                                                                                           │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯