typer icon indicating copy to clipboard operation
typer copied to clipboard

Must use typer.Option to supply default argument with Enums created with functional API

Open Torvaney opened this issue 2 years ago • 6 comments

First Check

  • [X] I added a very descriptive title to this issue.
  • [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 read and followed all the tutorial in the docs and didn't find an answer.
  • [X] I already checked if it is not related to Typer but to Click.

Commit to Help

  • [X] I commit to help with one of those options 👆

Example Code

import enum
import typer

Choice = enum.Enum('Choice', {k: k for k in ['first', 'second']})

def main(option: Choice = Choice.first):
    pass

typer.run(main)

# Usage: typer_reprex.py [OPTIONS]
# Try 'typer_reprex.py --help' for help.
# 
# Error: Invalid value for '--option': <Choice.first: 'first'> is not one of 'first', 'second'.

Description

When an enum is created via the functional API (as above), it cannot be used as a choice constraint, because it won't also subclass str.

In the documentation, choices are created by subclassing both str and enum.Enum. For example:

class Choice(str, Enum):
    first = 'first'
    second = 'second'

However, as far as I know, this isn't possible when using the functional API to create enums.

This can be worked around by supplying typer.Option with the default argument instead:

def main(option: Choice = typer.Option('first')):
    pass

While the workaround is very simple and non-obstructive, it's not clear from the docs that this is the case.

Operating System

macOS

Operating System Details

No response

Typer Version

0.4.1

Python Version

3.7.2

Additional Context

No response

Torvaney avatar May 03 '22 18:05 Torvaney

Funny I just bumped into this today. Would love to see Typer fully handle Enums created with the Functional API, but I think this might be a Python limitation.

Maybe the fix/improvement is to make it known that in order to use Enums defined with the functional API:

  • Need to be an enum that extends both str and Enum (note: looks like StrEnum will be added in Python 3.11 and there's also the StrEnum package here.
  • The default value needs to be a string, and not the Enum (ex. 'first' instead of Choice.first)

If this is brought up in the documentation with an example, I'm thinking folks should understand how it works and be ok with the limitations.

joaonc avatar May 16 '22 21:05 joaonc

Here's a good example that worked for me: having as an input parameter, the log level:

LogLevel = StrEnum('LogLevel', {k: k for k in logging._levelToName.values()})  # noqa

@app.command()
def main(
        log_level: LogLevel = typer.Option('INFO'),
)
    # `str(...)` is not needed, but adding for clarity and avoid linters complaining
    logging.basicConfig(level=str(log_level))

When doing --help all the values for logging will appear, including if you added new ones with logging.addLevelName(...).

joaonc avatar May 17 '22 00:05 joaonc

If this is brought up in the documentation with an example, I'm thinking folks should understand how it works and be ok with the limitations.

I agree - this isn't a major issue, but it would be helpful to have it documented

Torvaney avatar May 17 '22 09:05 Torvaney

I have created a quick PR with some additional documentation on Enums (https://github.com/tiangolo/typer/pull/398) - it would be good to get your thoughts on it, if possible, @joaonc 😄

Torvaney avatar May 17 '22 10:05 Torvaney

Not sure if I'm not off topic, but: what about this example with arguments instead of option ?

#!/usr/bin/env python3

from enum import Enum
import typer
NeuralNetwork = Enum("NeuralNetwork", {k: k for k in ["simple", "conv", "lstm"]})


def main(network: NeuralNetwork = typer.Argument("simple", case_sensitive=False)):
    typer.echo(f"Training neural network of type: {network.value}")

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

When I run the help usage, this is what it shows:

$ ./test.py --help
                                                                             
 Usage: test.py [OPTIONS] [NETWORK]:[simple|conv|lstm]                       
                                                                             
╭─ Arguments ───────────────────────────────────────────────────────────────╮
│   network      [NETWORK]:[simple|conv|lstm]  [default: simple]            │
╰───────────────────────────────────────────────────────────────────────────╯
╭─ 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.                 │
╰───────────────────────────────────────────────────────────────────────────╯

The help usage for the argument is a bit weird, and it does not do it for options.

How do you fix the help usage for Arguments (not Options) ? Also, as default value, if you want to use the Enum, you need to use it this way:

def main(network: NeuralNetwork = typer.Argument(NeuralNetwork.simple.value, case_sensitive=False)):

Because if used without the value:

def main(network: NeuralNetwork = typer.Argument(NeuralNetwork.simple, case_sensitive=False)):

This give you the following help message:

$ ./test.py --help
                                                                                               
 Usage: test.py [OPTIONS] [NETWORK]:[simple|conv|lstm]                                         
                                                                                               
╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────╮
│   network      [NETWORK]:[simple|conv|lstm]  [default: NeuralNetwork.simple]                │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯
...

I think those behaviors should be documented as well, but I'm not sure to fully understand the rational behind this behavior.

mrjk avatar Nov 04 '22 03:11 mrjk

The default value needs to be a string, and not the Enum ... If this is brought up in the documentation with an example, I'm thinking folks should understand how it works and be ok with the limitations.

Hmm I might be misunderstanding, sorry if I'm missing something, but this idea doesn't seem to work:

#!/usr/bin/env python3
from enum import Enum
from typing import Annotated

import typer
from typer import Option


class Color(Enum):
    RED = 'red'
    GREEN = 'green'

def main(color: Annotated[Color, Option(help='the color')] = 'red') -> None:
    print(color)


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

But that's a Mypy violation.

Passing typing.Option(.) as the default arg also doesn't work (this time Mypy is happy, but Typer is not):

#!/usr/bin/env python3
from enum import Enum
from typing import Annotated

import typer
from typer import Option


class Color(Enum):
    RED = 'red'
    GREEN = 'green'

def main(color: Annotated[Color, Option(help='the color')] = Option(Color.RED)) -> None:
    print(color)


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

Running this gives:

MixedAnnotatedAndDefaultStyleError: Cannot specify `Option` in `Annotated` and default value together for 'color'

Garrett-R avatar Jul 04 '23 06:07 Garrett-R