diff --git a/docs/tutorial/parameter-types/pydantic-types.md b/docs/tutorial/parameter-types/pydantic-types.md new file mode 100644 index 0000000000..6092113f57 --- /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!} + ``` \ No newline at end of file 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..580625fcc9 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001.py @@ -0,0 +1,8 @@ +import typer +from pydantic import EmailStr + +def main(email_arg: EmailStr): + typer.echo(f"email_arg: {email_arg}") + +if __name__ == "__main__": + typer.run(main) \ No newline at end of file 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..f7faf0ef34 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001_an.py @@ -0,0 +1,9 @@ +import typer +from typing_extensions import Annotated +from pydantic import EmailStr + +def main(email_arg: Annotated[EmailStr, typer.Argument()]): + typer.echo(f"email_arg: {email_arg}") + +if __name__ == "__main__": + typer.run(main) \ No newline at end of file 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..e06beb11c0 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002.py @@ -0,0 +1,8 @@ +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) \ No newline at end of file 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..4243abb066 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002_an.py @@ -0,0 +1,9 @@ +import typer +from typing_extensions import Annotated +from pydantic import EmailStr + +def main(email_opt: Annotated[EmailStr, typer.Option()]="tiangolo@gmail.com"): + typer.echo(f"email_opt: {email_opt}") + +if __name__ == "__main__": + typer.run(main) \ No newline at end of file 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..ef92f507b3 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003.py @@ -0,0 +1,9 @@ +import typer +from typing import List +from pydantic import AnyHttpUrl + +def main(urls: List[AnyHttpUrl] = typer.Option([],"--url")): + typer.echo(f"urls: {urls}") + +if __name__ == "__main__": + typer.run(main) \ No newline at end of file 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..1c28461307 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003_an.py @@ -0,0 +1,10 @@ +import typer +from typing import List +from typing_extensions import Annotated +from pydantic import AnyHttpUrl + +def main(urls: Annotated[List[AnyHttpUrl], typer.Option("--url", default_factory=list)]): + typer.echo(f"urls: {urls}") + +if __name__ == "__main__": + typer.run(main) \ No newline at end of file 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..7a82f59afc --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004.py @@ -0,0 +1,13 @@ +import typer +from typing import Tuple +from pydantic import EmailStr, AnyHttpUrl + +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) \ No newline at end of file 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..5aa822668a --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004_an.py @@ -0,0 +1,15 @@ +import typer +from typing import Tuple +from typing_extensions import Annotated +from pydantic import EmailStr, AnyHttpUrl + +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) \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 8022a19589..cde225de6f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,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 d3c6d940ae..ceac6d9114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,10 +64,14 @@ dev = [ "flake8 >=3.8.3,<4.0.0", "pre-commit >=2.17.0,<3.0.0", ] +pydantic = [ + "pydantic[email] >=2.0.0", +] all = [ "colorama >=0.4.3,<0.5.0", "shellingham >=1.3.0,<2.0.0", "rich >=10.11.0,<14.0.0", + "pydantic[email] >=2.0.0", ] [tool.isort] 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..ef86df6d33 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001.py @@ -0,0 +1,36 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..0d1cc548d6 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001_an.py @@ -0,0 +1,36 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..2e0a4b3f5f --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002.py @@ -0,0 +1,36 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..50cab01a4b --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002_an.py @@ -0,0 +1,36 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..536b0dcc65 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003.py @@ -0,0 +1,37 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..fff42974c1 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003_an.py @@ -0,0 +1,37 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..9d0823d1ee --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004.py @@ -0,0 +1,39 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file 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..fe35d353d9 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004_an.py @@ -0,0 +1,39 @@ +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"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout \ No newline at end of file diff --git a/typer/main.py b/typer/main.py index 9de5f5960d..3da4562862 100644 --- a/typer/main.py +++ b/typer/main.py @@ -46,6 +46,31 @@ except ImportError: # pragma: nocover 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_): + """Create a convertor for a parameter annotated with a pydantic type.""" + + @pydantic.validate_call + def internal_convertor(value: type_): + return value + + def convertor(value: str): + 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: nocover + 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__" @@ -608,6 +633,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 @@ -776,6 +803,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 @@ -784,6 +813,9 @@ def lenient_issubclass( ) -> bool: 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, @@ -817,6 +849,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: @@ -831,16 +864,12 @@ 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 - ), "List types with complex sub-types are not currently supported" + 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 - ), "Tuple types with complex sub-types are not currently supported" + 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) ) @@ -896,7 +925,7 @@ def get_click_param( required=required, default=default_value, callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor + callback=callback, convertor=convertor ), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value, @@ -930,7 +959,7 @@ def get_click_param( # Parameter default=default_value, callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor + callback=callback, convertor=convertor ), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value,