🐛 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:
Irvin Ho 2022-07-02 10:26:03 -07:00 committed by GitHub
parent b0fc0f00eb
commit 722a4febf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 128 additions and 44 deletions

View file

@ -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())

View 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

View file

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