fastapi / typer

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

[FEATURE] Define choices via dictionary instead of enum #241

Open codethief opened 3 years ago

codethief commented 3 years ago

I have a (dynamic) dictionary mapping strings (names) to more complicated objects. I want to define a Typer argument (or option) that allows the user to choose one of those objects by providing the corresponding name.

The solution I would like

Pseudo-code:

networks: Dict[str, NeuralNetwork] = {}
# <Add entries to networks here>

def main(
    network: NeuralNetwork = typer.Argument(choices=networks)
):
    ...

Basically, I would like to tell Typer to use the dictionary's string keys as the possible values the user can choose from on the command line and then map them to the corresponding NeuralNetwork object when invoking main().

Alternatives I've considered

Since Typer currently doesn't support Union types (let alone dynamically created ones), the only alternative with proper type checking and auto-completion that I've found is the following:

networks: Dict[str, NeuralNetwork] = {}
# <Add entries to networks here>

# Use Enum's functional interface to dynamically create one
NetworkEnum = Enum(
    "NetworkEnum",
    names=[ (name, network) for name, network in networks.items() ],  // EDIT: This needs to read (name, name), see vincentqb's comment below
    module=__name__,
)

def main(
    network: NetworkEnum
):
    the_network = networks[network.value]
    ...

While this works, it requires boilerplate code and is much less readable.

vincentqb commented 1 year ago

Thanks for the code example! I tried it, but I get an error. Thoughts?

min.py:

import typer

from enum import Enum
from typing import Dict

class A:
    pass

a = A()
networks: Dict[str, A] = {"A": a}
NetworkEnum = Enum(
    "NetworkEnum",
    names=[(name, network) for name, network in networks.items()],
    module=__name__,
)
app = typer.Typer()

@app.command()
def main(network: NetworkEnum):
    the_network = networks[network.value]
    print(the_network)

if __name__ == "__main__":
    app()  
❯ python min.py --help
Traceback (most recent call last):
  File "min.py", line 33, in <module>
    app()
  File "/Users/user/anaconda3/lib/python3.8/site-packages/typer/main.py", line 214, in __call__
    return get_command(self)(*args, **kwargs)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1128, in __call__
    return self.main(*args, **kwargs)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1052, in main
    with self.make_context(prog_name, args, **extra) as ctx:
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 914, in make_context
    self.parse_args(ctx, args)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1370, in parse_args
    value, args = param.handle_parse_result(ctx, opts, args)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 2347, in handle_parse_result
    value = self.process_value(ctx, value)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 2309, in process_value
    value = self.callback(ctx, self, value)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1270, in show_help
    echo(ctx.get_help(), color=ctx.color)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 693, in get_help
    return self.command.get_help(self)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1295, in get_help
    self.format_help(ctx, formatter)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1324, in format_help
    self.format_usage(ctx, formatter)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1239, in format_usage
    pieces = self.collect_usage_pieces(ctx)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 1249, in collect_usage_pieces
    rv.extend(param.get_usage_pieces(ctx))
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/core.py", line 2947, in get_usage_pieces
    return [self.make_metavar()]
  File "/Users/user/anaconda3/lib/python3.8/site-packages/typer/core.py", line 185, in make_metavar
    type_var = self.type.get_metavar(self)
  File "/Users/user/anaconda3/lib/python3.8/site-packages/click/types.py", line 248, in get_metavar
    choices_str = "|".join(self.choices)
TypeError: sequence item 0: expected str instance, A found

Here's a workaround:

import typer

from enum import Enum
from typing import Dict

class A:
    pass

a = A()
networks: Dict[str, A] = {"A": a}
NetworkEnum = Enum(
    "NetworkEnum",
    names=[(name, name) for name in networks.keys()],
    module=__name__,
)
app = typer.Typer()

@app.command()
def main(network: NetworkEnum):
    the_network = networks[network.value]
    print(the_network)

if __name__ == "__main__":
    app()

which results in

❯ python min.py --help
Usage: min.py [OPTIONS] NETWORK:{A}

Arguments:
  NETWORK:{A}  [required]

Options:
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell, to copy it or
                        customize the installation.
  --help                Show this message and exit.
codethief commented 1 year ago

Right, my apologies, this must have been a typo!

The point is basically that, while the existing option to map a string (= user input) to an enum member is nice, it doesn't cover the other classic kind of mapping: dictionaries, i.e. mapping (string) dictionary keys to dictionary values. This is particularly useful when the dictionary is dynamic and cannot be rewritten as an enum (at least not easily – this is what I meant to illustrate with the code example you fixed).

inkychris commented 1 year ago

I feel like this is an extension of the more basic functionality which is the option to specify choices from a list. Of course you can generate an enum but I feel like this is conflicting with the whole point of typer which is to make defining CLIs clean and simple. Similar to the examples provided, we could have:

import typing

import typer

value_options = ['hello', 'world']

def main(value: typing.Annotated[str, typer.Argument(choices=value_options)]):
    print(value)

if __name__ == "__main__":
    typer.run(main)

instead of the current solution

import enum

import typer

value_options = ['hello', 'world']

ValueEnum = enum.Enum('ValueEnum', {
    f'value_{index}': value
    for index, value in enumerate(value_options)
})

def main(value: ValueEnum):
    print(value.value)

if __name__ == "__main__":
    typer.run(main)

I think a big benefit here is that you can correctly type-annotate the value. Related again to this would be support for typing.Literal which I guess could just be syntactic sugar around the first option, although looks like there was an issue (#76) for that which was closed for some reason...:

import typing

import typer

def main(value: typing.Literal['hello', 'world']):
    print(value.value)

if __name__ == "__main__":
    typer.run(main)
jfcherng commented 1 year ago

I feel like this is an extension of the more basic functionality which is the option to specify choices from a list. Of course you can generate an enum but I feel like this is conflicting with the whole point of typer which is to make defining CLIs clean and simple. Similar to the examples provided, we could have: ...

Imho, the point of using enum is friendly for static analysis (including IDE autocompletion) and thus less error-prune. Your codes don't benefit from that. But this does,

import enum

import typer

class ValueEnum(enum.Enum):
    HELLO = "hello"
    WORLD = "world"

def main(value: ValueEnum) -> None:
    print(value.value)

    # What if I have typo here like `ValueEnum.HELLOW`?
    # It will be caught either by Python itself or static analysis tools.
    if value is ValueEnum.HELLO: ...

if __name__ == "__main__":
    typer.run(main)
inkychris commented 1 year ago

@jfcherng That's why I think supporting typing.Literal is particularly useful but that seems to have met several roadblocks. My main point around the use of enums is that there are cases where you will have to construct the enum procedurally rather than define it as a literal. One example might be if the valid choices are defined in a file, or depend on the environment in some way. That use case doesn't work with static typing. When you're in that situation, I think the best you can really do is have the fundamental type annotation, e.g. str, and also have an option to tell typer what the choices are for CLI validation and help text. It's fairly common to find Python APIs that use string values like enums. Certainly for retro-fitting typer to an existing function, it would be a useful option to have rather than having to change your API just to get typer to do what you want.

codethief commented 1 year ago

@jfcherng I agree with @inkychris. Static analysis is not possible here, anyway, because the whole point is to generate the choices dynamically:

This is particularly useful when the dictionary is dynamic and cannot be rewritten as an enum (at least not easily [let alone statically] […])

matanyall commented 4 months ago

Is there any updated guidance for doing this now?