diff --git a/docs/tutorial/multiple-values/multiple-options.md b/docs/tutorial/multiple-values/multiple-options.md index 0c876eab52..ae59013b29 100644 --- a/docs/tutorial/multiple-values/multiple-options.md +++ b/docs/tutorial/multiple-values/multiple-options.md @@ -103,3 +103,61 @@ The sum is 9.5 ``` + +## Passing multiple values in a single argument + +**Typer** supports passing multiple arguments with a single option, by using the `separator` parameter in combination with `typing.List[T]` types. +This feature makes it easy to parse multiple values from a single command-line argument into a list in your application. + +To use this feature, define a command-line option that accepts multiple values separated by a specific character (such as a comma). Here's an example of how to implement this: + +=== "Python 3.7+" + + ```Python hl_lines="7" + {!> ../docs_src/multiple_values/multiple_options/tutorial003_an.py!} + ``` + +=== "Python 3.7+ non-Annotated" + + !!! tip + Prefer to use the `Annotated` version if possible. + + ```Python hl_lines="6" + {!> ../docs_src/multiple_values/multiple_options/tutorial003.py!} + ``` + +Check it: + +
+ +```console +// With no optional CLI argument +$ python main.py + +The sum is 0 + +// With one number argument +$ python main.py --number 2 + +The sum is 2.0 + +// With several number arguments, split using the separator defined by the Option argument +$ python main.py --number "2, 3, 4.5" + +The sum is 9.5 + +// You can remove the quotes if no whitespace is added between the numbers +$ python main.py --number 2,3,4.5 + +The sum is 9.5 + +// Supports passing the option multiple times. This joins all values to a single list +$ python main.py --number 2,3,4.5 --number 5 + +The sum is 14.5 +``` + +
+ +!!! warning + Only single-character non-whitespace separators are supported. diff --git a/docs_src/multiple_values/multiple_options/tutorial003.py b/docs_src/multiple_values/multiple_options/tutorial003.py new file mode 100644 index 0000000000..a8dcfc6927 --- /dev/null +++ b/docs_src/multiple_values/multiple_options/tutorial003.py @@ -0,0 +1,11 @@ +from typing import List + +import typer + + +def main(number: List[float] = typer.Option([], separator=",")): + print(f"The sum is {sum(number)}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/multiple_values/multiple_options/tutorial003_an.py b/docs_src/multiple_values/multiple_options/tutorial003_an.py new file mode 100644 index 0000000000..d29e01c514 --- /dev/null +++ b/docs_src/multiple_values/multiple_options/tutorial003_an.py @@ -0,0 +1,12 @@ +from typing import List + +import typer +from typing_extensions import Annotated + + +def main(number: Annotated[List[float], typer.Option(separator=",")] = []): + print(f"The sum is {sum(number)}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/pyproject.toml b/pyproject.toml index c69e7c190c..ade8e654b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,6 +177,7 @@ ignore = [ # Default mutable data structure "docs_src/options_autocompletion/tutorial006_an.py" = ["B006"] "docs_src/multiple_values/multiple_options/tutorial002_an.py" = ["B006"] +"docs_src/multiple_values/multiple_options/tutorial003_an.py" = ["B006"] "docs_src/options_autocompletion/tutorial007_an.py" = ["B006"] "docs_src/options_autocompletion/tutorial008_an.py" = ["B006"] "docs_src/options_autocompletion/tutorial009_an.py" = ["B006"] diff --git a/tests/test_others.py b/tests/test_others.py index 8c78520029..6e5bee2386 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -256,3 +256,33 @@ def test_split_opt(): prefix, opt = _split_opt("verbose") assert prefix == "" assert opt == "verbose" + + +def test_multiple_options_separator_1_unsupported_separator(): + app = typer.Typer() + + @app.command() + def main(names: typing.List[str] = typer.Option(..., separator="\t \n")): + pass # pragma: no cover + + with pytest.raises(typer.UnsupportedSeparatorError) as exc_info: + runner.invoke(app, []) + assert ( + str(exc_info.value) + == "Error in definition of Option 'names'. Only single-character non-whitespace separators are supported, but got \"\t \n\"." + ) + + +def test_multiple_options_separator_2_non_list_type(): + app = typer.Typer() + + @app.command() + def main(names: str = typer.Option(..., separator=",")): + pass # pragma: no cover + + with pytest.raises(typer.SeparatorForNonListTypeError) as exc_info: + runner.invoke(app, []) + assert ( + str(exc_info.value) + == "Multiple values are supported for List[T] types only. Annotate 'names' as List[str] to support multiple values." + ) diff --git a/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003.py b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003.py new file mode 100644 index 0000000000..638b80b4c5 --- /dev/null +++ b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003.py @@ -0,0 +1,44 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.multiple_values.multiple_options import tutorial003 as mod + +runner = CliRunner() +app = typer.Typer() +app.command()(mod.main) + + +def test_main(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "The sum is 0" in result.output + + +def test_1_number(): + result = runner.invoke(app, ["--number", "2"]) + assert result.exit_code == 0 + assert "The sum is 2.0" in result.output + + +def test_2_number(): + result = runner.invoke(app, ["--number", "2,3,4.5"], catch_exceptions=False) + assert result.exit_code == 0 + assert "The sum is 9.5" in result.output + + +def test_3_number(): + result = runner.invoke(app, ["--number", "2,3,4.5", "--number", "5"]) + assert result.exit_code == 0 + assert "The sum is 14.5" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003_an.py b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003_an.py new file mode 100644 index 0000000000..45dd8c7242 --- /dev/null +++ b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003_an.py @@ -0,0 +1,44 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.multiple_values.multiple_options import tutorial003 as mod + +runner = CliRunner() +app = typer.Typer() +app.command()(mod.main) + + +def test_main(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "The sum is 0" in result.output + + +def test_1_number(): + result = runner.invoke(app, ["--number", "2"]) + assert result.exit_code == 0 + assert "The sum is 2.0" in result.output + + +def test_2_number(): + result = runner.invoke(app, ["--number", "2,3,4.5"]) + assert result.exit_code == 0 + assert "The sum is 9.5" in result.output + + +def test_3_number(): + result = runner.invoke(app, ["--number", "2,3,4.5", "--number", "5"]) + assert result.exit_code == 0 + assert "The sum is 14.5" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/__init__.py b/typer/__init__.py index d4ac56d0ba..e11ce98efc 100644 --- a/typer/__init__.py +++ b/typer/__init__.py @@ -37,3 +37,9 @@ from .models import FileTextWrite as FileTextWrite from .params import Argument as Argument from .params import Option as Option +from .utils import ( + SeparatorForNonListTypeError as SeparatorForNonListTypeError, +) +from .utils import ( + UnsupportedSeparatorError as UnsupportedSeparatorError, +) diff --git a/typer/core.py b/typer/core.py index 31fece5a76..a6e8dadd0c 100644 --- a/typer/core.py +++ b/typer/core.py @@ -2,6 +2,7 @@ import inspect import os import sys +import typing as t from enum import Enum from gettext import gettext as _ from typing import ( @@ -25,6 +26,7 @@ import click.shell_completion import click.types import click.utils +from click import Context if sys.version_info >= (3, 8): from typing import Literal @@ -419,6 +421,7 @@ def __init__( show_envvar: bool = False, # Rich settings rich_help_panel: Union[str, None] = None, + separator: Optional[str] = None, ): super().__init__( param_decls=param_decls, @@ -449,6 +452,19 @@ def __init__( ) _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) self.rich_help_panel = rich_help_panel + self.original_type = type + self.separator = separator + + def _parse_separated_parameter_list(self, parameter_values: List[str]) -> List[str]: + values = [] + for param_str_list in parameter_values: + values.extend(param_str_list.split(self.separator)) + return values + + def process_value(self, ctx: Context, value: t.Any) -> t.Any: + if self.separator is not None: + value = self._parse_separated_parameter_list(value) + return super().process_value(ctx, value) def _get_default_string( self, diff --git a/typer/main.py b/typer/main.py index 9db26975ca..cb5dda7738 100644 --- a/typer/main.py +++ b/typer/main.py @@ -34,7 +34,11 @@ Required, TyperInfo, ) -from .utils import get_params_from_function +from .utils import ( + SeparatorForNonListTypeError, + UnsupportedSeparatorError, + get_params_from_function, +) try: import rich @@ -884,6 +888,18 @@ def get_click_param( param_decls.extend(parameter_info.param_decls) else: param_decls.append(default_option_declaration) + + # Check the multiple separator option for validity + separator = None + if parameter_info.separator: + separator = parameter_info.separator.strip() + + if not is_list: + raise SeparatorForNonListTypeError(param.name, main_type) + + if len(separator) != 1: + raise UnsupportedSeparatorError(param.name, parameter_info.separator) + return ( TyperOption( # Option @@ -917,6 +933,7 @@ def get_click_param( autocompletion=get_param_completion(parameter_info.autocompletion), # Rich settings rich_help_panel=parameter_info.rich_help_panel, + separator=separator, ), convertor, ) diff --git a/typer/models.py b/typer/models.py index 9bbe2a36d2..271f8a38ad 100644 --- a/typer/models.py +++ b/typer/models.py @@ -331,6 +331,7 @@ def __init__( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, + separator: Optional[str] = None, ): super().__init__( default=default, @@ -386,6 +387,7 @@ def __init__( self.flag_value = flag_value self.count = count self.allow_from_autoenv = allow_from_autoenv + self.separator = separator class ArgumentInfo(ParameterInfo): diff --git a/typer/params.py b/typer/params.py index 710a4cf136..d44a6158ff 100644 --- a/typer/params.py +++ b/typer/params.py @@ -195,6 +195,8 @@ def Option( path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, + # Multiple values + separator: Optional[str] = None, ) -> Any: return OptionInfo( # Parameter @@ -250,6 +252,7 @@ def Option( path_type=path_type, # Rich settings rich_help_panel=rich_help_panel, + separator=separator, ) diff --git a/typer/utils.py b/typer/utils.py index 2ba7bace45..e114b273cf 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -190,3 +190,30 @@ def get_params_from_function(func: Callable[..., Any]) -> Dict[str, ParamMeta]: name=param.name, default=default, annotation=annotation ) return params + + +class SeparatorForNonListTypeError(Exception): + argument_name: str + argument_type: Type[Any] + + def __init__(self, argument_name: str, argument_type: Type[Any]): + self.argument_name = argument_name + self.argument_type = argument_type + + def __str__(self) -> str: + return f"Multiple values are supported for List[T] types only. Annotate {self.argument_name!r} as List[{self.argument_type.__name__}] to support multiple values." + + +class UnsupportedSeparatorError(Exception): + argument_name: str + separator: str + + def __init__(self, argument_name: str, separator: str): + self.argument_name = argument_name + self.separator = separator + + def __str__(self) -> str: + return ( + f"Error in definition of Option {self.argument_name!r}. " + f'Only single-character non-whitespace separators are supported, but got "{self.separator}".' + )