Open bsamseth opened 4 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. 🗡️
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 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.
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}")
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
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., 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.
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?
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 --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 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: Thanks for your advice, much appreciated! 👍🏻. I use a --log-level
Option now that uses an enum.
@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 --log=INFO
code bit).
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 ?
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 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
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 :
> python test.py hello --optA option_A
{'opta': 'option_A', 'optb': None}
> python test.py hello --optB option_B
{'optb': 'option_B', 'opta': None}
> python test.py hello --optA option_A --optB option_B
Usage: test.py hello [OPTIONS]
Try 'test.py hello --help' for help.
╭─ Error ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Mutually exclusive options from 'Exclusive options' option group cannot be used at the same time: │
│ '--optA' │
│ '--optB' │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
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. │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
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!