Textualize / trogon

Easily turn your Click CLI into a powerful terminal application
MIT License
2.5k stars 54 forks source link

Feature request: Default Enum choices from Typer #69

Closed daneah closed 1 month ago

daneah commented 11 months ago

[!NOTE] I am aware that Trogon is currently geared toward Click, but having seen a bit of past discussion on Typer I wanted to raise this as a rough edge stopping me from using Trogon full bore. Thanks for making a great tool, and if I can provide more clarity please let me know!

Whereas Click recommends using click.Choice(["one", "two", ...]), Typer recommends using something like the following:

from typing_extensions import Annotated

class SomeChoiceType(str, Enum):
    ONE = "one"
    TWO = "two"

some_app = typer.Typer()

@some_app.command
def some_command(some_option: Annotated[SomeChoiceType] = SomeChoiceType.ONE):
    ...

This all works great in Typer land, and Trogon will start up okay. But once one navigates to the some_command command in the Trogon TUI, it currently spits out an exception like the following:

╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ /path/to/python3.11/site-packages/textual/widget.py:3325                                         │
│ in _on_compose                                                                                   │
│                                                                                                  │
│   3322 │                                                                                         │
│   3323 │   async def _on_compose(self) -> None:                                                  │
│   3324 │   │   try:                                                                              │
│ ❱ 3325 │   │   │   widgets = [*self._nodes, *compose(self)]                                      │
│   3326 │   │   except TypeError as error:                                                        │
│   3327 │   │   │   raise TypeError(                                                              │
│   3328 │   │   │   │   f"{self!r} compose() method returned an invalid result; {error}"          │
│                                                                                                  │
│ /path/to/python3.11/site-packages/trogon/widgets/parameter_controls.py:163 in compose            │
│                                                                                                  │
│   160 │   │   │   │   │   │   for default_value, control_widget in zip(                          │
│   161 │   │   │   │   │   │   │   default_value_tuple, widget_group                              │
│   162 │   │   │   │   │   │   ):                                                                 │
│ ❱ 163 │   │   │   │   │   │   │   self._apply_default_value(control_widget, default_value)       │
│   164 │   │   │   │   │   │   │   yield control_widget                                           │
│   165 │   │   │   │   │   │   │   # Keep track of the first control we render, for easy focus    │
│   166 │   │   │   │   │   │   │   if first_focus_control is None:                                │
│                                                                                                  │
│ /path/to/python3.11/site-packages/trogon/widgets/parameter_controls.py:247                       |
|  in _apply_default_value                                                                         │
│                                                                                                  │
│   244 │   │   │   control_widget.value = str(default_value)                                      │
│   245 │   │   │   control_widget.placeholder = f"{default_value} (default)"                      │
│   246 │   │   elif isinstance(control_widget, Select):                                           │
│ ❱ 247 │   │   │   control_widget.value = str(default_value)                                      │
│   248 │   │   │   control_widget.prompt = f"{default_value} (default)"                           │
│   249 │                                                                                          │
│   250 │   @staticmethod                                                                          │
│                                                                                                  │
│ /path/to/python3.11/site-packages/textual/widgets/_select.py:387 in _validate_value              |
│                                                                                                  │
│   384 │   │   │   # so we provide a helpful message to catch this mistake in case people didn'   │
│   385 │   │   │   # realise we use a special value to flag "no selection".                       │
│   386 │   │   │   help_text = " Did you mean to use Select.clear()?" if value is None else ""    │
│ ❱ 387 │   │   │   raise InvalidSelectValueError(                                                 │
│   388 │   │   │   │   f"Illegal select value {value!r}." + help_text                             │
│   389 │   │   │   )                                                                              │
│   390                                                                                            │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
InvalidSelectValueError: Illegal select value 'SomeChoiceType.ONE'.

This appears to occur specifically when one of the enum choices is used as a default. If the Enum is used to restrict a required argument, or an option where the default is e.g. None, Trogon works as expected.

I understand that one would need to inject a .value there somewhere to get at the actual string that the Enum is referencing, which I imagine is fully within Trogon's control to be doing.

I haven't looked at the parameter conversion code base, so I'm fully willing to hear that knowing about Enums and doing something special with them is against Trogon's design!

Here is a small reproduction if that is helpful.

daneah commented 11 months ago

A workaround for this is to override the Enum class's __str__ method to return the string representation of the enum value:

from typing_extensions import Annotated

class SomeChoiceType(str, Enum):
    ONE = "one"
    TWO = "two"

    def __str__(self):
        return str(self.value)

some_app = typer.Typer()

@some_app.command
def some_command(some_option: Annotated[SomeChoiceType] = SomeChoiceType.ONE):
    ...

This continues allowing Typer to help with finding type safety issues while allowing Trogon to construct a Textual Select appropriately.

daneah commented 1 month ago

I've been using the workaround above which feels pretty natural in practice now; I'm not sure there's a clear ask from the Trogon side of the fence on it, at this point. The only thing I can think of would be that Trogon could call .value if it detects that the object in question is an Enum, but that isn't guaranteed to be a string either.