Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement list parsing from string with separators. #800

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/tutorial/multiple-values/multiple-options.md
Expand Up @@ -87,3 +87,51 @@ The sum is 9.5
```

</div>

## Passing multiple values in a single argument

Many users expect to be able to pass multiple arguments with a single .
libklein marked this conversation as resolved.
Show resolved Hide resolved
**Typer** supports this with the `multiple_separator` option for `typing.List[T]` types:

=== "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:

<div class="termy">

```console
$ python main.py

The sum is 0

// Behaves
$ python main.py --number 2

The sum is 2.0

// Values to the argument are split using the passed separator
$ python main.py --number "2, 3, 4.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
```

</div>

!!! warning
Only single-character non-whitespace separators are supported. Note that passing `--number 2, 3, 4.5` (without wrapping the value in "") does not work.
11 changes: 11 additions & 0 deletions 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([], multiple_separator=",")):
print(f"The sum is {sum(number)}")


if __name__ == "__main__":
typer.run(main)
12 changes: 12 additions & 0 deletions 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(multiple_separator=",")] = []):
print(f"The sum is {sum(number)}")


if __name__ == "__main__":
typer.run(main)
1 change: 1 addition & 0 deletions pyproject.toml
Expand Up @@ -175,6 +175,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"]
Expand Down
30 changes: 30 additions & 0 deletions tests/test_others.py
Expand Up @@ -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(..., multiple_separator="\t \n")):
pass # pragma: no cover

with pytest.raises(typer.UnsupportedMultipleSeparatorError) as exc_info:
runner.invoke(app, [])
assert (
str(exc_info.value)
== "Error in definition of Option 'names'. Separator \"\t \n\" is not supported for multiple value splitting."
)


def test_multiple_options_separator_2_non_list_type():
app = typer.Typer()

@app.command()
def main(names: str = typer.Option(..., multiple_separator=",")):
pass # pragma: no cover

with pytest.raises(typer.MultipleSeparatorForNonListTypeError) 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."
)
@@ -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
@@ -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
6 changes: 6 additions & 0 deletions typer/__init__.py
Expand Up @@ -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 (
MultipleSeparatorForNonListTypeError as MultipleSeparatorForNonListTypeError,
)
from .utils import (
UnsupportedMultipleSeparatorError as UnsupportedMultipleSeparatorError,
)
16 changes: 16 additions & 0 deletions typer/core.py
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -419,6 +421,7 @@ def __init__(
show_envvar: bool = False,
# Rich settings
rich_help_panel: Union[str, None] = None,
multiple_separator: Optional[str] = None,
libklein marked this conversation as resolved.
Show resolved Hide resolved
):
super().__init__(
param_decls=param_decls,
Expand Down Expand Up @@ -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.multiple_separator = multiple_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.multiple_separator))
return values

def process_value(self, ctx: Context, value: t.Any) -> t.Any:
if self.multiple_separator is not None:
value = self._parse_separated_parameter_list(value)
return super().process_value(ctx, value)

def _get_default_string(
self,
Expand Down
21 changes: 20 additions & 1 deletion typer/main.py
Expand Up @@ -34,7 +34,11 @@
Required,
TyperInfo,
)
from .utils import get_params_from_function
from .utils import (
MultipleSeparatorForNonListTypeError,
UnsupportedMultipleSeparatorError,
get_params_from_function,
)

try:
import rich
Expand Down Expand Up @@ -884,6 +888,20 @@ 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
multiple_separator = None
if parameter_info.multiple_separator:
multiple_separator = parameter_info.multiple_separator.strip()

if not is_list:
raise MultipleSeparatorForNonListTypeError(param.name, main_type)

if len(multiple_separator) != 1:
raise UnsupportedMultipleSeparatorError(
param.name, parameter_info.multiple_separator
)

return (
TyperOption(
# Option
Expand Down Expand Up @@ -917,6 +935,7 @@ def get_click_param(
autocompletion=get_param_completion(parameter_info.autocompletion),
# Rich settings
rich_help_panel=parameter_info.rich_help_panel,
multiple_separator=multiple_separator,
),
convertor,
)
Expand Down
2 changes: 2 additions & 0 deletions typer/models.py
Expand Up @@ -331,6 +331,7 @@ def __init__(
path_type: Union[None, Type[str], Type[bytes]] = None,
# Rich settings
rich_help_panel: Union[str, None] = None,
multiple_separator: Optional[str] = None,
):
super().__init__(
default=default,
Expand Down Expand Up @@ -386,6 +387,7 @@ def __init__(
self.flag_value = flag_value
self.count = count
self.allow_from_autoenv = allow_from_autoenv
self.multiple_separator = multiple_separator


class ArgumentInfo(ParameterInfo):
Expand Down
3 changes: 3 additions & 0 deletions typer/params.py
Expand Up @@ -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
multiple_separator: Optional[str] = None,
) -> Any:
return OptionInfo(
# Parameter
Expand Down Expand Up @@ -250,6 +252,7 @@ def Option(
path_type=path_type,
# Rich settings
rich_help_panel=rich_help_panel,
multiple_separator=multiple_separator,
)


Expand Down
27 changes: 27 additions & 0 deletions typer/utils.py
Expand Up @@ -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 MultipleSeparatorForNonListTypeError(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 UnsupportedMultipleSeparatorError(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'Separator "{self.separator}" is not supported for multiple value splitting.'
)