diff --git a/docs/tutorial/multiple-values/multiple-options.md b/docs/tutorial/multiple-values/multiple-options.md index 08c9e80ff2..d6c16b8a92 100644 --- a/docs/tutorial/multiple-values/multiple-options.md +++ b/docs/tutorial/multiple-values/multiple-options.md @@ -63,3 +63,49 @@ 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: + +{* docs_src/multiple_values/multiple_options/tutorial003_an.py hl[10] *} + +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..ba973ad5f9 --- /dev/null +++ b/docs_src/multiple_values/multiple_options/tutorial003.py @@ -0,0 +1,14 @@ +from typing import List + +import typer + +app = typer.Typer() + + +@app.command() +def main(number: List[float] = typer.Option([], separator=",")): + print(f"The sum is {sum(number)}") + + +if __name__ == "__main__": + app() 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..abdcf4e610 --- /dev/null +++ b/docs_src/multiple_values/multiple_options/tutorial003_an.py @@ -0,0 +1,15 @@ +from typing import List + +import typer +from typing_extensions import Annotated + +app = typer.Typer() + + +@app.command() +def main(number: Annotated[List[float], typer.Option(separator=",")] = []): + print(f"The sum is {sum(number)}") + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index ac89bbd9bb..26e2c31634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,6 +184,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 a8ba207a5f..f1846d3078 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -321,3 +321,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..d09150eb8d --- /dev/null +++ b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003.py @@ -0,0 +1,43 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.multiple_values.multiple_options import tutorial003 as mod + +app = mod.app + +runner = CliRunner() + + +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..e7ae10cb28 --- /dev/null +++ b/tests/test_tutorial/test_multiple_values/test_multiple_options/test_tutorial003_an.py @@ -0,0 +1,43 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.multiple_values.multiple_options import tutorial003_an as mod + +app = mod.app + +runner = CliRunner() + + +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 a2e897cdd7..726e03595a 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 e9631e56cf..aa22a638c6 100644 --- a/typer/core.py +++ b/typer/core.py @@ -3,6 +3,7 @@ import inspect import os import sys +import typing as t from difflib import get_close_matches from enum import Enum from gettext import gettext as _ @@ -26,6 +27,7 @@ import click.shell_completion import click.types import click.utils +from click import Context from ._typing import Literal @@ -446,6 +448,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, @@ -475,6 +478,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 2e3b1223cc..4429893768 100644 --- a/typer/main.py +++ b/typer/main.py @@ -48,7 +48,11 @@ TyperInfo, TyperPath, ) -from .utils import get_params_from_function +from .utils import ( + SeparatorForNonListTypeError, + UnsupportedSeparatorError, + get_params_from_function, +) _original_except_hook = sys.excepthook _typer_developer_exception_attr_name = "__typer_developer_exception__" @@ -896,6 +900,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 @@ -928,6 +944,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 e0bddb965b..8bbd7f6410 100644 --- a/typer/models.py +++ b/typer/models.py @@ -336,6 +336,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, @@ -398,6 +399,7 @@ def __init__( self.hide_input = hide_input 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 66c2b32d3e..ac3c55962c 100644 --- a/typer/params.py +++ b/typer/params.py @@ -202,6 +202,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 @@ -257,6 +259,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 81dc4dd61d..c9a47dbb54 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -188,3 +188,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}".' + )