+```
+
+* `--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,