diff --git a/docs/tutorial/options/optional_value.md b/docs/tutorial/options/optional_value.md new file mode 100644 index 0000000000..d200819e5c --- /dev/null +++ b/docs/tutorial/options/optional_value.md @@ -0,0 +1,65 @@ +# Optional value for CLI Options + +As in Click, providing a value to a *CLI option* can be made optional, in which case a default value will be used instead. + +To make a *CLI option*'s value optional, you can annotate it as a *Union* of types *bool* and the parameter type. + +/// info + +You can create a type Union by importing *Union* from the typing module. + +For example `Union[bool, str]` represents a type that is either a boolean or a string. + +You can also use the equivalent notation `bool | str` + +/// + +Let's add a *CLI option* `--tone` with optional value: + +{* docs_src/options/optional_value/tutorial001_an.py hl[5] *} + +Now, there are three possible configurations: + +* `--greeting` is not used, the parameter will receive a value of `False`. +``` +python main.py +``` + +* `--greeting` is supplied with a value, the parameter will receive the string representation of that value. +``` +python main.py --greeting +``` + +* `--greeting` is used with no value, the parameter will receive the default `formal` value. +``` +python main.py --greeting +``` + + +And test it: + +
+ +```console +$ python main.py Camila Gutiérrez + +// We didn't pass the greeting CLI option, we get no greeting + + +// Now update it to pass the --greeting CLI option with default value +$ python main.py Camila Gutiérrez --greeting + +Hello Camila Gutiérrez + +// The above is equivalent to passing the --greeting CLI option with value `formal` +$ python main.py Camila Gutiérrez --greeting formal + +Hello Camila Gutiérrez + +// But you can select another value +$ python main.py Camila Gutiérrez --greeting casual + +Hi Camila ! +``` + +
diff --git a/docs_src/options/optional_value/tutorial001.py b/docs_src/options/optional_value/tutorial001.py new file mode 100644 index 0000000000..d731afaece --- /dev/null +++ b/docs_src/options/optional_value/tutorial001.py @@ -0,0 +1,19 @@ +import typer + + +def main(name: str, lastname: str, greeting: bool | str = "formal"): + if not greeting: + return + + if greeting == "formal": + print(f"Hello {name} {lastname}") + + elif greeting == "casual": + print(f"Hi {name} !") + + else: + raise ValueError(f"Invalid greeting '{greeting}'") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/options/optional_value/tutorial001_an.py b/docs_src/options/optional_value/tutorial001_an.py new file mode 100644 index 0000000000..ecd097d066 --- /dev/null +++ b/docs_src/options/optional_value/tutorial001_an.py @@ -0,0 +1,22 @@ +import typer +from typing_extensions import Annotated + + +def main( + name: str, lastname: str, greeting: Annotated[bool | str, typer.Option()] = "formal" +): + if not greeting: + return + + if greeting == "formal": + print(f"Hello {name} {lastname}") + + elif greeting == "casual": + print(f"Hi {name} !") + + else: + raise ValueError(f"Invalid greeting '{greeting}'") + + +if __name__ == "__main__": + typer.run(main) diff --git a/mkdocs.yml b/mkdocs.yml index 042d7ad116..e1bbfc6d59 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,7 @@ nav: - tutorial/options/index.md - tutorial/options/help.md - tutorial/options/required.md + - tutorial/options/optional_value.md - tutorial/options/prompt.md - tutorial/options/password.md - tutorial/options/name.md diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 904a686d2e..0102ac1d2d 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -1,11 +1,12 @@ from enum import Enum from pathlib import Path -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, Tuple, Union import click import pytest import typer from typer.testing import CliRunner +from typing_extensions import Annotated from .utils import needs_py310 @@ -169,3 +170,101 @@ def custom_click_type( result = runner.invoke(app, ["0x56"]) assert result.exit_code == 0 + + +class TestOptionAcceptsOptionalValue: + def test_enum(self): + app = typer.Typer() + + class OptEnum(str, Enum): + val1 = "val1" + val2 = "val2" + + @app.command() + def cmd(opt: Annotated[Union[bool, OptEnum], typer.Option()] = OptEnum.val1): + if opt is False: + print("False") + + else: + print(opt.value) + + result = runner.invoke(app) + assert result.exit_code == 0, result.output + assert "False" in result.output + + result = runner.invoke(app, ["--opt"]) + assert result.exit_code == 0, result.output + assert "val1" in result.output + + result = runner.invoke(app, ["--opt", "val1"]) + assert result.exit_code == 0, result.output + assert "val1" in result.output + + result = runner.invoke(app, ["--opt", "val2"]) + assert result.exit_code == 0, result.output + assert "val2" in result.output + + result = runner.invoke(app, ["--opt", "val3"]) + assert result.exit_code != 0 + assert "Invalid value for '--opt': 'val3' is not one of" in result.output + + result = runner.invoke(app, ["--opt", "0"]) + assert result.exit_code == 0, result.output + assert "False" in result.output + + result = runner.invoke(app, ["--opt", "1"]) + assert result.exit_code == 0, result.output + assert "val1" in result.output + + def test_int(self): + app = typer.Typer() + + @app.command() + def cmd(opt: Annotated[Union[bool, int], typer.Option()] = 1): + print(opt) + + result = runner.invoke(app) + assert result.exit_code == 0, result.output + assert "False" in result.output + + result = runner.invoke(app, ["--opt"]) + assert result.exit_code == 0, result.output + assert "1" in result.output + + result = runner.invoke(app, ["--opt", "2"]) + assert result.exit_code == 0, result.output + assert "2" in result.output + + result = runner.invoke(app, ["--opt", "test"]) + assert result.exit_code != 0 + assert ( + "Invalid value for '--opt': 'test' is not a valid integer" in result.output + ) + + result = runner.invoke(app, ["--opt", "true"]) + assert result.exit_code == 0, result.output + assert "1" in result.output + + result = runner.invoke(app, ["--opt", "off"]) + assert result.exit_code == 0, result.output + assert "False" in result.output + + def test_path(self): + app = typer.Typer() + + @app.command() + def cmd(opt: Annotated[Union[bool, Path], typer.Option()] = Path(".")): + if isinstance(opt, Path): + print((opt / "file.py").as_posix()) + + result = runner.invoke(app, ["--opt"]) + assert result.exit_code == 0, result.output + assert "file.py" in result.output + + result = runner.invoke(app, ["--opt", "/test/path/file.py"]) + assert result.exit_code == 0, result.output + assert "/test/path/file.py" in result.output + + result = runner.invoke(app, ["--opt", "False"]) + assert result.exit_code == 0, result.output + assert "file.py" not in result.output diff --git a/typer/core.py b/typer/core.py index 8ec8b4b95d..f95f095779 100644 --- a/typer/core.py +++ b/typer/core.py @@ -420,6 +420,7 @@ def __init__( prompt_required: bool = True, hide_input: bool = False, is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, multiple: bool = False, count: bool = False, allow_from_autoenv: bool = True, @@ -446,6 +447,7 @@ def __init__( confirmation_prompt=confirmation_prompt, hide_input=hide_input, is_flag=is_flag, + flag_value=flag_value, multiple=multiple, count=count, allow_from_autoenv=allow_from_autoenv, diff --git a/typer/main.py b/typer/main.py index 55d865c780..93bb0b378f 100644 --- a/typer/main.py +++ b/typer/main.py @@ -11,11 +11,21 @@ from pathlib import Path from traceback import FrameSummary, StackSummary from types import TracebackType -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, +) from uuid import UUID import click -from typing_extensions import get_args, get_origin +from typing_extensions import get_args, get_origin, override from ._typing import is_union from .completion import get_completion_inspect_parameters @@ -619,30 +629,52 @@ def get_command_from_info( return command -def determine_type_convertor(type_: Any) -> Optional[Callable[[Any], Any]]: +def determine_type_convertor( + type_: Any, skip_bool: bool = False +) -> Optional[Callable[[Any], Any]]: convertor: Optional[Callable[[Any], Any]] = None if lenient_issubclass(type_, Path): - convertor = param_path_convertor + convertor = generate_path_convertor(skip_bool) if lenient_issubclass(type_, Enum): - convertor = generate_enum_convertor(type_) + convertor = generate_enum_convertor(type_, skip_bool) return convertor -def param_path_convertor(value: Optional[str] = None) -> Optional[Path]: - if value is not None: +def generate_path_convertor( + skip_bool: bool = False, +) -> Callable[[Any], Union[None, bool, Path]]: + def convertor(value: Optional[str] = None) -> Union[None, bool, Path]: + if value is None: + return None + + if isinstance(value, bool) and skip_bool: + return value + return Path(value) - return None + + return convertor -def generate_enum_convertor(enum: Type[Enum]) -> Callable[[Any], Any]: +def generate_enum_convertor( + enum: Type[Enum], skip_bool: bool = False +) -> Callable[[Any], Union[None, bool, Enum]]: val_map = {str(val.value): val for val in enum} - def convertor(value: Any) -> Any: - if value is not None: - val = str(value) - if val in val_map: - key = val_map[val] - return enum(key) + def convertor(value: Any) -> Union[bool, Enum]: + if isinstance(value, bool) and skip_bool: + return value + + if isinstance(value, enum): + return value + + val = str(value) + if val in val_map: + key = val_map[val] + return enum(key) + + raise click.BadParameter( + f"Invalid value '{value}' for enum '{enum.__name__}'" + ) # pragma: no cover return convertor @@ -809,6 +841,50 @@ def lenient_issubclass( return isinstance(cls, type) and issubclass(cls, class_or_tuple) +class DefaultOption(click.ParamType): + def __init__(self, type_: click.ParamType, default: Any) -> None: + self._type: click.ParamType = type_ + self._default: Any = default + self.name: str = f"BOOLEAN|{type_.name}" + + @override + def convert( + self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context] + ) -> Any: + str_value = str(value).strip().lower() + + if str_value in {"True", "true", "t", "yes", "y", "on"}: + return self._default + + if str_value in {"False", "false", "f", "no", "n", "off"}: + return False + + if isinstance(value, DefaultFalse): + return False + + try: + return self._type.convert(value, param, ctx) + + except click.BadParameter as e: + fail = e + + if str_value == "1": + return self._default + + if str_value == "0": + return False + + raise fail + + +class DefaultFalse: + def __init__(self, value: Any) -> None: + self._value = value + + def __str__(self) -> str: + return f"False ({str(self._value)})" + + def get_click_param( param: ParamMeta, ) -> Tuple[Union[click.Argument, click.Option], Any]: @@ -836,10 +912,12 @@ def get_click_param( else: annotation = str main_type = annotation + secondary_type: Union[Type[bool], None] = None is_list = False is_tuple = False parameter_type: Any = None is_flag = None + flag_value: Any = None origin = get_origin(main_type) if origin is not None: @@ -850,7 +928,17 @@ def get_click_param( if type_ is NoneType: continue types.append(type_) - assert len(types) == 1, "Typer Currently doesn't support Union types" + + if len(types) == 1: + main_type = types[0] + + else: + types = sorted(types, key=lambda t: t is bool) + main_type, secondary_type, *union_types = types + assert ( + not len(union_types) and secondary_type is bool + ), "Typer Currently doesn't support Union types" + main_type = types[0] origin = get_origin(main_type) # Handle Tuples and Lists @@ -875,7 +963,7 @@ def get_click_param( parameter_type = get_click_type( annotation=main_type, parameter_info=parameter_info ) - convertor = determine_type_convertor(main_type) + convertor = determine_type_convertor(main_type, skip_bool=secondary_type is bool) if is_list: convertor = generate_list_convertor( convertor=convertor, default_value=default_value @@ -888,6 +976,14 @@ def get_click_param( # Click doesn't accept a flag of type bool, only None, and then it sets it # to bool internally parameter_type = None + + elif secondary_type is bool: + is_flag = False + flag_value = default_value + assert parameter_type is not None + parameter_type = DefaultOption(parameter_type, default=default_value) + default_value = DefaultFalse(default_value) + default_option_name = get_command_name(param.name) if is_flag: default_option_declaration = ( @@ -910,6 +1006,7 @@ def get_click_param( prompt_required=parameter_info.prompt_required, hide_input=parameter_info.hide_input, is_flag=is_flag, + flag_value=flag_value, multiple=is_list, count=parameter_info.count, allow_from_autoenv=parameter_info.allow_from_autoenv,