diff --git a/README.md b/README.md index 32ead01746..5fc595293b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Create and activate a 100% -Successfully installed typer rich shellingham +Successfully installed typer rich shellingham pydantic ``` @@ -357,6 +357,7 @@ For a more complete example including more features, see the rich: to show nicely formatted errors automatically. +* pydantic: to support the usage of Pydantic types. * shellingham: to automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. @@ -377,7 +378,7 @@ pip install typer pip install "typer-slim[standard]" ``` -The `standard` extra dependencies are `rich` and `shellingham`. +The `standard` extra dependencies are `rich`, `shellingham` and `pydantic`. **Note**: The `typer` command is only included in the `typer` package. diff --git a/docs/index.md b/docs/index.md index 31baa86594..bea0edaf15 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,7 @@ Create and activate a 100% -Successfully installed typer rich shellingham +Successfully installed typer rich shellingham pydantic ``` @@ -363,6 +363,7 @@ For a more complete example including more features, see the rich: to show nicely formatted errors automatically. +* pydantic: to support the usage of Pydantic types. * shellingham: to automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. @@ -383,7 +384,7 @@ pip install typer pip install "typer-slim[standard]" ``` -The `standard` extra dependencies are `rich` and `shellingham`. +The `standard` extra dependencies are `rich`, `shellingham` and `pydantic`. **Note**: The `typer` command is only included in the `typer` package. diff --git a/docs/release-notes.md b/docs/release-notes.md index 2749092c55..d96bea2310 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -673,7 +673,7 @@ Updated docs [SubCommand Name and Help](https://typer.tiangolo.com/tutorial/subc Now you don't need to install `typer[all]`. When you install `typer` it comes with the default optional dependencies and the `typer` command. -If you don't want the extra optional dependencies (`rich` and `shellingham`), you can install `typer-slim` instead. +If you don't want the extra optional dependencies (`rich`, `shellingham` and `pydantic`), you can install `typer-slim` instead. You can also install `typer-slim[standard]`, which includes the default optional dependencies, but not the `typer` command. @@ -699,7 +699,7 @@ By installing the latest version (`0.12.1`) it fixes it, for any previous versio In version `0.12.0`, the `typer` package depends on `typer-slim[standard]` which includes the default dependencies (instead of `typer[all]`) and `typer-cli` (that provides the `typer` command). -If you don't want the extra optional dependencies (`rich` and `shellingham`), you can install `typer-slim` instead. +If you don't want the extra optional dependencies (`rich`, `shellingham` and `pydantic`), you can install `typer-slim` instead. You can also install `typer-slim[standard]`, which includes the default optional dependencies, but not the `typer` command. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index bad806c88a..983dbe9493 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -62,3 +62,34 @@ Using it in your editor is what really shows you the benefits of **Typer**, seei And running the examples is what will really help you **understand** what is going on. You can learn a lot more by **running some examples** and **playing around** with them than by reading all the docs here. + +--- + +## Install **Typer** + +The first step is to install **Typer**: + +
+ +```console +$ pip install typer +---> 100% +Successfully installed typer click shellingham rich pydantic +``` + +
+ +By default, `typer` comes with `rich`, `shellingham` and `pydantic`. + +!!! note + If you are an advanced user and want to opt out of these default extra dependencies, you can instead install `typer-slim`. + + ```bash + pip install typer + ``` + +...includes the same optional dependencies as: + + ```bash + pip install "typer-slim[standard]" + ``` diff --git a/docs/tutorial/parameter-types/pydantic-types.md b/docs/tutorial/parameter-types/pydantic-types.md new file mode 100644 index 0000000000..9623d86275 --- /dev/null +++ b/docs/tutorial/parameter-types/pydantic-types.md @@ -0,0 +1,37 @@ +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. + +Pydantic is installed automatically when installing Typer with its extra standard dependencies: + +
+ +```console +// Pydantic comes with typer +$ pip install typer +---> 100% +Successfully installed typer rich shellingham pydantic + +// Alternatively, you can install Pydantic independently +$ pip install pydantic +---> 100% +Successfully installed pydantic + +// Or if you want to use EmailStr +$ pip install "pydantic[email]" +---> 100% +Successfully installed pydantic, email-validator +``` + +
+ + +You can then use them as parameter types. + +{* docs_src/parameter_types/pydantic_types/tutorial001_an_py39.py hl[9] *} + +{* docs_src/parameter_types/pydantic_types/tutorial002_an_py39.py hl[9] *} + +These types are also supported in lists or tuples: + +{* docs_src/parameter_types/pydantic_types/tutorial003_an_py39.py hl[9] *} + +{* docs_src/parameter_types/pydantic_types/tutorial004_an_py39.py hl[9] *} 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_an_py39.py b/docs_src/parameter_types/pydantic_types/tutorial001_an_py39.py new file mode 100644 index 0000000000..fb251f1607 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001_an_py39.py @@ -0,0 +1,15 @@ +from typing import Annotated + +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main(url_arg: Annotated[AnyHttpUrl, typer.Argument()]): + typer.echo(f"url_arg: {url_arg}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial001_py39.py b/docs_src/parameter_types/pydantic_types/tutorial001_py39.py new file mode 100644 index 0000000000..f6e2cbbfc8 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial001_py39.py @@ -0,0 +1,13 @@ +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main(url_arg: AnyHttpUrl): + typer.echo(f"url_arg: {url_arg}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial002_an_py39.py b/docs_src/parameter_types/pydantic_types/tutorial002_an_py39.py new file mode 100644 index 0000000000..809c5ff237 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002_an_py39.py @@ -0,0 +1,15 @@ +from typing import Annotated + +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main(url_opt: Annotated[AnyHttpUrl, typer.Option()] = "https://typer.tiangolo.com"): + typer.echo(f"url_opt: {url_opt}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial002_py39.py b/docs_src/parameter_types/pydantic_types/tutorial002_py39.py new file mode 100644 index 0000000000..6566a0dad9 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial002_py39.py @@ -0,0 +1,13 @@ +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main(url_opt: AnyHttpUrl = typer.Option("https://typer.tiangolo.com")): + typer.echo(f"url_opt: {url_opt}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial003_an_py39.py b/docs_src/parameter_types/pydantic_types/tutorial003_an_py39.py new file mode 100644 index 0000000000..9d028c9d76 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003_an_py39.py @@ -0,0 +1,17 @@ +from typing import Annotated + +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main( + urls: Annotated[list[AnyHttpUrl], typer.Option("--url", default_factory=list)], +): + typer.echo(f"urls: {urls}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial003_py39.py b/docs_src/parameter_types/pydantic_types/tutorial003_py39.py new file mode 100644 index 0000000000..c9281cfe82 --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial003_py39.py @@ -0,0 +1,13 @@ +import typer +from pydantic import AnyHttpUrl + +app = typer.Typer() + + +@app.command() +def main(urls: list[AnyHttpUrl] = typer.Option([], "--url")): + typer.echo(f"urls: {urls}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial004_an_py39.py b/docs_src/parameter_types/pydantic_types/tutorial004_an_py39.py new file mode 100644 index 0000000000..3f6b649f0b --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004_an_py39.py @@ -0,0 +1,23 @@ +from typing import Annotated + +import typer +from pydantic import AnyHttpUrl, IPvAnyAddress + +app = typer.Typer() + + +@app.command() +def main( + server: Annotated[ + tuple[str, IPvAnyAddress, AnyHttpUrl], + typer.Option(help="User name, age, email and social media URL"), + ], +): + name, address, url = server + typer.echo(f"name: {name}") + typer.echo(f"address: {address}") + typer.echo(f"url: {url}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/parameter_types/pydantic_types/tutorial004_py39.py b/docs_src/parameter_types/pydantic_types/tutorial004_py39.py new file mode 100644 index 0000000000..c5e0fd4d7d --- /dev/null +++ b/docs_src/parameter_types/pydantic_types/tutorial004_py39.py @@ -0,0 +1,20 @@ +import typer +from pydantic import AnyHttpUrl, IPvAnyAddress + +app = typer.Typer() + + +@app.command() +def main( + server: tuple[str, IPvAnyAddress, AnyHttpUrl] = typer.Option( + ..., help="Server name, IP address and public URL" + ), +): + name, address, url = server + typer.echo(f"name: {name}") + typer.echo(f"address: {address}") + typer.echo(f"url: {url}") + + +if __name__ == "__main__": + app() diff --git a/mkdocs.yml b/mkdocs.yml index 744954ce2c..80d95f6319 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -116,6 +116,7 @@ nav: - tutorial/parameter-types/path.md - tutorial/parameter-types/file.md - tutorial/parameter-types/custom-types.md + - tutorial/parameter-types/pydantic-types.md - SubCommands - Command Groups: - tutorial/subcommands/index.md - tutorial/subcommands/add-typer.md diff --git a/pyproject.toml b/pyproject.toml index 5897e1aa8a..495eccfce4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ Changelog = "https://typer.tiangolo.com/release-notes/" standard = [ "shellingham >=1.3.0", "rich >=10.11.0", + "pydantic >=2.0.0", ] [dependency-groups] @@ -81,6 +82,7 @@ github-actions = [ tests = [ "coverage[toml]>=6.2,<8.0", "mypy==1.19.1", + "pydantic >=2.0.0", "pytest>=4.4.0,<9.0.0", "pytest-cov>=2.10.0,<8.0.0", "pytest-sugar>=0.9.4,<1.2.0", diff --git a/tests/test_tutorial/test_parameter_types/test_pydantic_types/__init__.py b/tests/test_tutorial/test_parameter_types/test_pydantic_types/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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..02f7e2a0a1 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial001.py @@ -0,0 +1,48 @@ +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_py39"), + pytest.param("tutorial001_an_py39"), + ], +) +def get_mod(request: pytest.FixtureRequest) -> ModuleType: + module_name = f"docs_src.parameter_types.pydantic_types.{request.param}" + mod = importlib.import_module(module_name) + return mod + + +def test_help(mod: ModuleType): + result = runner.invoke(mod.app, ["--help"]) + assert result.exit_code == 0 + + +def test_url_arg(mod: ModuleType): + result = runner.invoke(mod.app, ["https://typer.tiangolo.com"]) + assert result.exit_code == 0 + assert "url_arg: https://typer.tiangolo.com" in result.output + + +def test_url_arg_invalid(mod: ModuleType): + result = runner.invoke(mod.app, ["invalid"]) + assert result.exit_code != 0 + assert "Input should be a valid URL" 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/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..7e9756087a --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial002.py @@ -0,0 +1,48 @@ +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("tutorial002_py39"), + pytest.param("tutorial002_an_py39"), + ], +) +def get_mod(request: pytest.FixtureRequest) -> ModuleType: + module_name = f"docs_src.parameter_types.pydantic_types.{request.param}" + mod = importlib.import_module(module_name) + return mod + + +def test_help(mod: ModuleType): + result = runner.invoke(mod.app, ["--help"]) + assert result.exit_code == 0 + + +def test_url_opt(mod: ModuleType): + result = runner.invoke(mod.app, ["--url-opt", "https://typer.tiangolo.com"]) + assert result.exit_code == 0 + assert "url_opt: https://typer.tiangolo.com" in result.output + + +def test_url_opt_invalid(mod: ModuleType): + result = runner.invoke(mod.app, ["--url-opt", "invalid"]) + assert result.exit_code != 0 + assert "Input should be a valid URL" 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/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..0c39e50653 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial003.py @@ -0,0 +1,53 @@ +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("tutorial003_py39"), + pytest.param("tutorial003_an_py39"), + ], +) +def get_mod(request: pytest.FixtureRequest) -> ModuleType: + module_name = f"docs_src.parameter_types.pydantic_types.{request.param}" + mod = importlib.import_module(module_name) + return mod + + +def test_help(mod: ModuleType): + result = runner.invoke(mod.app, ["--help"]) + assert result.exit_code == 0 + + +def test_url_list(mod: ModuleType): + result = runner.invoke( + mod.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(mod: ModuleType): + result = runner.invoke( + mod.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(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/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..5853afce21 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_pydantic_types/test_tutorial004.py @@ -0,0 +1,60 @@ +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("tutorial004_py39"), + pytest.param("tutorial004_an_py39"), + ], +) +def get_mod(request: pytest.FixtureRequest) -> ModuleType: + module_name = f"docs_src.parameter_types.pydantic_types.{request.param}" + mod = importlib.import_module(module_name) + return mod + + +def test_help(mod: ModuleType): + result = runner.invoke(mod.app, ["--help"]) + assert result.exit_code == 0 + + +def test_tuple(mod: ModuleType): + result = runner.invoke( + mod.app, ["--server", "Example", "::1", "https://example.com"] + ) + assert result.exit_code == 0 + assert "name: Example" in result.output + assert "address: ::1" in result.output + assert "url: https://example.com" in result.output + + +def test_tuple_invalid_ip(mod: ModuleType): + result = runner.invoke( + mod.app, ["--server", "Invalid", "invalid", "https://example.com"] + ) + assert result.exit_code != 0 + assert "value is not a valid IPv4 or IPv6 address" in result.output + + +def test_tuple_invalid_url(mod: ModuleType): + result = runner.invoke(mod.app, ["--server", "Invalid", "::1", "invalid"]) + assert result.exit_code != 0 + assert "Input should be a valid URL" 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/completion.py b/typer/completion.py index db87f83e3f..af91c4284d 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -131,7 +131,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 e8c6b9e429..d1d9eb0344 100644 --- a/typer/main.py +++ b/typer/main.py @@ -12,11 +12,12 @@ from pathlib import Path from traceback import FrameSummary, StackSummary from types import TracebackType -from typing import Any, Callable, Optional, Union +from typing import Annotated, Any, Callable, Optional, Union from uuid import UUID import click from typer._types import TyperChoice +from typing_extensions import TypeAlias from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values from .completion import get_completion_inspect_parameters @@ -51,6 +52,40 @@ ) from .utils import get_params_from_function +try: + import pydantic + + def is_pydantic_type(type_: Any) -> bool: + if get_origin(type_) is Annotated: + # While this is excluded from coverage, we need this check for older versions of Pydantic 2, cf PR 723 + return is_pydantic_type(get_args(type_)[0]) # pragma: no cover + 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] + adapter: pydantic.TypeAdapter[T] = pydantic.TypeAdapter(type_) + + def convertor(value: str) -> T: + try: + return adapter.validate_python(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__" @@ -622,6 +657,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 @@ -809,6 +846,8 @@ def get_click_type( literal_values(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 @@ -818,6 +857,11 @@ 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 get_origin(type_) is not None and not is_pydantic_type(type_) + + def get_click_param( param: ParamMeta, ) -> tuple[Union[click.Argument, click.Option], Any]: @@ -851,6 +895,7 @@ def get_click_param( is_flag = None origin = get_origin(main_type) + callback = parameter_info.callback if origin is not None: # Handle SomeType | None and Optional[SomeType] if is_union(origin): @@ -865,14 +910,14 @@ 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), ( + 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): types = [] for type_ in get_args(main_type): - assert not get_origin(type_), ( + assert not is_complex_subtype(type_), ( "Tuple types with complex sub-types are not currently supported" ) types.append( @@ -930,9 +975,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, @@ -964,9 +1007,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, diff --git a/uv.lock b/uv.lock index aa96ca3fb5..6ff341d6c2 100644 --- a/uv.lock +++ b/uv.lock @@ -2231,6 +2231,7 @@ source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic" }, { name = "rich" }, { name = "shellingham" }, { name = "typing-extensions" }, @@ -2252,6 +2253,7 @@ dev = [ { name = "mypy" }, { name = "pillow" }, { name = "prek" }, + { name = "pydantic" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-sugar" }, @@ -2286,6 +2288,7 @@ tests = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "mypy" }, + { name = "pydantic" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-sugar" }, @@ -2298,6 +2301,7 @@ tests = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "rich", specifier = ">=10.11.0" }, { name = "shellingham", specifier = ">=1.3.0" }, { name = "typing-extensions", specifier = ">=3.7.4.3" }, @@ -2318,6 +2322,7 @@ dev = [ { name = "mypy", specifier = "==1.19.1" }, { name = "pillow", specifier = "==11.3.0" }, { name = "prek", specifier = "==0.2.24" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", specifier = ">=4.4.0,<9.0.0" }, { name = "pytest-cov", specifier = ">=2.10.0,<8.0.0" }, { name = "pytest-sugar", specifier = ">=0.9.4,<1.2.0" }, @@ -2350,6 +2355,7 @@ github-actions = [ tests = [ { name = "coverage", extras = ["toml"], specifier = ">=6.2,<8.0" }, { name = "mypy", specifier = "==1.19.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", specifier = ">=4.4.0,<9.0.0" }, { name = "pytest-cov", specifier = ">=2.10.0,<8.0.0" }, { name = "pytest-sugar", specifier = ">=0.9.4,<1.2.0" },