🐛 Fix type conversion for List
and Tuple
and their internal types (#143)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
b0fc0f00eb
commit
722a4febf5
3 changed files with 128 additions and 44 deletions
|
@ -1,7 +1,6 @@
|
|||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
|
@ -16,37 +15,6 @@ from typer.testing import CliRunner
|
|||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_optional():
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def opt(user: Optional[str] = None):
|
||||
if user:
|
||||
typer.echo(f"User: {user}")
|
||||
else:
|
||||
typer.echo("No user")
|
||||
|
||||
result = runner.invoke(app)
|
||||
assert result.exit_code == 0
|
||||
assert "No user" in result.output
|
||||
|
||||
result = runner.invoke(app, ["--user", "Camila"])
|
||||
assert result.exit_code == 0
|
||||
assert "User: Camila" in result.output
|
||||
|
||||
|
||||
def test_no_type():
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def no_type(user):
|
||||
typer.echo(f"User: {user}")
|
||||
|
||||
result = runner.invoke(app, ["Camila"])
|
||||
assert result.exit_code == 0
|
||||
assert "User: Camila" in result.output
|
||||
|
||||
|
||||
def test_help_from_info():
|
||||
# Mainly for coverage/completeness
|
||||
value = solve_typer_info_help(TyperInfo())
|
||||
|
|
92
tests/test_type_conversion.py
Normal file
92
tests/test_type_conversion.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_optional():
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def opt(user: Optional[str] = None):
|
||||
if user:
|
||||
typer.echo(f"User: {user}")
|
||||
else:
|
||||
typer.echo("No user")
|
||||
|
||||
result = runner.invoke(app)
|
||||
assert result.exit_code == 0
|
||||
assert "No user" in result.output
|
||||
|
||||
result = runner.invoke(app, ["--user", "Camila"])
|
||||
assert result.exit_code == 0
|
||||
assert "User: Camila" in result.output
|
||||
|
||||
|
||||
def test_no_type():
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def no_type(user):
|
||||
typer.echo(f"User: {user}")
|
||||
|
||||
result = runner.invoke(app, ["Camila"])
|
||||
assert result.exit_code == 0
|
||||
assert "User: Camila" in result.output
|
||||
|
||||
|
||||
class SomeEnum(Enum):
|
||||
ONE = "one"
|
||||
TWO = "two"
|
||||
THREE = "three"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"type_annotation",
|
||||
[List[Path], List[SomeEnum], List[str]],
|
||||
)
|
||||
def test_list_parameters_convert_to_lists(type_annotation):
|
||||
# Lists containing objects that are converted by Click (i.e. not Path or Enum)
|
||||
# should not be inadvertently converted to tuples
|
||||
expected_element_type = type_annotation.__args__[0]
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def list_conversion(container: type_annotation):
|
||||
assert isinstance(container, list)
|
||||
for element in container:
|
||||
assert isinstance(element, expected_element_type)
|
||||
|
||||
result = runner.invoke(app, ["one", "two", "three"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"type_annotation",
|
||||
[
|
||||
Tuple[str, str],
|
||||
Tuple[str, Path],
|
||||
Tuple[Path, Path],
|
||||
Tuple[str, SomeEnum],
|
||||
Tuple[SomeEnum, SomeEnum],
|
||||
],
|
||||
)
|
||||
def test_tuple_parameter_elements_are_converted_recursively(type_annotation):
|
||||
# Tuple elements that aren't converted by Click (i.e. Path or Enum)
|
||||
# should be recursively converted by Typer
|
||||
expected_element_types = type_annotation.__args__
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def tuple_recursive_conversion(container: type_annotation):
|
||||
assert isinstance(container, tuple)
|
||||
for element, expected_type in zip(container, expected_element_types):
|
||||
assert isinstance(element, expected_type)
|
||||
|
||||
result = runner.invoke(app, ["one", "two"])
|
||||
assert result.exit_code == 0
|
|
@ -453,13 +453,22 @@ def get_command_from_info(command_info: CommandInfo) -> click.Command:
|
|||
return command
|
||||
|
||||
|
||||
def determine_type_convertor(type_: Any) -> Optional[Callable[[Any], Any]]:
|
||||
convertor: Optional[Callable[[Any], Any]] = None
|
||||
if lenient_issubclass(type_, Path):
|
||||
convertor = param_path_convertor
|
||||
if lenient_issubclass(type_, Enum):
|
||||
convertor = generate_enum_convertor(type_)
|
||||
return convertor
|
||||
|
||||
|
||||
def param_path_convertor(value: Optional[str] = None) -> Optional[Path]:
|
||||
if value is not None:
|
||||
return Path(value)
|
||||
return None
|
||||
|
||||
|
||||
def generate_enum_convertor(enum: Type[Enum]) -> Callable[..., Any]:
|
||||
def generate_enum_convertor(enum: Type[Enum]) -> Callable[[Any], Any]:
|
||||
lower_val_map = {str(val.value).lower(): val for val in enum}
|
||||
|
||||
def convertor(value: Any) -> Any:
|
||||
|
@ -472,9 +481,25 @@ def generate_enum_convertor(enum: Type[Enum]) -> Callable[..., Any]:
|
|||
return convertor
|
||||
|
||||
|
||||
def generate_iter_convertor(convertor: Callable[[Any], Any]) -> Callable[..., Any]:
|
||||
def internal_convertor(value: Any) -> List[Any]:
|
||||
return [convertor(v) for v in value]
|
||||
def generate_list_convertor(
|
||||
convertor: Optional[Callable[[Any], Any]]
|
||||
) -> Callable[[Sequence[Any]], List[Any]]:
|
||||
def internal_convertor(value: Sequence[Any]) -> List[Any]:
|
||||
return [convertor(v) if convertor else v for v in value]
|
||||
|
||||
return internal_convertor
|
||||
|
||||
|
||||
def generate_tuple_convertor(
|
||||
types: Sequence[Any],
|
||||
) -> Callable[[Tuple[Any, ...]], Tuple[Any, ...]]:
|
||||
convertors = [determine_type_convertor(type_) for type_ in types]
|
||||
|
||||
def internal_convertor(param_args: Tuple[Any, ...]) -> Tuple[Any, ...]:
|
||||
return tuple(
|
||||
convertor(arg) if convertor else arg
|
||||
for (convertor, arg) in zip(convertors, param_args)
|
||||
)
|
||||
|
||||
return internal_convertor
|
||||
|
||||
|
@ -631,6 +656,7 @@ def get_click_param(
|
|||
annotation = str
|
||||
main_type = annotation
|
||||
is_list = False
|
||||
is_tuple = False
|
||||
parameter_type: Any = None
|
||||
is_flag = None
|
||||
origin = getattr(main_type, "__origin__", None)
|
||||
|
@ -662,18 +688,16 @@ def get_click_param(
|
|||
get_click_type(annotation=type_, parameter_info=parameter_info)
|
||||
)
|
||||
parameter_type = tuple(types)
|
||||
is_tuple = True
|
||||
if parameter_type is None:
|
||||
parameter_type = get_click_type(
|
||||
annotation=main_type, parameter_info=parameter_info
|
||||
)
|
||||
convertor = None
|
||||
if lenient_issubclass(main_type, Path):
|
||||
convertor = param_path_convertor
|
||||
if lenient_issubclass(main_type, Enum):
|
||||
convertor = generate_enum_convertor(main_type)
|
||||
if convertor and is_list:
|
||||
convertor = generate_iter_convertor(convertor)
|
||||
# TODO: handle recursive conversion for tuples
|
||||
convertor = determine_type_convertor(main_type)
|
||||
if is_list:
|
||||
convertor = generate_list_convertor(convertor)
|
||||
if is_tuple:
|
||||
convertor = generate_tuple_convertor(main_type.__args__)
|
||||
if isinstance(parameter_info, OptionInfo):
|
||||
if main_type is bool and not (parameter_info.is_flag is False):
|
||||
is_flag = True
|
||||
|
|
Loading…
Reference in a new issue