diff --git a/docs/tutorial/multiple-values/multiple-options-with-multiple-values.md b/docs/tutorial/multiple-values/multiple-options-with-multiple-values.md new file mode 100644 index 0000000000..65d924f397 --- /dev/null +++ b/docs/tutorial/multiple-values/multiple-options-with-multiple-values.md @@ -0,0 +1,37 @@ +# Multiple options with multiple values + +For when simple doesn't quite cut it, you may also declare a *CLI option* that takes several values of different types and can be used multiple times. + +For this, we use the standard Python `list` and declare it as a list of `tuple`: + +{* docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_an_py310.py hl[9] *} + +Just as before, the types internal to the `tuple` define the type of each value in the tuple. + +## Check it + +
+ +```console +$ python main.py + +Congratulations, you're debt-free! + +// Now let's borrow some money. +$ python main.py --borrow 2.5 Mark + +Borrowed 2.50 from Mark + +Total borrowed: 2.50 + +// And, of course, it may be used multiple times +$ python main.py --borrow 2.5 Mark --borrow 5.25 Sean --borrow 1.75 Wade + +Borrowed 2.50 from Mark +Borrowed 5.25 from Sean +Borrowed 1.75 from Wade + +Total borrowed: 9.50 +``` + +
diff --git a/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_an_py310.py b/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_an_py310.py new file mode 100644 index 0000000000..45dbbaf61e --- /dev/null +++ b/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_an_py310.py @@ -0,0 +1,22 @@ +from typing import Annotated + +import typer + +app = typer.Typer() + + +@app.command() +def main(borrow: Annotated[list[tuple[float, str]], typer.Option()] = []): + if not borrow: + print("Congratulations, you're debt-free!") + raise typer.Exit(0) + total = 0.0 + for amount, person in borrow: + print(f"Borrowed {amount:.2f} from {person}") + total += amount + print() + print(f"Total borrowed: {total:.2f}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_py310.py b/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_py310.py new file mode 100644 index 0000000000..382a466625 --- /dev/null +++ b/docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_py310.py @@ -0,0 +1,20 @@ +import typer + +app = typer.Typer() + + +@app.command() +def main(borrow: list[tuple[float, str]] = typer.Option([])): + if not borrow: + print("Congratulations, you're debt-free!") + raise typer.Exit(0) + total = 0.0 + for amount, person in borrow: + print(f"Borrowed {amount:.2f} from {person}") + total += amount + print() + print(f"Total borrowed: {total:.2f}") + + +if __name__ == "__main__": + app() diff --git a/mkdocs.yml b/mkdocs.yml index 3cf3b5c1f7..cebb8aea63 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -146,6 +146,7 @@ nav: - tutorial/multiple-values/index.md - tutorial/multiple-values/multiple-options.md - tutorial/multiple-values/options-with-multiple-values.md + - tutorial/multiple-values/multiple-options-with-multiple-values.md - tutorial/multiple-values/arguments-with-multiple-values.md - tutorial/prompt.md - tutorial/progressbar.md diff --git a/pyproject.toml b/pyproject.toml index 990366bd3e..9d37018a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -228,6 +228,7 @@ ignore = [ # Default mutable data structure "docs_src/options_autocompletion/tutorial006_an_py310.py" = ["B006"] "docs_src/multiple_values/multiple_options/tutorial002_an_py310.py" = ["B006"] +"docs_src/multiple_values/multiple_options_with_multiple_values/tutorial001_an_py310.py" = ["B006"] "docs_src/options_autocompletion/tutorial007_an_py310.py" = ["B006"] "docs_src/options_autocompletion/tutorial008_an_py310.py" = ["B006"] "docs_src/options_autocompletion/tutorial009_an_py310.py" = ["B006"] diff --git a/tests/test_tutorial/test_multiple_values/test_multiple_options_with_multiple_values/__init__.py b/tests/test_tutorial/test_multiple_values/test_multiple_options_with_multiple_values/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_multiple_values/test_multiple_options_with_multiple_values/test_tutorial001.py b/tests/test_tutorial/test_multiple_values/test_multiple_options_with_multiple_values/test_tutorial001.py new file mode 100644 index 0000000000..305ae6791b --- /dev/null +++ b/tests/test_tutorial/test_multiple_values/test_multiple_options_with_multiple_values/test_tutorial001.py @@ -0,0 +1,77 @@ +import importlib +import subprocess +import sys +from types import ModuleType + +import pytest +from typer.testing import CliRunner + +runner = CliRunner() + + +@pytest.fixture( + name="mod", + params=[ + pytest.param("tutorial001_py310"), + pytest.param("tutorial001_an_py310"), + ], +) +def get_mod(request: pytest.FixtureRequest) -> ModuleType: + module_name = f"docs_src.multiple_values.multiple_options_with_multiple_values.{request.param}" + mod = importlib.import_module(module_name) + return mod + + +def test_main(mod: ModuleType): + result = runner.invoke(mod.app) + assert result.exit_code == 0 + assert "Congratulations, you're debt-free!" in result.output + + +def test_borrow_1(mod: ModuleType): + result = runner.invoke(mod.app, ["--borrow", "2.5", "Mark"]) + assert result.exit_code == 0 + assert "Borrowed 2.50 from Mark" in result.output + assert "Total borrowed: 2.50" in result.output + + +def test_borrow_many(mod: ModuleType): + result = runner.invoke( + mod.app, + [ + "--borrow", + "2.5", + "Mark", + "--borrow", + "5.25", + "Sean", + "--borrow", + "1.75", + "Wade", + ], + ) + assert result.exit_code == 0 + assert "Borrowed 2.50 from Mark" in result.output + assert "Borrowed 5.25 from Sean" in result.output + assert "Borrowed 1.75 from Wade" in result.output + assert "Total borrowed: 9.50" in result.output + + +def test_invalid_borrow(mod: ModuleType): + result = runner.invoke(mod.app, ["--borrow", "2.5"]) + assert result.exit_code != 0 + # TODO: when deprecating Click 7, remove second option + + assert ( + "Option '--borrow' requires 2 arguments" in result.output + or "--borrow option requires 2 arguments" in result.output + ) + + +def test_script(mod: ModuleType): + 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/main.py b/typer/main.py index f4f21bb844..6b95f5283b 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1671,10 +1671,24 @@ def get_click_param( # Handle Tuples and Lists if lenient_issubclass(origin, list): main_type = get_args(main_type)[0] - assert not get_origin(main_type), ( - "List types with complex sub-types are not currently supported" - ) + list_origin = get_origin(main_type) + list_origin_is_tuple = lenient_issubclass(list_origin, tuple) + if not list_origin_is_tuple: + assert not list_origin, ( + "List types with complex sub-types are not currently supported" + ) is_list = True + if list_origin_is_tuple: + types = [] + for type_ in get_args(main_type): + assert not get_origin(type_), ( + "List[Tuple] types with complex Tuple sub-types are not " + "currently supported" + ) + types.append( + get_click_type(annotation=type_, parameter_info=parameter_info) + ) + parameter_type = tuple(types) elif lenient_issubclass(origin, tuple): types = [] for type_ in get_args(main_type): @@ -1692,6 +1706,8 @@ def get_click_param( ) convertor = determine_type_convertor(main_type) if is_list: + if list_origin_is_tuple: + convertor = generate_tuple_convertor(get_args(main_type)) convertor = generate_list_convertor( convertor=convertor, default_value=default_value )