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 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/tutorial/multiple-values/multiple-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,61 @@ The sum is 9.5
```

</div>

## 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:

<div class="termy">

```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
```

</div>

!!! warning
Only single-character non-whitespace separators are supported.
11 changes: 11 additions & 0 deletions docs_src/multiple_values/multiple_options/tutorial003.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 12 additions & 0 deletions docs_src/multiple_values/multiple_options/tutorial003_an.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
30 changes: 30 additions & 0 deletions tests/test_others.py
Original file line number Diff line number Diff line change
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(..., 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."
)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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 (
SeparatorForNonListTypeError as SeparatorForNonListTypeError,
)
from .utils import (
UnsupportedSeparatorError as UnsupportedSeparatorError,
)
16 changes: 16 additions & 0 deletions typer/core.py
Original file line number Diff line number Diff line change
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,
separator: Optional[str] = None,
):
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.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,
Expand Down
19 changes: 18 additions & 1 deletion typer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
2 changes: 2 additions & 0 deletions typer/models.py
Original file line number Diff line number Diff line change
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,
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.separator = separator


class ArgumentInfo(ParameterInfo):
Expand Down
3 changes: 3 additions & 0 deletions typer/params.py
Original file line number Diff line number Diff line change
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
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,
separator=separator,
)


Expand Down
27 changes: 27 additions & 0 deletions typer/utils.py
Original file line number Diff line number Diff line change
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 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}".'
)
Loading