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
)