-
-
Notifications
You must be signed in to change notification settings - Fork 702
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
[QUESTION] How to handle mutually exclusive options #140
Comments
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 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 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. 🗡️ |
Are there any new solutions available in typer for this problem yet? |
Hi all, any update on this? |
Just as an additional workaround, you might be able to use subcommands instead. E.g. if the mutually exclusive options are
you will have
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. |
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 $ 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}") |
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 how were you envisioning the typer to create the groups? |
@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., I mean this in the context of a new feature since this the only way to do it now. |
Ah okay @daddycocoaman, I see what you are saying. I could take a crack at implementing this using 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? |
If there's a way to expose the functionality of click-option-group, that might be a good way to achieve this. |
Just adding another "I'd like to see this too" comment. |
My current use case: Options |
@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.
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 That way, you can still say quiet, default, and verbose, clever! |
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 |
This also adds a mechanism for creating mutual exclusion groups for options since Typer doesn't currently provide this functionality (see fastapi/typer#140).
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 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 ? |
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. |
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
Additionally, it seems to be ignoring short options:
However,
|
Hi all, is there any update on this topic? |
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
vs
|
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: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!
The text was updated successfully, but these errors were encountered: