diff --git a/docs/tutorial/parameter-types/pydantic-types.md b/docs/tutorial/parameter-types/pydantic-types.md new file mode 100644 index 0000000000..fe54ef3afb --- /dev/null +++ b/docs/tutorial/parameter-types/pydantic-types.md @@ -0,0 +1,84 @@ +Pydantic types such as [AnyUrl](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.AnyUrl) or [EmailStr](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.EmailStr) can be very convenient to describe and validate some parameters. + +You can add pydantic from typer's optional dependencies + +
+ +```console +// Pydantic comes with typer[all] +$ pip install "typer[all]" +---> 100% +Successfully installed typer rich pydantic + +// Alternatively, you can install Pydantic independently +$ pip install pydantic +---> 100% +Successfully installed pydantic +``` + +
+ + +You can then use them as parameter types. + +=== "Python 3.6+ Argument" + + ```Python hl_lines="5" + {!> ../docs_src/parameter_types/pydantic_types/tutorial001_an.py!} + ``` + +=== "Python 3.6+ Argument non-Annotated" + + !!! tip + Prefer to use the `Annotated` version if possible. + + ```Python hl_lines="4" + {!> ../docs_src/parameter_types/pydantic_types/tutorial001.py!} + ``` + +=== "Python 3.6+ Option" + + ```Python hl_lines="5" + {!> ../docs_src/parameter_types/pydantic_types/tutorial002_an.py!} + ``` + +=== "Python 3.6+ Option non-Annotated" + + !!! tip + Prefer to use the `Annotated` version if possible. + + ```Python hl_lines="4" + {!> ../docs_src/parameter_types/pydantic_types/tutorial002.py!} + ``` + +These types are also supported in lists or tuples + +=== "Python 3.6+ list" + + ```Python hl_lines="6" + {!> ../docs_src/parameter_types/pydantic_types/tutorial003_an.py!} + ``` + +=== "Python 3.6+ list non-Annotated" + + !!! tip + Prefer to use the `Annotated` version if possible. + + ```Python hl_lines="5" + {!> ../docs_src/parameter_types/pydantic_types/tutorial003.py!} + ``` + +=== "Python 3.6+ tuple" + + ```Python hl_lines="6" + {!> ../docs_src/parameter_types/pydantic_types/tutorial004_an.py!} + ``` + +=== "Python 3.6+ tuple non-Annotated" + + !!! tip + Prefer to use the `Annotated` version if possible. + + ```Python hl_lines="5" + {!> ../docs_src/parameter_types/pydantic_types/tutorial004.py!} + ``` diff --git a/docs_src/parameter_types/pydantic_types/__init__.py b/docs_src/parameter_types/pydantic_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/parameter_types/pydantic_types/tutorial001.py b/docs_src/parameter_types/pydantic_types/tutorial001.py new file mode 100644 index 0000000000..4aec54161a --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001.py @@ -0,0 +1,10 @@ +import typer +from pydantic import EmailStr + + +def main(email_arg: EmailStr): + typer.echo(f"email_arg: {email_arg}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial001_an.py b/docs_src/parameter_types/pydantic_types/tutorial001_an.py new file mode 100644 index 0000000000..c92dbce546 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001_an.py @@ -0,0 +1,11 @@ +import typer +from pydantic import EmailStr +from typing_extensions import Annotated + + +def main(email_arg: Annotated[EmailStr, typer.Argument()]): + typer.echo(f"email_arg: {email_arg}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial002.py b/docs_src/parameter_types/pydantic_types/tutorial002.py new file mode 100644 index 0000000000..14ef540743 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002.py @@ -0,0 +1,10 @@ +import typer +from pydantic import EmailStr + + +def main(email_opt: EmailStr = typer.Option("tiangolo@gmail.com")): + typer.echo(f"email_opt: {email_opt}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial002_an.py b/docs_src/parameter_types/pydantic_types/tutorial002_an.py new file mode 100644 index 0000000000..bcf7cf5e15 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002_an.py @@ -0,0 +1,11 @@ +import typer +from pydantic import EmailStr +from typing_extensions import Annotated + + +def main(email_opt: Annotated[EmailStr, typer.Option()] = "tiangolo@gmail.com"): + typer.echo(f"email_opt: {email_opt}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial003.py b/docs_src/parameter_types/pydantic_types/tutorial003.py new file mode 100644 index 0000000000..c1b13964be --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003.py @@ -0,0 +1,12 @@ +from typing import List + +import typer +from pydantic import AnyHttpUrl + + +def main(urls: List[AnyHttpUrl] = typer.Option([], "--url")): + typer.echo(f"urls: {urls}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial003_an.py b/docs_src/parameter_types/pydantic_types/tutorial003_an.py new file mode 100644 index 0000000000..61b816243e --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003_an.py @@ -0,0 +1,15 @@ +from typing import List + +import typer +from pydantic import AnyHttpUrl +from typing_extensions import Annotated + + +def main( + urls: Annotated[List[AnyHttpUrl], typer.Option("--url", default_factory=list)], +): + typer.echo(f"urls: {urls}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial004.py b/docs_src/parameter_types/pydantic_types/tutorial004.py new file mode 100644 index 0000000000..66b7b71a25 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004.py @@ -0,0 +1,20 @@ +from typing import Tuple + +import typer +from pydantic import AnyHttpUrl, EmailStr + + +def main( + user: Tuple[str, int, EmailStr, AnyHttpUrl] = typer.Option( + ..., help="User name, age, email and social media URL" + ), +): + name, age, email, url = user + typer.echo(f"name: {name}") + typer.echo(f"age: {age}") + typer.echo(f"email: {email}") + typer.echo(f"url: {url}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/pydantic_types/tutorial004_an.py b/docs_src/parameter_types/pydantic_types/tutorial004_an.py new file mode 100644 index 0000000000..9fa0ee5494 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004_an.py @@ -0,0 +1,22 @@ +from typing import Tuple + +import typer +from pydantic import AnyHttpUrl, EmailStr +from typing_extensions import Annotated + + +def main( + user: Annotated[ + Tuple[str, int, EmailStr, AnyHttpUrl], + typer.Option(help="User name, age, email and social media URL"), + ], +): + name, age, email, url = user + typer.echo(f"name: {name}") + typer.echo(f"age: {age}") + typer.echo(f"email: {email}") + typer.echo(f"url: {url}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/mkdocs.yml b/mkdocs.yml index 77024d83bb..5a0b02bbb5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - Path: tutorial/parameter-types/path.md - File: tutorial/parameter-types/file.md - Custom Types: tutorial/parameter-types/custom-types.md + - Pydantic Types: tutorial/parameter-types/pydantic-types.md - SubCommands - Command Groups: - SubCommands - Command Groups - Intro: tutorial/subcommands/index.md - Add Typer: tutorial/subcommands/add-typer.md diff --git a/pyproject.toml b/pyproject.toml index c9e793e1ed..32a52a9a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ homepage = "https://github.com/tiangolo/typer" standard = [ "shellingham >=1.3.0", "rich >=10.11.0", + "pydantic[email] >=2.0.0", ] [tool.pdm] diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001.py new file mode 100644 index 0000000000..e8d226088b --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001.py @@ -0,0 +1,38 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial001 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_email_arg(): + result = runner.invoke(app, ["tiangolo@gmail.com"]) + assert result.exit_code == 0 + assert "email_arg: tiangolo@gmail.com" in result.output + + +def test_email_arg_invalid(): + result = runner.invoke(app, ["invalid"]) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001_an.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001_an.py new file mode 100644 index 0000000000..167c1ce3a8 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001_an.py @@ -0,0 +1,38 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial001_an as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_email_arg(): + result = runner.invoke(app, ["tiangolo@gmail.com"]) + assert result.exit_code == 0 + assert "email_arg: tiangolo@gmail.com" in result.output + + +def test_email_arg_invalid(): + result = runner.invoke(app, ["invalid"]) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002.py new file mode 100644 index 0000000000..265e1d3191 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002.py @@ -0,0 +1,38 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial002 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_email_opt(): + result = runner.invoke(app, ["--email-opt", "tiangolo@gmail.com"]) + assert result.exit_code == 0 + assert "email_opt: tiangolo@gmail.com" in result.output + + +def test_email_opt_invalid(): + result = runner.invoke(app, ["--email-opt", "invalid"]) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002_an.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002_an.py new file mode 100644 index 0000000000..1d0475d009 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002_an.py @@ -0,0 +1,38 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial002_an as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_email_opt(): + result = runner.invoke(app, ["--email-opt", "tiangolo@gmail.com"]) + assert result.exit_code == 0 + assert "email_opt: tiangolo@gmail.com" in result.output + + +def test_email_opt_invalid(): + result = runner.invoke(app, ["--email-opt", "invalid"]) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003.py new file mode 100644 index 0000000000..b9a9018e04 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003.py @@ -0,0 +1,41 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial003 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_url_list(): + result = runner.invoke( + app, ["--url", "https://example.com", "--url", "https://example.org"] + ) + assert result.exit_code == 0 + assert "https://example.com" in result.output + assert "https://example.org" in result.output + + +def test_url_invalid(): + result = runner.invoke(app, ["--url", "invalid", "--url", "https://example.org"]) + assert result.exit_code != 0 + assert "Input should be a valid URL" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003_an.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003_an.py new file mode 100644 index 0000000000..487fffc55b --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003_an.py @@ -0,0 +1,41 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial003_an as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_url_list(): + result = runner.invoke( + app, ["--url", "https://example.com", "--url", "https://example.org"] + ) + assert result.exit_code == 0 + assert "https://example.com" in result.output + assert "https://example.org" in result.output + + +def test_url_invalid(): + result = runner.invoke(app, ["--url", "invalid", "--url", "https://example.org"]) + assert result.exit_code != 0 + assert "Input should be a valid URL" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004.py new file mode 100644 index 0000000000..e51c2b5b89 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004.py @@ -0,0 +1,45 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial004 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_tuple(): + result = runner.invoke( + app, ["--user", "Camila", "23", "camila@example.org", "https://example.com"] + ) + assert result.exit_code == 0 + assert "name: Camila" in result.output + assert "age: 23" in result.output + assert "email: camila@example.org" in result.output + assert "url: https://example.com" in result.output + + +def test_tuple_invalid(): + result = runner.invoke( + app, ["--user", "Camila", "23", "invalid", "https://example.com"] + ) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004_an.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004_an.py new file mode 100644 index 0000000000..dde6671976 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004_an.py @@ -0,0 +1,45 @@ +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.pydantic_types import tutorial004_an as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_tuple(): + result = runner.invoke( + app, ["--user", "Camila", "23", "camila@example.org", "https://example.com"] + ) + assert result.exit_code == 0 + assert "name: Camila" in result.output + assert "age: 23" in result.output + assert "email: camila@example.org" in result.output + assert "url: https://example.com" in result.output + + +def test_tuple_invalid(): + result = runner.invoke( + app, ["--user", "Camila", "23", "invalid", "https://example.com"] + ) + assert result.exit_code != 0 + assert "value is not a valid email address" 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 diff --git a/typer/completion.py b/typer/completion.py index 1220a1b545..90c4984b30 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -135,7 +135,7 @@ def shell_complete( click.echo(f"Shell {shell} not supported.", err=True) return 1 - comp = comp_cls(cli, ctx_args, prog_name, complete_var) + comp = comp_cls(cli, dict(ctx_args), prog_name, complete_var) if instruction == "source": click.echo(comp.source()) diff --git a/typer/main.py b/typer/main.py index 9db26975ca..a6ad970d69 100644 --- a/typer/main.py +++ b/typer/main.py @@ -12,6 +12,7 @@ from uuid import UUID import click +from typing_extensions import TypeAlias from .completion import get_completion_inspect_parameters from .core import MarkupMode, TyperArgument, TyperCommand, TyperGroup, TyperOption @@ -46,6 +47,40 @@ except ImportError: # pragma: no cover rich = None # type: ignore +try: + import pydantic + + def is_pydantic_type(type_: Any) -> bool: + return type_.__module__.startswith("pydantic") and not lenient_issubclass( + type_, pydantic.BaseModel + ) + + def pydantic_convertor(type_: type) -> Callable[[str], Any]: + """Create a convertor for a parameter annotated with a pydantic type.""" + T: TypeAlias = type_ # type: ignore[valid-type] + + @pydantic.validate_call + def internal_convertor(value: T) -> T: + return value + + def convertor(value: str) -> T: + try: + return internal_convertor(value) + except pydantic.ValidationError as e: + error_message = e.errors( + include_context=False, include_input=False, include_url=False + )[0]["msg"] + raise click.BadParameter(error_message) from e + + return convertor + +except ImportError: # pragma: no cover + pydantic = None # type: ignore + + def is_pydantic_type(type_: Any) -> bool: + return False + + _original_except_hook = sys.excepthook _typer_developer_exception_attr_name = "__typer_developer_exception__" @@ -610,6 +645,8 @@ def determine_type_convertor(type_: Any) -> Optional[Callable[[Any], Any]]: convertor = param_path_convertor if lenient_issubclass(type_, Enum): convertor = generate_enum_convertor(type_) + if is_pydantic_type(type_): + convertor = pydantic_convertor(type_) return convertor @@ -785,6 +822,8 @@ def get_click_type( [item.value for item in annotation], case_sensitive=parameter_info.case_sensitive, ) + elif is_pydantic_type(annotation): + return click.STRING raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover @@ -794,6 +833,13 @@ def lenient_issubclass( return isinstance(cls, type) and issubclass(cls, class_or_tuple) +def is_complex_subtype(type_: Any) -> bool: + # For pydantic types, such as `AnyUrl`, there's an extra `Annotated` layer that we don't need to treat as complex + return getattr(type_, "__origin__", None) is not None and not is_pydantic_type( + type_ + ) + + def get_click_param( param: ParamMeta, ) -> Tuple[Union[click.Argument, click.Option], Any]: @@ -826,6 +872,7 @@ def get_click_param( parameter_type: Any = None is_flag = None origin = getattr(main_type, "__origin__", None) + callback = parameter_info.callback if origin is not None: # Handle Optional[SomeType] if origin is Union: @@ -840,15 +887,15 @@ def get_click_param( # Handle Tuples and Lists if lenient_issubclass(origin, List): main_type = main_type.__args__[0] - assert not getattr( - main_type, "__origin__", None + assert not is_complex_subtype( + main_type ), "List types with complex sub-types are not currently supported" is_list = True elif lenient_issubclass(origin, Tuple): # type: ignore types = [] for type_ in main_type.__args__: - assert not getattr( - type_, "__origin__", None + assert not is_complex_subtype( + type_ ), "Tuple types with complex sub-types are not currently supported" types.append( get_click_type(annotation=type_, parameter_info=parameter_info) @@ -906,9 +953,7 @@ def get_click_param( # Parameter required=required, default=default_value, - callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor - ), + callback=get_param_callback(callback=callback, convertor=convertor), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value, is_eager=parameter_info.is_eager, @@ -940,9 +985,7 @@ def get_click_param( hidden=parameter_info.hidden, # Parameter default=default_value, - callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor - ), + callback=get_param_callback(callback=callback, convertor=convertor), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value, is_eager=parameter_info.is_eager,