From 9b6081db11cbef215e2c19c8eb7f44ebdb5e1cea Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 16:15:10 +0200 Subject: [PATCH 01/73] add pydantic as requirement --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f4d0af0ebb..198f92e4da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "pydantic >=2.5.3", "shellingham >=1.3.0", "rich >=13.8.0", "annotated-doc >=0.0.2", diff --git a/uv.lock b/uv.lock index 2ff8a55329..fb57a194ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1788,6 +1788,7 @@ source = { editable = "." } dependencies = [ { name = "annotated-doc" }, { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pydantic" }, { name = "rich" }, { name = "shellingham" }, ] @@ -1851,6 +1852,7 @@ tests = [ requires-dist = [ { name = "annotated-doc", specifier = ">=0.0.2" }, { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pydantic", specifier = ">=2.5.3" }, { name = "rich", specifier = ">=13.8.0" }, { name = "shellingham", specifier = ">=1.3.0" }, ] From b54b3081ecaf20627abc65e3d1de52a0d6028854 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 17:37:14 +0200 Subject: [PATCH 02/73] use TypeAdapter for int and float validation --- tests/test_type_conversion.py | 16 ++++++++++++++++ typer/_click/types.py | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 26709db0e2..e808ba9a43 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -368,6 +368,22 @@ class CustomType: assert func_type.name == "CustomType" +def test_int_rejects_float_default() -> None: + app = typer.Typer() + + @app.command() + def main(age: int = typer.Option(15.3)): + typer.echo(age) + + result = runner.invoke(app, ["--age", 42]) + assert "42" in result.stdout + + # Pydantic validation rejects floats as int instead of converting int(15.3) to 15 + result = runner.invoke(app) + assert result.exit_code != 0 + assert "15.3 is not a valid integer" in result.stderr + + @pytest.mark.parametrize( ("platform_case", "stdin_encoding", "filesystem_encoding"), [ diff --git a/typer/_click/types.py b/typer/_click/types.py index 5ccf15fe1b..589ab02309 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -16,6 +16,8 @@ cast, ) +from pydantic import TypeAdapter, ValidationError + from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter from .utils import LazyFile, format_filename, safecall @@ -231,8 +233,8 @@ def convert( self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] ) -> Any: try: - return self._number_class(value) - except ValueError: + return TypeAdapter(self._number_class).validate_python(value) + except ValidationError: self.fail( f"{value!r} is not a valid {self.name}.", param, From c8640aea03b9774f084948a20cbb536d4cba4d31 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 21:41:05 +0200 Subject: [PATCH 03/73] Range adaptor and Pydantic error messages --- tests/test_others.py | 3 +- .../test_index/test_tutorial001.py | 2 +- .../test_number/test_tutorial001.py | 9 +-- .../test_number/test_tutorial002.py | 5 +- tests/test_type_conversion.py | 2 +- typer/_click/types.py | 76 ++++++++++++++----- 6 files changed, 65 insertions(+), 32 deletions(-) diff --git a/tests/test_others.py b/tests/test_others.py index d2cc8696f1..077ed65d4f 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -450,7 +450,8 @@ def main(arg1, arg2: int, arg3: "int", arg4: bool = False, arg5: "bool" = False) result = runner.invoke(app, ["Hello", "2", "invalid"]) - assert "Invalid value for 'ARG3': 'invalid' is not a valid integer" in result.output + assert "Invalid value for 'ARG3'" in result.output + assert "unable to parse string as an integer" in result.output result = runner.invoke(app, ["Hello", "2", "3", "--arg4", "--arg5"]) assert ( "arg1: Hello\narg2: 2\narg3: 3\narg4: True\narg5: True\n" diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index ee0daa9f06..b0c9ea9942 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -44,7 +44,7 @@ def test_invalid(): result = runner.invoke(app, ["Camila", "--age", "15.3"]) assert result.exit_code != 0 assert "Invalid value for '--age'" in result.output - assert "'15.3' is not a valid integer" in result.output + assert "Input should be a valid integer" in result.output def test_script(): diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index ffe3ad09a0..e660a83f6a 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -67,16 +67,15 @@ def test_params(mod: ModuleType): def test_invalid_id(mod: ModuleType): result = runner.invoke(mod.app, ["1002"]) assert result.exit_code != 0 - assert ( - "Invalid value for 'ID': 1002 is not in the range 0<=x<=1000." in result.output - ) + assert "Invalid value for 'ID'" in result.output + assert "should be less than or equal to 1000" in result.output def test_invalid_age(mod: ModuleType): result = runner.invoke(mod.app, ["5", "--age", "15"]) assert result.exit_code != 0 assert "Invalid value for '--age'" in result.output - assert "15 is not in the range x>=18" in result.output + assert "should be greater than or equal to 18" in result.output def test_invalid_score(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): @@ -84,7 +83,7 @@ def test_invalid_score(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): result = runner.invoke(mod.app, ["5", "--age", "20", "--score", "100.5"]) assert result.exit_code != 0 assert "Invalid value for '--score'" in result.output - assert "100.5 is not in the range x<=100." in result.output + assert "should be less than or equal to 100" in result.output def test_negative_score(mod: ModuleType): diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py index 48162c763e..acdc0994fe 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial002.py @@ -25,9 +25,8 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_invalid_id(mod: ModuleType): result = runner.invoke(mod.app, ["1002"]) assert result.exit_code != 0 - assert ( - "Invalid value for 'ID': 1002 is not in the range 0<=x<=1000" in result.output - ) + assert "Invalid value for 'ID'" in result.output + assert "should be less than or equal to 1000" in result.output def test_clamped(mod: ModuleType): diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index e808ba9a43..03f9627dc5 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -381,7 +381,7 @@ def main(age: int = typer.Option(15.3)): # Pydantic validation rejects floats as int instead of converting int(15.3) to 15 result = runner.invoke(app) assert result.exit_code != 0 - assert "15.3 is not a valid integer" in result.stderr + assert "Input should be a valid integer" in result.stderr @pytest.mark.parametrize( diff --git a/typer/_click/types.py b/typer/_click/types.py index 589ab02309..8a1635a221 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -5,6 +5,7 @@ from typing import ( IO, TYPE_CHECKING, + Annotated, Any, ClassVar, Literal, @@ -16,7 +17,7 @@ cast, ) -from pydantic import TypeAdapter, ValidationError +from pydantic import Field, TypeAdapter, ValidationError from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter @@ -29,6 +30,14 @@ ParamTypeValue = TypeVar("ParamTypeValue") +def _get_error_msg(exc: ValidationError) -> str: + """Get a string representation of the (first) validation error.""" + errors = exc.errors() + if errors: + return errors[0]["msg"] + return str(exc) + + class ParamType: """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -228,21 +237,23 @@ def __repr__(self) -> str: class _NumberParamTypeBase(ParamType): _number_class: ClassVar[type[Any]] + _class_adapter: TypeAdapter[Any] + + def __init__(self) -> None: + self._class_adapter = TypeAdapter(self._number_class) def convert( self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] ) -> Any: try: - return TypeAdapter(self._number_class).validate_python(value) - except ValidationError: - self.fail( - f"{value!r} is not a valid {self.name}.", - param, - ctx, - ) + return self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) class _NumberRangeBase(_NumberParamTypeBase): + _range_adapter: TypeAdapter[Any] + def __init__( self, min: float | None = None, @@ -251,18 +262,49 @@ def __init__( max_open: bool = False, clamp: bool = False, ) -> None: + super().__init__() self.min = min self.max = max self.min_open = min_open self.max_open = max_open self.clamp = clamp + self._range_adapter = self._build_range_adapter() + + def _build_range_adapter(self) -> TypeAdapter[Any]: + field_kwargs: dict[str, Any] = {} + if self.min is not None: + if self.min_open: + field_kwargs["gt"] = self.min + else: + field_kwargs["ge"] = self.min + if self.max is not None: + if self.max_open: + field_kwargs["lt"] = self.max + else: + field_kwargs["le"] = self.max + if not field_kwargs: + return self._class_adapter + annotated = Annotated[self._number_class, Field(**field_kwargs)] + return TypeAdapter(annotated) def convert( self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] ) -> Any: + if not self.clamp: + try: + return self._range_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + + # Clamping - only check the class, don't error on range + try: + rv = self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + + # adjust the min/max accordingly import operator - rv = super().convert(value, param, ctx) lt_min: bool = self.min is not None and ( operator.le if self.min_open else operator.lt )(rv, self.min) @@ -270,19 +312,11 @@ def convert( operator.ge if self.max_open else operator.gt )(rv, self.max) - if self.clamp: - if lt_min: - return self._clamp(self.min, 1, self.min_open) # type: ignore[arg-type] - - if gt_max: - return self._clamp(self.max, -1, self.max_open) # type: ignore[arg-type] + if lt_min: + return self._clamp(self.min, 1, self.min_open) # type: ignore[arg-type] - if lt_min or gt_max: - self.fail( - f"{rv} is not in the range {self._describe_range()}.", - param, - ctx, - ) + if gt_max: + return self._clamp(self.max, -1, self.max_open) # type: ignore[arg-type] return rv From 10746de4fbbf7f520c16a0099b3ba8fa34272a31 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 21:55:59 +0200 Subject: [PATCH 04/73] UUID adapter --- .../test_uuid/test_tutorial001.py | 6 ++---- typer/_click/types.py | 20 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py index 7b79e81405..c4c8cfac5d 100644 --- a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py @@ -35,10 +35,8 @@ def test_main_with_uuid_object(): def test_invalid_uuid(): result = runner.invoke(app, ["7479706572-72756c6573"]) assert result.exit_code != 0 - assert ( - "Invalid value for 'USER_ID': '7479706572-72756c6573' is not a valid UUID" - in result.output - ) + assert "Invalid value for 'USER_ID'" in result.output + assert "should be a valid UUID" in result.output def test_script(): diff --git a/typer/_click/types.py b/typer/_click/types.py index 8a1635a221..31315f51cd 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -2,6 +2,7 @@ import sys from collections.abc import Callable, Sequence from datetime import datetime +from uuid import UUID from typing import ( IO, TYPE_CHECKING, @@ -484,21 +485,20 @@ def __repr__(self) -> str: class UUIDParameterType(ParamType): name = "uuid" + _class_adapter: TypeAdapter[UUID] + + def __init__(self) -> None: + self._class_adapter = TypeAdapter(UUID) def convert( self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] ) -> Any: - import uuid - - if isinstance(value, uuid.UUID): - return value - - value = value.strip() - + if isinstance(value, str): + value = value.strip() try: - return uuid.UUID(value) - except ValueError: - self.fail(f"{value!r} is not a valid UUID.", param, ctx) + return self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) def __repr__(self) -> str: return "UUID" From 394e7f218bed1f8c408b03c65aa9f5dc1d56d03f Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 22:16:24 +0200 Subject: [PATCH 05/73] DateTime adapter --- .../test_datetime/test_tutorial001.py | 12 +--- typer/_click/types.py | 55 ++++++++++--------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index eea63c5a8b..44f844f9ce 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -20,7 +20,7 @@ def test_type_repr(): def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - assert "[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]" in result.output + assert "[%Y-%m-%d]" in result.output def test_main(): @@ -42,14 +42,8 @@ def test_main_datetime_object(): def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 - assert ( - "Invalid value for 'BIRTH:[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]':" - in result.output - ) - assert "'july-19-1989' does not match the formats" in result.output - assert "%Y-%m-%d" in result.output - assert "%Y-%m-%dT%H:%M:%S" in result.output - assert "%Y-%m-%d %H:%M:%S" in result.output + assert "Invalid value for 'BIRTH:[%Y-%m-%d]'" in result.output + assert "should be a valid datetime" in result.output def test_script(): diff --git a/typer/_click/types.py b/typer/_click/types.py index 31315f51cd..57ba8cf237 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -18,7 +18,7 @@ cast, ) -from pydantic import Field, TypeAdapter, ValidationError +from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter @@ -196,41 +196,42 @@ class DateTime(ParamType): """ name = "datetime" + _class_adapter: TypeAdapter[datetime] def __init__(self, formats: Sequence[str] | None = None): - self.formats: Sequence[str] = formats or [ - "%Y-%m-%d", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - ] + self.formats = tuple(formats) if formats else None + self._class_adapter = self._build_datetime_adapter() def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: - return f"[{'|'.join(self.formats)}]" + formats = self.formats or ["%Y-%m-%d"] + return f"[{'|'.join(formats)}]" - def _try_to_convert_date(self, value: Any, format: str) -> datetime | None: - try: - return datetime.strptime(value, format) - except ValueError: - return None + def _build_datetime_adapter(self) -> TypeAdapter[datetime]: + if self.formats is None: + return TypeAdapter(datetime) - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - if isinstance(value, datetime): - return value + formats = self.formats - for format in self.formats: - converted = self._try_to_convert_date(value, format) + def parse_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + for format in formats: + try: + return datetime.strptime(value, format) + except ValueError: + continue + formats_str = ", ".join(map(repr, formats)) + raise ValueError(f"{value!r} does not match the formats {formats_str}.") - if converted is not None: - return converted + return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) - formats_str = ", ".join(map(repr, self.formats)) - self.fail( - f"{value!r} does not match the formats {formats_str}.", - param, - ctx, - ) + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + try: + return self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) def __repr__(self) -> str: return "DateTime" From 27f58806fab17f946335226862b8b4d076161123 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 22:34:47 +0200 Subject: [PATCH 06/73] Choice adapter --- typer/_types.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/typer/_types.py b/typer/_types.py index 09b38afb3f..723233736c 100644 --- a/typer/_types.py +++ b/typer/_types.py @@ -1,10 +1,13 @@ from collections.abc import Iterable, Mapping, Sequence from enum import Enum -from typing import Any, Generic, TypeVar +from typing import Annotated, Any, Generic, TypeVar + +from pydantic import BeforeValidator, TypeAdapter, ValidationError from . import _click from ._click import types from ._click.shell_completion import CompletionItem +from ._click.types import _get_error_msg ParamTypeValue = TypeVar("ParamTypeValue") @@ -72,29 +75,27 @@ def get_missing_message( choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) return f"Choose from:\n\t{choices}" + def _build_class_adapter( + self, ctx: _click.Context | None + ) -> TypeAdapter[ParamTypeValue]: + normalized_mapping = self._normalized_mapping(ctx=ctx) + + def parse_choice(value: Any) -> ParamTypeValue: + normed_value = self.normalize_choice(choice=value, ctx=ctx) + for original, normalized in normalized_mapping.items(): + if normalized == normed_value: + return original + raise ValueError(self.get_invalid_choice_message(value=value, ctx=ctx)) + + return TypeAdapter(Annotated[ParamTypeValue, BeforeValidator(parse_choice)]) + def convert( self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None ) -> ParamTypeValue: - """ - For a given value from the parser, normalize it and find its - matching normalized value in the list of choices. Then return the - matched "original" choice. - """ - normed_value = self.normalize_choice(choice=value, ctx=ctx) - normalized_mapping = self._normalized_mapping(ctx=ctx) - try: - return next( - original - for original, normalized in normalized_mapping.items() - if normalized == normed_value - ) - except StopIteration: - self.fail( - self.get_invalid_choice_message(value=value, ctx=ctx), - param=param, - ctx=ctx, - ) + return self._build_class_adapter(ctx).validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param=param, ctx=ctx) def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> str: """Get the error message when the given choice is invalid.""" From 570d2be09d11cceb6b38e9a78b92c8efb60dcaaa Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 9 Jun 2026 22:48:39 +0200 Subject: [PATCH 07/73] introduce PydanticParamType --- typer/_click/types.py | 176 +++++++++++++++++++++++------------------- 1 file changed, 96 insertions(+), 80 deletions(-) diff --git a/typer/_click/types.py b/typer/_click/types.py index 57ba8cf237..16d2508b95 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -39,6 +39,26 @@ def _get_error_msg(exc: ValidationError) -> str: return str(exc) +def _build_datetime_adapter( + formats: Sequence[str] | None, +) -> TypeAdapter[datetime]: + if formats is None: + return TypeAdapter(datetime) + + def parse_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + for format in formats: + try: + return datetime.strptime(value, format) + except ValueError: + continue + formats_str = ", ".join(map(repr, formats)) + raise ValueError(f"{value!r} does not match the formats {formats_str}.") + + return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) + + class ParamType: """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -125,6 +145,47 @@ def shell_complete( return [] +class PydanticParamType(ParamType): + _class_adapter: TypeAdapter[Any] + + def __init__( + self, + adapter: TypeAdapter[Any], + *, + name: str, + repr_name: str | None = None, + metavar: str + | Callable[["Parameter", "Context"], str | None] + | None = None, + preprocess: Callable[[Any], Any] | None = None, + ) -> None: + self._class_adapter = adapter + self.name = name + self._repr_name = repr_name or name + self._metavar = metavar + self._preprocess = preprocess + + def convert( + self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] + ) -> Any: + if self._preprocess is not None: + value = self._preprocess(value) + try: + return self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + + def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: + if self._metavar is None: + return None + if callable(self._metavar): + return self._metavar(param, ctx) + return self._metavar + + def __repr__(self) -> str: + return self._repr_name + + class CompositeParamType(ParamType): is_composite = True @@ -179,7 +240,7 @@ def __repr__(self) -> str: return "STRING" -class DateTime(ParamType): +class DateTime(PydanticParamType): """The DateTime type converts date strings into `datetime` objects. The format strings which are checked are configurable, but default to some @@ -195,65 +256,22 @@ class DateTime(ParamType): parses successfully is used. """ - name = "datetime" - _class_adapter: TypeAdapter[datetime] + formats: Sequence[str] | None def __init__(self, formats: Sequence[str] | None = None): - self.formats = tuple(formats) if formats else None - self._class_adapter = self._build_datetime_adapter() - - def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: - formats = self.formats or ["%Y-%m-%d"] - return f"[{'|'.join(formats)}]" - - def _build_datetime_adapter(self) -> TypeAdapter[datetime]: - if self.formats is None: - return TypeAdapter(datetime) - - formats = self.formats - - def parse_datetime(value: Any) -> datetime: - if isinstance(value, datetime): - return value - for format in formats: - try: - return datetime.strptime(value, format) - except ValueError: - continue - formats_str = ", ".join(map(repr, formats)) - raise ValueError(f"{value!r} does not match the formats {formats_str}.") - - return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - try: - return self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - def __repr__(self) -> str: - return "DateTime" + self.formats = tuple(formats) if formats is not None else None + metavar_formats = self.formats or ["%Y-%m-%d"] + super().__init__( + _build_datetime_adapter(self.formats), + name="datetime", + repr_name="DateTime", + metavar=f"[{'|'.join(metavar_formats)}]", + ) -class _NumberParamTypeBase(ParamType): +class _NumberRangeBase(ParamType): _number_class: ClassVar[type[Any]] _class_adapter: TypeAdapter[Any] - - def __init__(self) -> None: - self._class_adapter = TypeAdapter(self._number_class) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - try: - return self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - -class _NumberRangeBase(_NumberParamTypeBase): _range_adapter: TypeAdapter[Any] def __init__( @@ -265,6 +283,9 @@ def __init__( clamp: bool = False, ) -> None: super().__init__() + range_name = type(self).__dict__.get("name") + if range_name is not None: + self.name = range_name self.min = min self.max = max self.min_open = min_open @@ -347,12 +368,15 @@ def __repr__(self) -> str: return f"<{type(self).__name__} {self._describe_range()}{clamp}>" -class IntParamType(_NumberParamTypeBase): - name = "integer" +class IntParamType(PydanticParamType): _number_class = int - def __repr__(self) -> str: - return "INT" + def __init__(self) -> None: + super().__init__( + TypeAdapter(int), + name="integer", + repr_name="INT", + ) class IntRange(_NumberRangeBase, IntParamType): @@ -377,12 +401,15 @@ def _clamp( # type: ignore return bound + dir -class FloatParamType(_NumberParamTypeBase): - name = "float" +class FloatParamType(PydanticParamType): _number_class = float - def __repr__(self) -> str: - return "FLOAT" + def __init__(self) -> None: + super().__init__( + TypeAdapter(float), + name="float", + repr_name="FLOAT", + ) class FloatRange(_NumberRangeBase, FloatParamType): @@ -484,25 +511,14 @@ def __repr__(self) -> str: return "BOOL" -class UUIDParameterType(ParamType): - name = "uuid" - _class_adapter: TypeAdapter[UUID] - +class UUIDParameterType(PydanticParamType): def __init__(self) -> None: - self._class_adapter = TypeAdapter(UUID) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - if isinstance(value, str): - value = value.strip() - try: - return self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - def __repr__(self) -> str: - return "UUID" + super().__init__( + TypeAdapter(UUID), + name="uuid", + repr_name="UUID", + preprocess=lambda value: value.strip() if isinstance(value, str) else value, + ) class File(ParamType): From c8f6dfffa45f926e6084cd13d59841cba85f6adc Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 11:16:35 +0200 Subject: [PATCH 08/73] build_type_adapter --- tests/test_others.py | 2 +- typer/_click/types.py | 146 +++++++++++++++++++++++++++--------------- typer/main.py | 32 +++------ 3 files changed, 102 insertions(+), 78 deletions(-) diff --git a/tests/test_others.py b/tests/test_others.py index 077ed65d4f..9a481309aa 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -451,7 +451,7 @@ def main(arg1, arg2: int, arg3: "int", arg4: bool = False, arg5: "bool" = False) result = runner.invoke(app, ["Hello", "2", "invalid"]) assert "Invalid value for 'ARG3'" in result.output - assert "unable to parse string as an integer" in result.output + assert "Input should be a valid integer" in result.output result = runner.invoke(app, ["Hello", "2", "3", "--arg4", "--arg5"]) assert ( "arg1: Hello\narg2: 2\narg3: 3\narg4: True\narg5: True\n" diff --git a/typer/_click/types.py b/typer/_click/types.py index 16d2508b95..bf8a3678c4 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -2,7 +2,7 @@ import sys from collections.abc import Callable, Sequence from datetime import datetime -from uuid import UUID +from uuid import UUID as UUIDType from typing import ( IO, TYPE_CHECKING, @@ -59,6 +59,41 @@ def parse_datetime(value: Any) -> datetime: return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) +def build_type_adapter( + annotation: Any, + *, + min: float | None = None, + max: float | None = None, + min_open: bool = False, + max_open: bool = False, + formats: Sequence[str] | None = None, +) -> TypeAdapter[Any]: + """Build a Pydantic ``TypeAdapter`` for a CLI annotation and constraints. + + Known constraints (ranges, custom datetime formats, etc.) are applied first; + everything else is delegated to Pydantic via ``TypeAdapter(annotation)``. + """ + if annotation is datetime and formats is not None: + return _build_datetime_adapter(formats) + + if annotation is int or annotation is float: + field_kwargs: dict[str, Any] = {} + if min is not None: + if min_open: + field_kwargs["gt"] = min + else: + field_kwargs["ge"] = min + if max is not None: + if max_open: + field_kwargs["lt"] = max + else: + field_kwargs["le"] = max + if field_kwargs: + return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) + + return TypeAdapter(annotation) + + class ParamType: """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -186,6 +221,10 @@ def __repr__(self) -> str: return self._repr_name +def _strip_string(value: Any) -> Any: + return value.strip() if isinstance(value, str) else value + + class CompositeParamType(ParamType): is_composite = True @@ -262,7 +301,7 @@ def __init__(self, formats: Sequence[str] | None = None): self.formats = tuple(formats) if formats is not None else None metavar_formats = self.formats or ["%Y-%m-%d"] super().__init__( - _build_datetime_adapter(self.formats), + build_type_adapter(datetime, formats=self.formats), name="datetime", repr_name="DateTime", metavar=f"[{'|'.join(metavar_formats)}]", @@ -282,7 +321,7 @@ def __init__( max_open: bool = False, clamp: bool = False, ) -> None: - super().__init__() + self._class_adapter = build_type_adapter(self._number_class) range_name = type(self).__dict__.get("name") if range_name is not None: self.name = range_name @@ -294,21 +333,13 @@ def __init__( self._range_adapter = self._build_range_adapter() def _build_range_adapter(self) -> TypeAdapter[Any]: - field_kwargs: dict[str, Any] = {} - if self.min is not None: - if self.min_open: - field_kwargs["gt"] = self.min - else: - field_kwargs["ge"] = self.min - if self.max is not None: - if self.max_open: - field_kwargs["lt"] = self.max - else: - field_kwargs["le"] = self.max - if not field_kwargs: - return self._class_adapter - annotated = Annotated[self._number_class, Field(**field_kwargs)] - return TypeAdapter(annotated) + return build_type_adapter( + self._number_class, + min=self.min, + max=self.max, + min_open=self.min_open, + max_open=self.max_open, + ) def convert( self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] @@ -368,18 +399,8 @@ def __repr__(self) -> str: return f"<{type(self).__name__} {self._describe_range()}{clamp}>" -class IntParamType(PydanticParamType): +class IntRange(_NumberRangeBase): _number_class = int - - def __init__(self) -> None: - super().__init__( - TypeAdapter(int), - name="integer", - repr_name="INT", - ) - - -class IntRange(_NumberRangeBase, IntParamType): """Restrict an `INT` value to a range of accepted values. See If ``min`` or ``max`` are not passed, any value is accepted in that @@ -401,18 +422,8 @@ def _clamp( # type: ignore return bound + dir -class FloatParamType(PydanticParamType): +class FloatRange(_NumberRangeBase): _number_class = float - - def __init__(self) -> None: - super().__init__( - TypeAdapter(float), - name="float", - repr_name="FLOAT", - ) - - -class FloatRange(_NumberRangeBase, FloatParamType): """Restrict a `FLOAT` value to a range of accepted values. See `ranges`. @@ -511,16 +522,6 @@ def __repr__(self) -> str: return "BOOL" -class UUIDParameterType(PydanticParamType): - def __init__(self) -> None: - super().__init__( - TypeAdapter(UUID), - name="uuid", - repr_name="UUID", - preprocess=lambda value: value.strip() if isinstance(value, str) else value, - ) - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command @@ -727,18 +728,57 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: # An integer parameter. This can also be selected by using ``int`` as # type. -INT = IntParamType() +INT = PydanticParamType( + build_type_adapter(int), name="integer", repr_name="INT" +) # A floating point value parameter. This can also be selected by using # ``float`` as type. -FLOAT = FloatParamType() +FLOAT = PydanticParamType( + build_type_adapter(float), name="float", repr_name="FLOAT" +) # A boolean parameter. This is the default for boolean flags. This can # also be selected by using ``bool`` as a type. BOOL = BoolParamType() # A UUID parameter. -UUID = UUIDParameterType() +UUID = PydanticParamType( + build_type_adapter(UUIDType), + name="uuid", + repr_name="UUID", + preprocess=_strip_string, +) + + +def param_type_from_annotation( + annotation: Any, + *, + min: int | float | None = None, + max: int | float | None = None, + clamp: bool = False, + formats: Sequence[str] | None = None, +) -> ParamType | None: + """Map a type annotation and Typer constraints to a ``ParamType``. + + Unconstrained scalars use ``build_type_adapter`` via ``PydanticParamType``. + ``IntRange`` / ``FloatRange`` are used when ``min``/``max`` (or ``clamp``) apply. + """ + if annotation is int: + if min is not None or max is not None: + min_ = int(min) if min is not None else None + max_ = int(max) if max is not None else None + return IntRange(min=min_, max=max_, clamp=clamp) + return INT + if annotation is float: + if min is not None or max is not None: + return FloatRange(min=min, max=max, clamp=clamp) + return FLOAT + if annotation is UUIDType: + return UUID + if annotation is datetime: + return DateTime(formats=formats) + return None class OptionHelpExtra(TypedDict, total=False): diff --git a/typer/main.py b/typer/main.py index a825c1b14e..bddd9211a0 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1529,32 +1529,16 @@ def get_click_type( elif annotation is str: return types.STRING - elif annotation is int: - if parameter_info.min is not None or parameter_info.max is not None: - min_ = None - max_ = None - if parameter_info.min is not None: - min_ = int(parameter_info.min) - if parameter_info.max is not None: - max_ = int(parameter_info.max) - return types.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) - else: - return types.INT - elif annotation is float: - if parameter_info.min is not None or parameter_info.max is not None: - return types.FloatRange( - min=parameter_info.min, - max=parameter_info.max, - clamp=parameter_info.clamp, - ) - else: - return types.FLOAT + elif pydantic_scalar := types.param_type_from_annotation( + annotation, + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + formats=parameter_info.formats, + ): + return pydantic_scalar elif annotation is bool: return types.BOOL - elif annotation == UUID: - return types.UUID - elif annotation == datetime: - return types.DateTime(formats=parameter_info.formats) elif ( annotation == Path or parameter_info.allow_dash From 15a0e9ad41810243a1d912f15e0890ffe7f4e648 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 11:32:46 +0200 Subject: [PATCH 09/73] consolidate enum validation --- typer/_types.py | 7 ++++++- typer/main.py | 22 +++------------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/typer/_types.py b/typer/_types.py index 723233736c..0b8ebf54f5 100644 --- a/typer/_types.py +++ b/typer/_types.py @@ -105,12 +105,17 @@ def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> def __repr__(self) -> str: return f"Choice({list(self.choices)})" + def _choice_as_str(self, choice: ParamTypeValue) -> str: + if isinstance(choice, Enum): + return str(choice.value) + return str(choice) + def shell_complete( self, ctx: _click.Context, param: _click.Parameter, incomplete: str ) -> list[CompletionItem]: """Complete choices that start with the incomplete value.""" - str_choices = map(str, self.choices) + str_choices = map(self._choice_as_str, self.choices) if self.case_sensitive: matched = (c for c in str_choices if c.startswith(incomplete)) diff --git a/typer/main.py b/typer/main.py index bddd9211a0..eb57813950 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1426,12 +1426,9 @@ def get_command_from_info( def determine_type_convertor(type_: Any) -> Callable[[Any], Any] | None: - convertor: Callable[[Any], Any] | None = None if lenient_issubclass(type_, Path): - convertor = param_path_convertor - if lenient_issubclass(type_, Enum): - convertor = generate_enum_convertor(type_) - return convertor + return param_path_convertor + return None def param_path_convertor(value: str | None = None) -> Path | None: @@ -1442,19 +1439,6 @@ def param_path_convertor(value: str | None = None) -> Path | None: return None -def generate_enum_convertor(enum: type[Enum]) -> Callable[[Any], Any]: - val_map = {str(val.value): val for val in enum} - - def convertor(value: Any) -> Any: - if value is not None: - val = str(value) - if val in val_map: - key = val_map[val] - return enum(key) - - return convertor - - def generate_list_convertor( convertor: Callable[[Any], Any] | None, default_value: Any | None ) -> Callable[[Sequence[Any] | None], list[Any] | None]: @@ -1589,7 +1573,7 @@ def get_click_type( ) elif lenient_issubclass(annotation, Enum): return TyperChoice( - [item.value for item in annotation], + list(annotation), case_sensitive=parameter_info.case_sensitive, ) elif is_literal_type(annotation): From 94cf21b0f75bf8351fd68db4b95958a36054280e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 11:51:31 +0200 Subject: [PATCH 10/73] type adapter for bool parsing --- tests/test_type_conversion.py | 38 +++++++++++++++-- typer/_click/types.py | 80 ++++++++++------------------------- typer/core.py | 6 +-- typer/main.py | 2 - 4 files changed, 59 insertions(+), 67 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 03f9627dc5..757d82fd8b 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -216,6 +216,40 @@ def custom_click_type( assert "2" in result.output +@pytest.mark.parametrize( + ("cli_value", "expected"), + [ + ("true", True), + ("false", False), + ("yes", True), + ("no", False), + ("1", True), + ("0", False), + ("on", True), + ("off", False), + ("t", True), + ("f", False), + ("y", True), + ("n", False), + ("", False), + (" true ", True), + (" FALSE ", False), + ("TRUE", True), + ("No", False), + ], +) +def test_bool_convert_valid(cli_value: str, expected: bool) -> None: + app = typer.Typer() + + @app.command() + def main(value: bool): + print(value) + + result = runner.invoke(app, [cli_value]) + assert result.exit_code == 0 + assert str(expected) in result.output + + def test_bool_convert_invalid(): app = typer.Typer() @@ -225,9 +259,7 @@ def main(value: bool): result = runner.invoke(app, ["maybe"]) assert result.exit_code == 2 - assert "is not a valid boolean" in result.output - assert "yes" in result.output - assert "false" in result.output + assert "Input should be a valid boolean" in result.output @pytest.mark.parametrize( diff --git a/typer/_click/types.py b/typer/_click/types.py index bf8a3678c4..1a043ccdd4 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -59,6 +59,20 @@ def parse_datetime(value: Any) -> datetime: return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) +_bool_adapter = TypeAdapter(bool) + + +def _parse_cli_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + stripped = value.strip() + if stripped == "": + return False + value = stripped + return _bool_adapter.validate_python(value) + + def build_type_adapter( annotation: Any, *, @@ -91,6 +105,9 @@ def build_type_adapter( if field_kwargs: return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) + if annotation is bool: + return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) + return TypeAdapter(annotation) @@ -465,63 +482,6 @@ def _clamp(self, bound: float, dir: Literal[1, -1], open: bool) -> float: ) # pragma: no cover -class BoolParamType(ParamType): - name = "boolean" - - bool_states: dict[str, bool] = { - "1": True, - "0": False, - "yes": True, - "no": False, - "true": True, - "false": False, - "on": True, - "off": False, - "t": True, - "f": False, - "y": True, - "n": False, - # Absence of value is considered False. - "": False, - } - """A mapping of string values to boolean states. - - Mapping is inspired by `configparser.ConfigParser.BOOLEAN_STATES` - and extends it. - """ - - @staticmethod - def str_to_bool(value: str | bool) -> bool | None: - """Convert a string to a boolean value. - - If the value is already a boolean, it is returned as-is. If the value is a - string, it is stripped of whitespaces and lower-cased, then checked against - the known boolean states pre-defined in the `BoolParamType.bool_states` mapping - above. - - Returns `None` if the value does not match any known boolean state. - """ - if isinstance(value, bool): - return value - return BoolParamType.bool_states.get(value.strip().lower()) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> bool: - normalized = self.str_to_bool(value) - if normalized is None: - states = ", ".join(sorted(self.bool_states)) - self.fail( - f"{value!r} is not a valid boolean. Recognized values: {states}", - param, - ctx, - ) - return normalized - - def __repr__(self) -> str: - return "BOOL" - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command @@ -740,7 +700,9 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: # A boolean parameter. This is the default for boolean flags. This can # also be selected by using ``bool`` as a type. -BOOL = BoolParamType() +BOOL = PydanticParamType( + build_type_adapter(bool), name="boolean", repr_name="BOOL" +) # A UUID parameter. UUID = PydanticParamType( @@ -778,6 +740,8 @@ def param_type_from_annotation( return UUID if annotation is datetime: return DateTime(formats=formats) + if annotation is bool: + return BOOL return None diff --git a/typer/core.py b/typer/core.py index 6868ab4355..6935d65690 100644 --- a/typer/core.py +++ b/typer/core.py @@ -508,12 +508,10 @@ def __init__( # TODO: revisit all of this flag stuff if is_flag and type is None: - self.type: types.ParamType = types.BoolParamType() + self.type: types.ParamType = types.BOOL self.is_flag: bool = bool(is_flag) - self.is_bool_flag: bool = bool( - is_flag and isinstance(self.type, types.BoolParamType) - ) + self.is_bool_flag: bool = bool(is_flag and self.type is types.BOOL) if self.is_flag: self._depr_flag_value = True diff --git a/typer/main.py b/typer/main.py index eb57813950..087f50c0d0 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1521,8 +1521,6 @@ def get_click_type( formats=parameter_info.formats, ): return pydantic_scalar - elif annotation is bool: - return types.BOOL elif ( annotation == Path or parameter_info.allow_dash From d0fe9275fc7efacbf08cce286de05c28c0bad0a6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:06:56 +0000 Subject: [PATCH 11/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/_click/types.py | 18 +++++------------- typer/main.py | 2 -- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/typer/_click/types.py b/typer/_click/types.py index 1a043ccdd4..8c6c2bf56e 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -2,7 +2,6 @@ import sys from collections.abc import Callable, Sequence from datetime import datetime -from uuid import UUID as UUIDType from typing import ( IO, TYPE_CHECKING, @@ -17,6 +16,7 @@ Union, cast, ) +from uuid import UUID as UUIDType from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError @@ -206,9 +206,7 @@ def __init__( *, name: str, repr_name: str | None = None, - metavar: str - | Callable[["Parameter", "Context"], str | None] - | None = None, + metavar: str | Callable[["Parameter", "Context"], str | None] | None = None, preprocess: Callable[[Any], Any] | None = None, ) -> None: self._class_adapter = adapter @@ -688,21 +686,15 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: # An integer parameter. This can also be selected by using ``int`` as # type. -INT = PydanticParamType( - build_type_adapter(int), name="integer", repr_name="INT" -) +INT = PydanticParamType(build_type_adapter(int), name="integer", repr_name="INT") # A floating point value parameter. This can also be selected by using # ``float`` as type. -FLOAT = PydanticParamType( - build_type_adapter(float), name="float", repr_name="FLOAT" -) +FLOAT = PydanticParamType(build_type_adapter(float), name="float", repr_name="FLOAT") # A boolean parameter. This is the default for boolean flags. This can # also be selected by using ``bool`` as a type. -BOOL = PydanticParamType( - build_type_adapter(bool), name="boolean", repr_name="BOOL" -) +BOOL = PydanticParamType(build_type_adapter(bool), name="boolean", repr_name="BOOL") # A UUID parameter. UUID = PydanticParamType( diff --git a/typer/main.py b/typer/main.py index 087f50c0d0..2519286233 100644 --- a/typer/main.py +++ b/typer/main.py @@ -6,14 +6,12 @@ import sys import traceback from collections.abc import Callable, Sequence -from datetime import datetime from enum import Enum from functools import update_wrapper from pathlib import Path from traceback import FrameSummary, StackSummary from types import TracebackType from typing import Annotated, Any -from uuid import UUID from annotated_doc import Doc from typer._types import TyperChoice From b749fc2e08591b3726ecbff617e30deb17fa99cf Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 12:13:02 +0200 Subject: [PATCH 12/73] fix types --- typer/_click/types.py | 6 +++--- typer/_types.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/typer/_click/types.py b/typer/_click/types.py index 8c6c2bf56e..8880337c2e 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -228,9 +228,9 @@ def convert( def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: if self._metavar is None: return None - if callable(self._metavar): - return self._metavar(param, ctx) - return self._metavar + if isinstance(self._metavar, str): + return self._metavar + return self._metavar(param, ctx) def __repr__(self) -> str: return self._repr_name diff --git a/typer/_types.py b/typer/_types.py index 0b8ebf54f5..f215fbf00c 100644 --- a/typer/_types.py +++ b/typer/_types.py @@ -87,7 +87,7 @@ def parse_choice(value: Any) -> ParamTypeValue: return original raise ValueError(self.get_invalid_choice_message(value=value, ctx=ctx)) - return TypeAdapter(Annotated[ParamTypeValue, BeforeValidator(parse_choice)]) + return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) def convert( self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None From 48ff1f430950da2d925ac1797436b4266d6627a7 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 14:04:20 +0200 Subject: [PATCH 13/73] param_type_from_annotation in new module param_types --- tests/test_type_conversion.py | 11 ++++ typer/_click/types.py | 32 ----------- typer/main.py | 99 ++++++++--------------------------- typer/models.py | 24 ++++++++- typer/param_types.py | 74 ++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 109 deletions(-) create mode 100644 typer/param_types.py diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 757d82fd8b..b4d085f27f 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -310,6 +310,17 @@ def show(path: Any = typer.Option(..., path_type=path_type)): assert "my_awesome_file" in result.output +def test_str_with_path_options() -> None: + app = typer.Typer() + + @app.command() + def warp(loc: str = typer.Option(..., resolve_path=True)): + print(loc) + + param = next(p for p in typer.main.get_command(app).params if p.name == "loc") + assert isinstance(param.type, models.TyperPath) + + @pytest.mark.parametrize( ("create_file", "option_kwargs", "deny_mode", "expected_error"), [ diff --git a/typer/_click/types.py b/typer/_click/types.py index 8880337c2e..63cc12da2b 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -705,38 +705,6 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: ) -def param_type_from_annotation( - annotation: Any, - *, - min: int | float | None = None, - max: int | float | None = None, - clamp: bool = False, - formats: Sequence[str] | None = None, -) -> ParamType | None: - """Map a type annotation and Typer constraints to a ``ParamType``. - - Unconstrained scalars use ``build_type_adapter`` via ``PydanticParamType``. - ``IntRange`` / ``FloatRange`` are used when ``min``/``max`` (or ``clamp``) apply. - """ - if annotation is int: - if min is not None or max is not None: - min_ = int(min) if min is not None else None - max_ = int(max) if max is not None else None - return IntRange(min=min_, max=max_, clamp=clamp) - return INT - if annotation is float: - if min is not None or max is not None: - return FloatRange(min=min, max=max, clamp=clamp) - return FLOAT - if annotation is UUIDType: - return UUID - if annotation is datetime: - return DateTime(formats=formats) - if annotation is bool: - return BOOL - return None - - class OptionHelpExtra(TypedDict, total=False): envvars: tuple[str, ...] default: str diff --git a/typer/main.py b/typer/main.py index 2519286233..156f803d04 100644 --- a/typer/main.py +++ b/typer/main.py @@ -6,20 +6,17 @@ import sys import traceback from collections.abc import Callable, Sequence -from enum import Enum from functools import update_wrapper -from pathlib import Path from traceback import FrameSummary, StackSummary from types import TracebackType from typing import Annotated, Any from annotated_doc import Doc -from typer._types import TyperChoice from . import _click from ._click import types from ._click.globals import get_current_context -from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values +from ._typing import get_args, get_origin, is_union from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -48,8 +45,8 @@ ParamMeta, Required, TyperInfo, - TyperPath, ) +from .param_types import param_type_from_annotation from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1423,45 +1420,26 @@ def get_command_from_info( return command -def determine_type_convertor(type_: Any) -> Callable[[Any], Any] | None: - if lenient_issubclass(type_, Path): - return param_path_convertor - return None - - -def param_path_convertor(value: str | None = None) -> Path | None: - if value is not None: - # allow returning any subclass of Path created by an annotated parser without converting - # it back to a Path - return value if isinstance(value, Path) else Path(value) - return None - - def generate_list_convertor( - convertor: Callable[[Any], Any] | None, default_value: Any | None + default_value: Any | None, ) -> Callable[[Sequence[Any] | None], list[Any] | None]: def internal_convertor(value: Sequence[Any] | None) -> list[Any] | None: if (value is None) or (default_value is None and len(value) == 0): return None - return [convertor(v) if convertor else v for v in value] + return list(value) return internal_convertor -def generate_tuple_convertor( - types: Sequence[Any], -) -> Callable[[tuple[Any, ...] | None], tuple[Any, ...] | None]: - convertors = [determine_type_convertor(type_) for type_ in types] - +def generate_tuple_convertor() -> Callable[ + [tuple[Any, ...] | None], tuple[Any, ...] | None +]: def internal_convertor( param_args: tuple[Any, ...] | None, ) -> tuple[Any, ...] | None: if param_args is None: return None - return tuple( - convertor(arg) if convertor else arg - for (convertor, arg) in zip(convertors, param_args, strict=False) - ) + return param_args return internal_convertor @@ -1506,36 +1484,17 @@ def get_click_type( if parameter_info.click_type is not None: return parameter_info.click_type - elif parameter_info.parser is not None: + if parameter_info.parser is not None: return types.FuncParamType(parameter_info.parser) - elif annotation is str: + param_type = param_type_from_annotation(annotation, parameter_info) + if param_type is not None: + return param_type + + if annotation is str: return types.STRING - elif pydantic_scalar := types.param_type_from_annotation( - annotation, - min=parameter_info.min, - max=parameter_info.max, - clamp=parameter_info.clamp, - formats=parameter_info.formats, - ): - return pydantic_scalar - elif ( - annotation == Path - or parameter_info.allow_dash - or parameter_info.path_type - or parameter_info.resolve_path - ): - return TyperPath( - exists=parameter_info.exists, - file_okay=parameter_info.file_okay, - dir_okay=parameter_info.dir_okay, - writable=parameter_info.writable, - readable=parameter_info.readable, - resolve_path=parameter_info.resolve_path, - allow_dash=parameter_info.allow_dash, - path_type=parameter_info.path_type, - ) - elif lenient_issubclass(annotation, FileTextWrite): + + if lenient_issubclass(annotation, FileTextWrite): return types.File( mode=parameter_info.mode or "w", encoding=parameter_info.encoding, @@ -1543,7 +1502,7 @@ def get_click_type( lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) - elif lenient_issubclass(annotation, FileText): + if lenient_issubclass(annotation, FileText): return types.File( mode=parameter_info.mode or "r", encoding=parameter_info.encoding, @@ -1551,7 +1510,7 @@ def get_click_type( lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) - elif lenient_issubclass(annotation, FileBinaryRead): + if lenient_issubclass(annotation, FileBinaryRead): return types.File( mode=parameter_info.mode or "rb", encoding=parameter_info.encoding, @@ -1559,7 +1518,7 @@ def get_click_type( lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) - elif lenient_issubclass(annotation, FileBinaryWrite): + if lenient_issubclass(annotation, FileBinaryWrite): return types.File( mode=parameter_info.mode or "wb", encoding=parameter_info.encoding, @@ -1567,16 +1526,6 @@ def get_click_type( lazy=parameter_info.lazy, atomic=parameter_info.atomic, ) - elif lenient_issubclass(annotation, Enum): - return TyperChoice( - list(annotation), - case_sensitive=parameter_info.case_sensitive, - ) - elif is_literal_type(annotation): - return TyperChoice( - literal_values(annotation), - case_sensitive=parameter_info.case_sensitive, - ) raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover @@ -1650,13 +1599,11 @@ def get_click_param( parameter_type = get_click_type( annotation=main_type, parameter_info=parameter_info ) - convertor = determine_type_convertor(main_type) + convertor: Callable[..., Any] | None = None if is_list: - convertor = generate_list_convertor( - convertor=convertor, default_value=default_value - ) - if is_tuple: - convertor = generate_tuple_convertor(get_args(main_type)) + convertor = generate_list_convertor(default_value) + elif is_tuple: + convertor = generate_tuple_convertor() if isinstance(parameter_info, OptionInfo): if main_type is bool: is_flag = True diff --git a/typer/models.py b/typer/models.py index 00385c38ce..76bc99b1c1 100644 --- a/typer/models.py +++ b/typer/models.py @@ -3,6 +3,7 @@ import os import stat from collections.abc import Callable, Sequence +from pathlib import Path from typing import ( TYPE_CHECKING, Any, @@ -12,9 +13,12 @@ cast, ) +from pydantic import ValidationError + from . import _click from ._click import types from ._click.shell_completion import CompletionItem +from ._click.types import _get_error_msg, build_type_adapter if TYPE_CHECKING: # pragma: no cover from .core import TyperCommand, TyperGroup @@ -678,6 +682,24 @@ def __init__( else: self.name = "path" + def _parse_path_value( + self, + value: Any, + param: _click.Parameter | None, + ctx: Context | None, + ) -> Any: + if self.type is None or self.type is str or self.type is bytes: + return value + if isinstance(self.type, type) and issubclass(self.type, Path): + if isinstance(value, self.type): + return value + if isinstance(value, (str, os.PathLike)): + try: + return build_type_adapter(self.type).validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + return value + def coerce_path_result( self, value: str | os.PathLike[str] ) -> str | bytes | os.PathLike[str]: @@ -699,7 +721,7 @@ def convert( # ty: ignore[invalid-method-override] param: _click.Parameter | None, ctx: Context | None, # type: ignore[override] ) -> str | bytes | os.PathLike[str]: - rv = value + rv = self._parse_path_value(value, param, ctx) is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") diff --git a/typer/param_types.py b/typer/param_types.py new file mode 100644 index 0000000000..f5494f4536 --- /dev/null +++ b/typer/param_types.py @@ -0,0 +1,74 @@ +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any +from uuid import UUID as UUIDType + +from ._click import types +from ._types import TyperChoice +from ._typing import is_literal_type, literal_values +from .models import ParameterInfo, TyperPath + + +def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: + return ( + annotation == Path + or parameter_info.allow_dash + or parameter_info.path_type is not None + or parameter_info.resolve_path + ) + + +def param_type_from_annotation( + annotation: Any, + parameter_info: ParameterInfo, +) -> types.ParamType | None: + if annotation is int: + if parameter_info.min is not None or parameter_info.max is not None: + min_ = int(parameter_info.min) if parameter_info.min is not None else None + max_ = int(parameter_info.max) if parameter_info.max is not None else None + return types.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) + return types.INT + if annotation is float: + if parameter_info.min is not None or parameter_info.max is not None: + return types.FloatRange( + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + ) + return types.FLOAT + if annotation is UUIDType: + return types.UUID + if annotation is datetime: + return types.DateTime(formats=parameter_info.formats) + if annotation is bool: + return types.BOOL + if _needs_typer_path(annotation, parameter_info): + resolved_path_type: type[Any] | None = parameter_info.path_type + if ( + resolved_path_type is None + and isinstance(annotation, type) + and issubclass(annotation, Path) + ): + resolved_path_type = annotation + return TyperPath( + exists=parameter_info.exists, + file_okay=parameter_info.file_okay, + dir_okay=parameter_info.dir_okay, + writable=parameter_info.writable, + readable=parameter_info.readable, + resolve_path=parameter_info.resolve_path, + allow_dash=parameter_info.allow_dash, + path_type=resolved_path_type, + ) + if isinstance(annotation, type) and issubclass(annotation, Enum): + return TyperChoice( + list(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + if is_literal_type(annotation): + return TyperChoice( + literal_values(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + return None From e0fd56dc5d9fd3f2e761f057e62916b42ef9da23 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 14:42:14 +0200 Subject: [PATCH 14/73] move str and File to new module --- typer/main.py | 46 +------------------------------------------- typer/param_types.py | 44 +++++++++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/typer/main.py b/typer/main.py index 156f803d04..0bfa2cb7dd 100644 --- a/typer/main.py +++ b/typer/main.py @@ -28,17 +28,12 @@ TyperOption, ) from .models import ( - AnyType, ArgumentInfo, CommandFunctionType, CommandInfo, Default, DefaultPlaceholder, DeveloperExceptionConfig, - FileBinaryRead, - FileBinaryWrite, - FileText, - FileTextWrite, NoneType, OptionInfo, ParameterInfo, @@ -46,7 +41,7 @@ Required, TyperInfo, ) -from .param_types import param_type_from_annotation +from .param_types import lenient_issubclass, param_type_from_annotation from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1491,48 +1486,9 @@ def get_click_type( if param_type is not None: return param_type - if annotation is str: - return types.STRING - - if lenient_issubclass(annotation, FileTextWrite): - return types.File( - mode=parameter_info.mode or "w", - encoding=parameter_info.encoding, - errors=parameter_info.errors, - lazy=parameter_info.lazy, - atomic=parameter_info.atomic, - ) - if lenient_issubclass(annotation, FileText): - return types.File( - mode=parameter_info.mode or "r", - encoding=parameter_info.encoding, - errors=parameter_info.errors, - lazy=parameter_info.lazy, - atomic=parameter_info.atomic, - ) - if lenient_issubclass(annotation, FileBinaryRead): - return types.File( - mode=parameter_info.mode or "rb", - encoding=parameter_info.encoding, - errors=parameter_info.errors, - lazy=parameter_info.lazy, - atomic=parameter_info.atomic, - ) - if lenient_issubclass(annotation, FileBinaryWrite): - return types.File( - mode=parameter_info.mode or "wb", - encoding=parameter_info.encoding, - errors=parameter_info.errors, - lazy=parameter_info.lazy, - atomic=parameter_info.atomic, - ) raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover -def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: - return isinstance(cls, type) and issubclass(cls, class_or_tuple) - - def get_click_param( param: ParamMeta, ) -> tuple[TyperArgument | TyperOption, Any]: diff --git a/typer/param_types.py b/typer/param_types.py index f5494f4536..fd32040d2a 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -7,7 +7,31 @@ from ._click import types from ._types import TyperChoice from ._typing import is_literal_type, literal_values -from .models import ParameterInfo, TyperPath +from .models import ( + AnyType, + FileBinaryRead, + FileBinaryWrite, + FileText, + FileTextWrite, + ParameterInfo, + TyperPath, +) + + +def lenient_issubclass( + cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...] +) -> bool: + return isinstance(cls, type) and issubclass(cls, class_or_tuple) + + +def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: + return types.File( + mode=parameter_info.mode or mode, + encoding=parameter_info.encoding, + errors=parameter_info.errors, + lazy=parameter_info.lazy, + atomic=parameter_info.atomic, + ) def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: @@ -45,11 +69,7 @@ def param_type_from_annotation( return types.BOOL if _needs_typer_path(annotation, parameter_info): resolved_path_type: type[Any] | None = parameter_info.path_type - if ( - resolved_path_type is None - and isinstance(annotation, type) - and issubclass(annotation, Path) - ): + if resolved_path_type is None and lenient_issubclass(annotation, Path): resolved_path_type = annotation return TyperPath( exists=parameter_info.exists, @@ -61,7 +81,7 @@ def param_type_from_annotation( allow_dash=parameter_info.allow_dash, path_type=resolved_path_type, ) - if isinstance(annotation, type) and issubclass(annotation, Enum): + if lenient_issubclass(annotation, Enum): return TyperChoice( list(annotation), case_sensitive=parameter_info.case_sensitive, @@ -71,4 +91,14 @@ def param_type_from_annotation( literal_values(annotation), case_sensitive=parameter_info.case_sensitive, ) + if annotation is str: + return types.STRING + if lenient_issubclass(annotation, FileTextWrite): + return _file_param_type(parameter_info, mode="w") + if lenient_issubclass(annotation, FileText): + return _file_param_type(parameter_info, mode="r") + if lenient_issubclass(annotation, FileBinaryRead): + return _file_param_type(parameter_info, mode="rb") + if lenient_issubclass(annotation, FileBinaryWrite): + return _file_param_type(parameter_info, mode="wb") return None From 5eb5ade208d724c35d8eba713ea62b49a143c8e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:42:58 +0000 Subject: [PATCH 15/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/param_types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/typer/param_types.py b/typer/param_types.py index fd32040d2a..77cec290a1 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -18,9 +18,7 @@ ) -def lenient_issubclass( - cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...] -) -> bool: +def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: return isinstance(cls, type) and issubclass(cls, class_or_tuple) From 4495762ac9c77dfeef274871cfaf6e79ea3faff2 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 15:52:42 +0200 Subject: [PATCH 16/73] clean up converter code --- typer/main.py | 214 ++++++++++++++++++++------------------------------ 1 file changed, 85 insertions(+), 129 deletions(-) diff --git a/typer/main.py b/typer/main.py index 0bfa2cb7dd..2fe7b3bccc 100644 --- a/typer/main.py +++ b/typer/main.py @@ -101,8 +101,8 @@ def except_hook( def get_install_completion_arguments() -> tuple[_click.Parameter, _click.Parameter]: install_param, show_param = get_completion_inspect_parameters() - click_install_param, _ = get_click_param(install_param) - click_show_param, _ = get_click_param(show_param) + click_install_param = get_param(install_param) + click_show_param = get_param(show_param) return click_install_param, click_show_param @@ -1309,11 +1309,9 @@ def get_group_from_info( for sub_command_name, sub_command in sub_group.commands.items(): commands[sub_command_name] = sub_command solved_info = solve_typer_info_defaults(group_info) - ( - params, - convertors, - context_param_name, - ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) + params, context_param_name = get_params_ctx_param_name_from_function( + solved_info.callback + ) cls = solved_info.cls or TyperGroup assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" group = cls( @@ -1327,7 +1325,6 @@ def get_group_from_info( callback=get_callback( callback=solved_info.callback, params=params, - convertors=convertors, context_param_name=context_param_name, pretty_exceptions_short=pretty_exceptions_short, ), @@ -1351,11 +1348,10 @@ def get_command_name(name: str) -> str: return name.lower().replace("_", "-") -def get_params_convertors_ctx_param_name_from_function( +def get_params_ctx_param_name_from_function( callback: Callable[..., Any] | None, -) -> tuple[list[TyperArgument | TyperOption], dict[str, Any], str | None]: +) -> tuple[list[TyperArgument | TyperOption], str | None]: params = [] - convertors = {} context_param_name = None if callback: parameters = get_params_from_function(callback) @@ -1363,11 +1359,8 @@ def get_params_convertors_ctx_param_name_from_function( if lenient_issubclass(param.annotation, _click.Context): context_param_name = param_name continue - click_param, convertor = get_click_param(param) - if convertor: - convertors[param_name] = convertor - params.append(click_param) - return params, convertors, context_param_name + params.append(get_param(param)) + return params, context_param_name def get_command_from_info( @@ -1383,11 +1376,9 @@ def get_command_from_info( use_help = inspect.getdoc(command_info.callback) else: use_help = inspect.cleandoc(use_help) - ( - params, - convertors, - context_param_name, - ) = get_params_convertors_ctx_param_name_from_function(command_info.callback) + params, context_param_name = get_params_ctx_param_name_from_function( + command_info.callback + ) cls = command_info.cls or TyperCommand command = cls( name=name, @@ -1395,7 +1386,6 @@ def get_command_from_info( callback=get_callback( callback=command_info.callback, params=params, - convertors=convertors, context_param_name=context_param_name, pretty_exceptions_short=pretty_exceptions_short, ), @@ -1415,54 +1405,42 @@ def get_command_from_info( return command -def generate_list_convertor( - default_value: Any | None, -) -> Callable[[Sequence[Any] | None], list[Any] | None]: - def internal_convertor(value: Sequence[Any] | None) -> list[Any] | None: - if (value is None) or (default_value is None and len(value) == 0): - return None - return list(value) - - return internal_convertor - - -def generate_tuple_convertor() -> Callable[ - [tuple[Any, ...] | None], tuple[Any, ...] | None -]: - def internal_convertor( - param_args: tuple[Any, ...] | None, - ) -> tuple[Any, ...] | None: - if param_args is None: - return None - return param_args - - return internal_convertor +def _normalize_collection_value(param: _click.Parameter, value: Any) -> Any: + if value is None: + return None + is_multi = getattr(param, "multiple", False) or getattr(param, "nargs", 1) == -1 + if not is_multi: + return value + if param.default is None and len(value) == 0: + return None + return list(value) def get_callback( *, callback: Callable[..., Any] | None = None, params: Sequence[_click.Parameter] = [], - convertors: dict[str, Callable[[str], Any]] | None = None, context_param_name: str | None = None, pretty_exceptions_short: bool, ) -> Callable[..., Any] | None: - use_convertors = convertors or {} if not callback: return None parameters = get_params_from_function(callback) use_params: dict[str, Any] = {} for param_name in parameters: use_params[param_name] = None + params_by_name: dict[str, _click.Parameter] = {} for param in params: if param.name: use_params[param.name] = param.default + params_by_name[param.name] = param def wrapper(**kwargs: Any) -> Any: _rich_traceback_guard = pretty_exceptions_short # noqa: F841 for k, v in kwargs.items(): - if k in use_convertors: - use_params[k] = use_convertors[k](v) + click_param = params_by_name.get(k) + if click_param is not None: + use_params[k] = _normalize_collection_value(click_param, v) else: use_params[k] = v if context_param_name: @@ -1473,7 +1451,7 @@ def wrapper(**kwargs: Any) -> Any: return wrapper -def get_click_type( +def get_param_type( *, annotation: Any, parameter_info: ParameterInfo ) -> types.ParamType: if parameter_info.click_type is not None: @@ -1489,9 +1467,9 @@ def get_click_type( raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover -def get_click_param( +def get_param( param: ParamMeta, -) -> tuple[TyperArgument | TyperOption, Any]: +) -> TyperArgument | TyperOption: # First, find out what will be: # * ParamInfo (ArgumentInfo or OptionInfo) # * default_value @@ -1517,7 +1495,6 @@ def get_click_param( annotation = str main_type = annotation is_list = False - is_tuple = False parameter_type: Any = None is_flag = None origin = get_origin(main_type) @@ -1547,19 +1524,13 @@ def get_click_param( "Tuple types with complex sub-types are not currently supported" ) types.append( - get_click_type(annotation=type_, parameter_info=parameter_info) + get_param_type(annotation=type_, parameter_info=parameter_info) ) parameter_type = tuple(types) - is_tuple = True if parameter_type is None: - parameter_type = get_click_type( + parameter_type = get_param_type( annotation=main_type, parameter_info=parameter_info ) - convertor: Callable[..., Any] | None = None - if is_list: - convertor = generate_list_convertor(default_value) - elif is_tuple: - convertor = generate_tuple_convertor() if isinstance(parameter_info, OptionInfo): if main_type is bool: is_flag = True @@ -1578,82 +1549,71 @@ def get_click_param( param_decls.extend(parameter_info.param_decls) else: param_decls.append(default_option_declaration) - return ( - TyperOption( - # Option - param_decls=param_decls, - show_default=parameter_info.show_default, - prompt=parameter_info.prompt, - confirmation_prompt=parameter_info.confirmation_prompt, - prompt_required=parameter_info.prompt_required, - hide_input=parameter_info.hide_input, - is_flag=is_flag, - multiple=is_list, - count=parameter_info.count, - allow_from_autoenv=parameter_info.allow_from_autoenv, - type=parameter_type, - help=parameter_info.help, - hidden=parameter_info.hidden, - show_choices=parameter_info.show_choices, - show_envvar=parameter_info.show_envvar, - # Parameter - required=required, - default=default_value, - callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor - ), - metavar=parameter_info.metavar, - expose_value=parameter_info.expose_value, - is_eager=parameter_info.is_eager, - envvar=parameter_info.envvar, - shell_complete=parameter_info.shell_complete, - autocompletion=get_param_completion(parameter_info.autocompletion), - # Rich settings - rich_help_panel=parameter_info.rich_help_panel, - ), - convertor, + return TyperOption( + # Option + param_decls=param_decls, + show_default=parameter_info.show_default, + prompt=parameter_info.prompt, + confirmation_prompt=parameter_info.confirmation_prompt, + prompt_required=parameter_info.prompt_required, + hide_input=parameter_info.hide_input, + is_flag=is_flag, + multiple=is_list, + count=parameter_info.count, + allow_from_autoenv=parameter_info.allow_from_autoenv, + type=parameter_type, + help=parameter_info.help, + hidden=parameter_info.hidden, + show_choices=parameter_info.show_choices, + show_envvar=parameter_info.show_envvar, + # Parameter + required=required, + default=default_value, + callback=get_param_callback(callback=parameter_info.callback), + metavar=parameter_info.metavar, + expose_value=parameter_info.expose_value, + is_eager=parameter_info.is_eager, + envvar=parameter_info.envvar, + shell_complete=parameter_info.shell_complete, + autocompletion=get_param_completion(parameter_info.autocompletion), + # Rich settings + rich_help_panel=parameter_info.rich_help_panel, ) elif isinstance(parameter_info, ArgumentInfo): param_decls = [param.name] nargs = None if is_list: nargs = -1 - return ( - TyperArgument( - # Argument - param_decls=param_decls, - type=parameter_type, - required=required, - nargs=nargs, - # TyperArgument - show_default=parameter_info.show_default, - show_choices=parameter_info.show_choices, - show_envvar=parameter_info.show_envvar, - help=parameter_info.help, - hidden=parameter_info.hidden, - # Parameter - default=default_value, - callback=get_param_callback( - callback=parameter_info.callback, convertor=convertor - ), - metavar=parameter_info.metavar, - expose_value=parameter_info.expose_value, - is_eager=parameter_info.is_eager, - envvar=parameter_info.envvar, - shell_complete=parameter_info.shell_complete, - autocompletion=get_param_completion(parameter_info.autocompletion), - # Rich settings - rich_help_panel=parameter_info.rich_help_panel, - ), - convertor, + return TyperArgument( + # Argument + param_decls=param_decls, + type=parameter_type, + required=required, + nargs=nargs, + # TyperArgument + show_default=parameter_info.show_default, + show_choices=parameter_info.show_choices, + show_envvar=parameter_info.show_envvar, + help=parameter_info.help, + hidden=parameter_info.hidden, + # Parameter + default=default_value, + callback=get_param_callback(callback=parameter_info.callback), + metavar=parameter_info.metavar, + expose_value=parameter_info.expose_value, + is_eager=parameter_info.is_eager, + envvar=parameter_info.envvar, + shell_complete=parameter_info.shell_complete, + autocompletion=get_param_completion(parameter_info.autocompletion), + # Rich settings + rich_help_panel=parameter_info.rich_help_panel, ) - raise AssertionError("A _click.Parameter should be returned") # pragma: no cover + raise AssertionError("A Parameter should be returned") # pragma: no cover def get_param_callback( *, callback: Callable[..., Any] | None = None, - convertor: Callable[..., Any] | None = None, ) -> Callable[..., Any] | None: if not callback: return None @@ -1691,11 +1651,7 @@ def wrapper(ctx: _click.Context, param: _click.Parameter, value: Any) -> Any: if click_param_name: use_params[click_param_name] = param if value_name: - if convertor: - use_value = convertor(value) - else: - use_value = value - use_params[value_name] = use_value + use_params[value_name] = _normalize_collection_value(param, value) return callback(**use_params) update_wrapper(wrapper, callback) From 31432df936627f6f2f161bc2217594a14a48da5e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 16:37:43 +0200 Subject: [PATCH 17/73] remove support for click_type and open bounds --- tests/test_others.py | 44 ----- tests/test_type_conversion.py | 34 +--- tests/test_types.py | 5 - typer/_click/types.py | 100 ++--------- typer/main.py | 18 +- typer/models.py | 13 -- typer/param_types.py | 13 ++ typer/params.py | 305 +--------------------------------- 8 files changed, 32 insertions(+), 500 deletions(-) diff --git a/tests/test_others.py b/tests/test_others.py index 9a481309aa..c1dfe8f5bc 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -32,50 +32,6 @@ def test_defaults_from_info(): assert value -def test_too_many_parsers(): - def custom_parser(value: str) -> int: - return int(value) # pragma: no cover - - class CustomClickParser(_click.types.ParamType): - name = "custom_parser" - - def convert( - self, - value: str, - param: _click.Parameter | None, - ctx: _click.Context | None, - ) -> typing.Any: - return int(value) # pragma: no cover - - expected_error = ( - "Multiple custom type parsers provided. " - "`parser` and `click_type` may not both be provided." - ) - - with pytest.raises(ValueError, match=expected_error): - ParameterInfo(parser=custom_parser, click_type=CustomClickParser()) - - -def test_valid_parser_permutations(): - def custom_parser(value: str) -> int: - return int(value) # pragma: no cover - - class CustomClickParser(_click.types.ParamType): - name = "custom_parser" - - def convert( - self, - value: str, - param: _click.Parameter | None, - ctx: _click.Context | None, - ) -> typing.Any: - return int(value) # pragma: no cover - - ParameterInfo() - ParameterInfo(parser=custom_parser) - ParameterInfo(click_type=CustomClickParser()) - - @requires_completion_permission def test_install_invalid_shell(): app = typer.Typer() diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index b4d085f27f..1393742cef 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -175,23 +175,12 @@ def custom_parser( assert "Invalid value" in result.output -def test_custom_click_type(): - class BaseNumberParamType(_click.types.ParamType): - name = "base_integer" - - def convert( - self, - value: Any, - param: _click.Parameter | None, - ctx: _click.Context | None, - ) -> Any: - return int(value, 0) - +def test_custom_parser_hex(): app = typer.Typer() @app.command() - def custom_click_type( - hex_value: int = typer.Argument(None, click_type=BaseNumberParamType()), + def custom_parser_hex( + hex_value: int = typer.Argument(None, parser=lambda x: int(x, 0)), ): assert hex_value == 0x56 @@ -199,23 +188,6 @@ def custom_click_type( assert result.exit_code == 0 -def test_int_range_open_bound_clamp(): - app = typer.Typer() - - @app.command() - def custom_click_type( - value: int = typer.Argument( - ..., - click_type=_click.types.IntRange(min=1, min_open=True, clamp=True), - ), - ): - print(value) - - result = runner.invoke(app, ["1"]) - assert result.exit_code == 0 - assert "2" in result.output - - @pytest.mark.parametrize( ("cli_value", "expected"), [ diff --git a/tests/test_types.py b/tests/test_types.py index db6dae08da..6e31516e4e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -158,8 +158,3 @@ def test_list_pair() -> None: assert result.exit_code == 0 assert "items=['a', 'b', 'c']" in result.output assert "pair=('x', 'y')" in result.output - - -def test_float_range_open_bounds_with_clamp_not_allowed(): - with pytest.raises(TypeError, match="Clamping is not supported for open bounds."): - _click.types.FloatRange(min=0.0, min_open=True, clamp=True) diff --git a/typer/_click/types.py b/typer/_click/types.py index 63cc12da2b..825624031d 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -8,7 +8,6 @@ Annotated, Any, ClassVar, - Literal, NoReturn, TypedDict, TypeGuard, @@ -78,8 +77,6 @@ def build_type_adapter( *, min: float | None = None, max: float | None = None, - min_open: bool = False, - max_open: bool = False, formats: Sequence[str] | None = None, ) -> TypeAdapter[Any]: """Build a Pydantic ``TypeAdapter`` for a CLI annotation and constraints. @@ -93,15 +90,9 @@ def build_type_adapter( if annotation is int or annotation is float: field_kwargs: dict[str, Any] = {} if min is not None: - if min_open: - field_kwargs["gt"] = min - else: - field_kwargs["ge"] = min + field_kwargs["ge"] = min if max is not None: - if max_open: - field_kwargs["lt"] = max - else: - field_kwargs["le"] = max + field_kwargs["le"] = max if field_kwargs: return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) @@ -332,8 +323,6 @@ def __init__( self, min: float | None = None, max: float | None = None, - min_open: bool = False, - max_open: bool = False, clamp: bool = False, ) -> None: self._class_adapter = build_type_adapter(self._number_class) @@ -342,8 +331,6 @@ def __init__( self.name = range_name self.min = min self.max = max - self.min_open = min_open - self.max_open = max_open self.clamp = clamp self._range_adapter = self._build_range_adapter() @@ -352,8 +339,6 @@ def _build_range_adapter(self) -> TypeAdapter[Any]: self._number_class, min=self.min, max=self.max, - min_open=self.min_open, - max_open=self.max_open, ) def convert( @@ -372,42 +357,23 @@ def convert( self.fail(_get_error_msg(exc), param, ctx) # adjust the min/max accordingly - import operator + if self.min is not None and rv < self.min: + return self.min - lt_min: bool = self.min is not None and ( - operator.le if self.min_open else operator.lt - )(rv, self.min) - gt_max: bool = self.max is not None and ( - operator.ge if self.max_open else operator.gt - )(rv, self.max) - - if lt_min: - return self._clamp(self.min, 1, self.min_open) # type: ignore[arg-type] - - if gt_max: - return self._clamp(self.max, -1, self.max_open) # type: ignore[arg-type] + if self.max is not None and rv > self.max: + return self.max return rv - def _clamp(self, bound: float, dir: Literal[1, -1], open: bool) -> float: - """Find the valid value to clamp to bound in the given - direction. - """ - raise NotImplementedError # pragma: no cover - def _describe_range(self) -> str: """Describe the range for use in help text.""" if self.min is None: - op = "<" if self.max_open else "<=" - return f"x{op}{self.max}" + return f"x<={self.max}" if self.max is None: - op = ">" if self.min_open else ">=" - return f"x{op}{self.min}" + return f"x>={self.min}" - lop = "<" if self.min_open else "<=" - rop = "<" if self.max_open else "<=" - return f"{self.min}{lop}x{rop}{self.max}" + return f"{self.min}<=x<={self.max}" def __repr__(self) -> str: clamp = " clamped" if self.clamp else "" @@ -416,11 +382,10 @@ def __repr__(self) -> str: class IntRange(_NumberRangeBase): _number_class = int - """Restrict an `INT` value to a range of accepted values. See + """Restrict an `INT` value to a range of accepted values. If ``min`` or ``max`` are not passed, any value is accepted in that - direction. If ``min_open`` or ``max_open`` are enabled, the - corresponding boundary is not included in the range. + direction. If ``clamp`` is enabled, a value outside the range is clamped to the boundary instead of failing. @@ -428,57 +393,20 @@ class IntRange(_NumberRangeBase): name = "integer range" - def _clamp( # type: ignore - self, bound: int, dir: Literal[1, -1], open: bool - ) -> int: - if not open: - return bound - - return bound + dir - class FloatRange(_NumberRangeBase): _number_class = float - """Restrict a `FLOAT` value to a range of accepted - values. See `ranges`. + """Restrict a `FLOAT` value to a range of accepted values. If ``min`` or ``max`` are not passed, any value is accepted in that - direction. If ``min_open`` or ``max_open`` are enabled, the - corresponding boundary is not included in the range. + direction. If ``clamp`` is enabled, a value outside the range is clamped to the - boundary instead of failing. This is not supported if either - boundary is marked ``open``. + boundary instead of failing. """ name = "float range" - def __init__( - self, - min: float | None = None, - max: float | None = None, - min_open: bool = False, - max_open: bool = False, - clamp: bool = False, - ) -> None: - super().__init__( - min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp - ) - - if (min_open or max_open) and clamp: - raise TypeError("Clamping is not supported for open bounds.") - - def _clamp(self, bound: float, dir: Literal[1, -1], open: bool) -> float: - if not open: - return bound - - # Could use math.nextafter here, but clamping an - # open float range doesn't seem to be particularly useful. It's - # left up to the user to write a callback to do it if needed. - raise RuntimeError( - "Clamping is not supported for open bounds." - ) # pragma: no cover - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file diff --git a/typer/main.py b/typer/main.py index 2fe7b3bccc..d65e4a8cda 100644 --- a/typer/main.py +++ b/typer/main.py @@ -41,7 +41,7 @@ Required, TyperInfo, ) -from .param_types import lenient_issubclass, param_type_from_annotation +from .param_types import lenient_issubclass, param_type_from_annotation, get_param_type from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1451,22 +1451,6 @@ def wrapper(**kwargs: Any) -> Any: return wrapper -def get_param_type( - *, annotation: Any, parameter_info: ParameterInfo -) -> types.ParamType: - if parameter_info.click_type is not None: - return parameter_info.click_type - - if parameter_info.parser is not None: - return types.FuncParamType(parameter_info.parser) - - param_type = param_type_from_annotation(annotation, parameter_info) - if param_type is not None: - return param_type - - raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover - - def get_param( param: ParamMeta, ) -> TyperArgument | TyperOption: diff --git a/typer/models.py b/typer/models.py index 76bc99b1c1..b8f2e5b982 100644 --- a/typer/models.py +++ b/typer/models.py @@ -303,7 +303,6 @@ def __init__( default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: types.ParamType | None = None, # TyperArgument show_default: bool | str = True, show_choices: bool = True, @@ -336,13 +335,6 @@ def __init__( # Rich settings rich_help_panel: str | None = None, ): - # Check if user has provided multiple custom parsers - if parser and click_type: - raise ValueError( - "Multiple custom type parsers provided. " - "`parser` and `click_type` may not both be provided." - ) - self.default = default self.param_decls = param_decls self.callback = callback @@ -355,7 +347,6 @@ def __init__( self.default_factory = default_factory # Custom type self.parser = parser - self.click_type = click_type # TyperArgument self.show_default = show_default self.show_choices = show_choices @@ -412,7 +403,6 @@ def __init__( default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: types.ParamType | None = None, # Option show_default: bool | str = True, prompt: bool | str = False, @@ -467,7 +457,6 @@ def __init__( default_factory=default_factory, # Custom type parser=parser, - click_type=click_type, # TyperArgument show_default=show_default, show_choices=show_choices, @@ -540,7 +529,6 @@ def __init__( default_factory: Callable[[], Any] | None = None, # Custom type parser: Callable[[str], Any] | None = None, - click_type: types.ParamType | None = None, # TyperArgument show_default: bool | str = True, show_choices: bool = True, @@ -586,7 +574,6 @@ def __init__( default_factory=default_factory, # Custom type parser=parser, - click_type=click_type, # TyperArgument show_default=show_default, show_choices=show_choices, diff --git a/typer/param_types.py b/typer/param_types.py index 77cec290a1..923d0a2511 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -41,6 +41,19 @@ def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: ) +def get_param_type( + *, annotation: Any, parameter_info: ParameterInfo +) -> types.ParamType: + if parameter_info.parser is not None: + return types.FuncParamType(parameter_info.parser) + + param_type = param_type_from_annotation(annotation, parameter_info) + if param_type is not None: + return param_type + + raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover + + def param_type_from_annotation( annotation: Any, parameter_info: ParameterInfo, diff --git a/typer/params.py b/typer/params.py index 833461fa78..5aef6da32d 100644 --- a/typer/params.py +++ b/typer/params.py @@ -1,10 +1,9 @@ from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, overload +from typing import TYPE_CHECKING, Annotated, Any from annotated_doc import Doc from . import _click -from ._click import types from ._click.shell_completion import CompletionItem from .models import ArgumentInfo, OptionInfo @@ -12,136 +11,6 @@ pass -# Overload for Option created with custom type 'parser' -@overload -def Option( - # Parameter - default: Any | None = ..., - *param_decls: str, - callback: Callable[..., Any] | None = None, - metavar: str | None = None, - expose_value: bool = True, - is_eager: bool = False, - envvar: str | list[str] | None = None, - # Note that shell_complete is not fully supported and will be removed in future versions - # TODO: Remove shell_complete in a future version (after 0.16.0) - shell_complete: Callable[ - [_click.Context, _click.Parameter, str], - list["CompletionItem"] | list[str], - ] - | None = None, - autocompletion: Callable[..., Any] | None = None, - default_factory: Callable[[], Any] | None = None, - # Custom type - parser: Callable[[str], Any] | None = None, - # Option - show_default: bool | str = True, - prompt: bool | str = False, - confirmation_prompt: bool = False, - prompt_required: bool = True, - hide_input: bool = False, - # TODO: remove is_flag and flag_value in a future release - is_flag: bool | None = None, - flag_value: Any | None = None, - count: bool = False, - allow_from_autoenv: bool = True, - help: str | None = None, - hidden: bool = False, - show_choices: bool = True, - show_envvar: bool = True, - # Choice - case_sensitive: bool = True, - # Numbers - min: int | float | None = None, - max: int | float | None = None, - clamp: bool = False, - # DateTime - formats: list[str] | None = None, - # File - mode: str | None = None, - encoding: str | None = None, - errors: str | None = "strict", - lazy: bool | None = None, - atomic: bool = False, - # Path - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: None | type[str] | type[bytes] = None, - # Rich settings - rich_help_panel: str | None = None, -) -> Any: ... - - -# Overload for Option created with custom type 'click_type' -@overload -def Option( - # Parameter - default: Any | None = ..., - *param_decls: str, - callback: Callable[..., Any] | None = None, - metavar: str | None = None, - expose_value: bool = True, - is_eager: bool = False, - envvar: str | list[str] | None = None, - # Note that shell_complete is not fully supported and will be removed in future versions - # TODO: Remove shell_complete in a future version (after 0.16.0) - shell_complete: Callable[ - [_click.Context, _click.Parameter, str], - list["CompletionItem"] | list[str], - ] - | None = None, - autocompletion: Callable[..., Any] | None = None, - default_factory: Callable[[], Any] | None = None, - # Custom type - click_type: types.ParamType | None = None, - # Option - show_default: bool | str = True, - prompt: bool | str = False, - confirmation_prompt: bool = False, - prompt_required: bool = True, - hide_input: bool = False, - # TODO: remove is_flag and flag_value in a future release - is_flag: bool | None = None, - flag_value: Any | None = None, - count: bool = False, - allow_from_autoenv: bool = True, - help: str | None = None, - hidden: bool = False, - show_choices: bool = True, - show_envvar: bool = True, - # Choice - case_sensitive: bool = True, - # Numbers - min: int | float | None = None, - max: int | float | None = None, - clamp: bool = False, - # DateTime - formats: list[str] | None = None, - # File - mode: str | None = None, - encoding: str | None = None, - errors: str | None = "strict", - lazy: bool | None = None, - atomic: bool = False, - # Path - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: None | type[str] | type[bytes] = None, - # Rich settings - rich_help_panel: str | None = None, -) -> Any: ... - - def Option( # Parameter default: Annotated[ @@ -344,35 +213,6 @@ def main(opt: Annotated[CustomClass, typer.Option(parser=my_parser)] = "Foo"): """ ), ] = None, - click_type: Annotated[ - types.ParamType | None, - Doc( - """ - Define this parameter to use a [custom Click type](https://click.palletsprojects.com/en/stable/parameters/#implementing-custom-types) in your Typer applications. - - **Example** - - ```python - class MyClass: - def __init__(self, value: str): - self.value = value - - def __str__(self): - return f"" - - class MyParser(click.ParamType): - name = "MyClass" - - def convert(self, value, param, ctx): - return MyClass(value * 3) - - @app.command() - def main(opt: Annotated[MyClass, typer.Option(click_type=MyParser())] = "Foo"): - print(f"--opt is {opt}") - ``` - """ - ), - ] = None, # Option show_default: Annotated[ bool | str, @@ -959,7 +799,6 @@ def register( default_factory=default_factory, # Custom type parser=parser, - click_type=click_type, # Option show_default=show_default, prompt=prompt, @@ -1002,118 +841,6 @@ def register( ) -# Overload for Argument created with custom type 'parser' -@overload -def Argument( - # Parameter - default: Any | None = ..., - *, - callback: Callable[..., Any] | None = None, - metavar: str | None = None, - expose_value: bool = True, - is_eager: bool = False, - envvar: str | list[str] | None = None, - # Note that shell_complete is not fully supported and will be removed in future versions - # TODO: Remove shell_complete in a future version (after 0.16.0) - shell_complete: Callable[ - [_click.Context, _click.Parameter, str], - list["CompletionItem"] | list[str], - ] - | None = None, - autocompletion: Callable[..., Any] | None = None, - default_factory: Callable[[], Any] | None = None, - # Custom type - parser: Callable[[str], Any] | None = None, - # TyperArgument - show_default: bool | str = True, - show_choices: bool = True, - show_envvar: bool = True, - help: str | None = None, - hidden: bool = False, - # Choice - case_sensitive: bool = True, - # Numbers - min: int | float | None = None, - max: int | float | None = None, - clamp: bool = False, - # DateTime - formats: list[str] | None = None, - # File - mode: str | None = None, - encoding: str | None = None, - errors: str | None = "strict", - lazy: bool | None = None, - atomic: bool = False, - # Path - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: None | type[str] | type[bytes] = None, - # Rich settings - rich_help_panel: str | None = None, -) -> Any: ... - - -# Overload for Argument created with custom type 'click_type' -@overload -def Argument( - # Parameter - default: Any | None = ..., - *, - callback: Callable[..., Any] | None = None, - metavar: str | None = None, - expose_value: bool = True, - is_eager: bool = False, - envvar: str | list[str] | None = None, - # Note that shell_complete is not fully supported and will be removed in future versions - # TODO: Remove shell_complete in a future version (after 0.16.0) - shell_complete: Callable[ - [_click.Context, _click.Parameter, str], - list["CompletionItem"] | list[str], - ] - | None = None, - autocompletion: Callable[..., Any] | None = None, - default_factory: Callable[[], Any] | None = None, - # Custom type - click_type: types.ParamType | None = None, - # TyperArgument - show_default: bool | str = True, - show_choices: bool = True, - show_envvar: bool = True, - help: str | None = None, - hidden: bool = False, - # Choice - case_sensitive: bool = True, - # Numbers - min: int | float | None = None, - max: int | float | None = None, - clamp: bool = False, - # DateTime - formats: list[str] | None = None, - # File - mode: str | None = None, - encoding: str | None = None, - errors: str | None = "strict", - lazy: bool | None = None, - atomic: bool = False, - # Path - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: None | type[str] | type[bytes] = None, - # Rich settings - rich_help_panel: str | None = None, -) -> Any: ... - - def Argument( # Parameter default: Annotated[ @@ -1298,35 +1025,6 @@ def main(arg: Annotated[CustomClass, typer.Argument(parser=my_parser): """ ), ] = None, - click_type: Annotated[ - types.ParamType | None, - Doc( - """ - Define this parameter to use a [custom Click type](https://click.palletsprojects.com/en/stable/parameters/#implementing-custom-types) in your Typer applications. - - **Example** - - ```python - class MyClass: - def __init__(self, value: str): - self.value = value - - def __str__(self): - return f"" - - class MyParser(click.ParamType): - name = "MyClass" - - def convert(self, value, param, ctx): - return MyClass(value * 3) - - @app.command() - def main(arg: Annotated[MyClass, typer.Argument(click_type=MyParser())]): - print(f"arg is {arg}") - ``` - """ - ), - ] = None, # TyperArgument show_default: Annotated[ bool | str, @@ -1798,7 +1496,6 @@ def main(name: Annotated[str, typer.Argument()] = "World"): default_factory=default_factory, # Custom type parser=parser, - click_type=click_type, # TyperArgument show_default=show_default, show_choices=show_choices, From eaa68ddea63872378f63be315bc768fd343ca343 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 16:38:19 +0200 Subject: [PATCH 18/73] datetime factory --- typer/_click/types.py | 37 ++++++++++--------------------------- typer/param_types.py | 2 +- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/typer/_click/types.py b/typer/_click/types.py index 825624031d..7c77e91aca 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -285,33 +285,16 @@ def __repr__(self) -> str: return "STRING" -class DateTime(PydanticParamType): - """The DateTime type converts date strings into `datetime` objects. - - The format strings which are checked are configurable, but default to some - common (non-timezone aware) ISO 8601 formats. - - When specifying *DateTime* formats, you should only pass a list or a tuple. - Other iterables, like generators, may lead to surprising results. - - The format strings are processed using ``datetime.strptime``, and this - consequently defines the format strings which are allowed. - - Parsing is tried using each format, in order, and the first format which - parses successfully is used. - """ - - formats: Sequence[str] | None - - def __init__(self, formats: Sequence[str] | None = None): - self.formats = tuple(formats) if formats is not None else None - metavar_formats = self.formats or ["%Y-%m-%d"] - super().__init__( - build_type_adapter(datetime, formats=self.formats), - name="datetime", - repr_name="DateTime", - metavar=f"[{'|'.join(metavar_formats)}]", - ) +def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamType: + formats_tuple = tuple(formats) if formats is not None else None + metavar_formats = formats_tuple or ["%Y-%m-%d"] + + return PydanticParamType( + build_type_adapter(datetime, formats=formats_tuple), + name="datetime", + repr_name="DateTime", + metavar=f"[{'|'.join(metavar_formats)}]", + ) class _NumberRangeBase(ParamType): diff --git a/typer/param_types.py b/typer/param_types.py index 923d0a2511..0838094a20 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -75,7 +75,7 @@ def param_type_from_annotation( if annotation is UUIDType: return types.UUID if annotation is datetime: - return types.DateTime(formats=parameter_info.formats) + return types.datetime_param_type(formats=parameter_info.formats) if annotation is bool: return types.BOOL if _needs_typer_path(annotation, parameter_info): From a0e429e6b8707af39880af2b44d429c8777e8ead Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:39:14 +0000 Subject: [PATCH 19/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_others.py | 2 +- tests/test_types.py | 2 -- typer/main.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_others.py b/tests/test_others.py index c1dfe8f5bc..e02c356ce7 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -12,7 +12,7 @@ import typer.completion from typer import _click from typer.main import solve_typer_info_defaults, solve_typer_info_help -from typer.models import ParameterInfo, TyperInfo +from typer.models import TyperInfo from typer.testing import CliRunner from .utils import requires_completion_permission diff --git a/tests/test_types.py b/tests/test_types.py index 6e31516e4e..a1c19bf266 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,8 +1,6 @@ from enum import Enum -import pytest import typer -from typer import _click from typer.testing import CliRunner app = typer.Typer(context_settings={"token_normalize_func": str.lower}) diff --git a/typer/main.py b/typer/main.py index d65e4a8cda..8631f3a38d 100644 --- a/typer/main.py +++ b/typer/main.py @@ -14,7 +14,6 @@ from annotated_doc import Doc from . import _click -from ._click import types from ._click.globals import get_current_context from ._typing import get_args, get_origin, is_union from .completion import get_completion_inspect_parameters @@ -41,7 +40,7 @@ Required, TyperInfo, ) -from .param_types import lenient_issubclass, param_type_from_annotation, get_param_type +from .param_types import get_param_type, lenient_issubclass from .utils import get_params_from_function _original_except_hook = sys.excepthook From b79b40165c1af1bb6fd91bb07f2193fec63acfbd Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 17:48:56 +0200 Subject: [PATCH 20/73] move min/max info into TyperOption and TyperArgument and clamp using AfterValidator --- tests/test_core.py | 4 +- tests/test_type_conversion.py | 3 - typer/_click/types.py | 126 ++++++++-------------------------- typer/core.py | 45 +++++++++--- typer/main.py | 4 ++ typer/param_types.py | 31 ++++++++- typer/rich_utils.py | 22 +++--- typer/utils.py | 27 ++++++++ 8 files changed, 136 insertions(+), 126 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 2cb759918b..ccaf960476 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -93,8 +93,8 @@ def test_parameter_constructor() -> None: required=False, count=True, ) - assert isinstance(option.type, _click.types.IntRange) - assert option.type.min == 0 + assert option.type.name == "integer range" + assert option.min == 0 def test_option_error_hint() -> None: diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 1393742cef..3cc431d11d 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -365,9 +365,6 @@ def test_convert_type(): assert convert_type(float) is _click.types.FLOAT assert convert_type(bool) is _click.types.BOOL - param_type = _click.types.IntRange(min=0, max=10) - assert convert_type(param_type) is param_type - guessed_int = convert_type(None, default=42) assert guessed_int is _click.types.INT diff --git a/typer/_click/types.py b/typer/_click/types.py index 7c77e91aca..9179ea0310 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -17,7 +17,7 @@ ) from uuid import UUID as UUIDType -from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError +from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter, ValidationError from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter @@ -72,22 +72,46 @@ def _parse_cli_bool(value: Any) -> bool: return _bool_adapter.validate_python(value) +def _make_number_clamp_validator( + number_class: type[Any], + min: float | None, + max: float | None, +) -> Callable[[Any], Any]: + def clamp_number(value: Any) -> Any: + if min is not None and value < min: + return number_class(min) + if max is not None and value > max: + return number_class(max) + return value + + return clamp_number + + def build_type_adapter( annotation: Any, *, min: float | None = None, max: float | None = None, + clamp: bool = False, formats: Sequence[str] | None = None, ) -> TypeAdapter[Any]: - """Build a Pydantic ``TypeAdapter`` for a CLI annotation and constraints. + """Build a Pydantic TypeAdapter for a CLI annotation and constraints. - Known constraints (ranges, custom datetime formats, etc.) are applied first; - everything else is delegated to Pydantic via ``TypeAdapter(annotation)``. + Known constraints (ranges, custom datetime formats, etc.) are applied first, + everything else is delegated to Pydantic. """ if annotation is datetime and formats is not None: return _build_datetime_adapter(formats) if annotation is int or annotation is float: + if clamp: + # Use AfterValidator so it runs after coercion + return TypeAdapter( + Annotated[ + annotation, + AfterValidator(_make_number_clamp_validator(annotation, min, max)), + ] + ) field_kwargs: dict[str, Any] = {} if min is not None: field_kwargs["ge"] = min @@ -297,100 +321,6 @@ def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamTy ) -class _NumberRangeBase(ParamType): - _number_class: ClassVar[type[Any]] - _class_adapter: TypeAdapter[Any] - _range_adapter: TypeAdapter[Any] - - def __init__( - self, - min: float | None = None, - max: float | None = None, - clamp: bool = False, - ) -> None: - self._class_adapter = build_type_adapter(self._number_class) - range_name = type(self).__dict__.get("name") - if range_name is not None: - self.name = range_name - self.min = min - self.max = max - self.clamp = clamp - self._range_adapter = self._build_range_adapter() - - def _build_range_adapter(self) -> TypeAdapter[Any]: - return build_type_adapter( - self._number_class, - min=self.min, - max=self.max, - ) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - if not self.clamp: - try: - return self._range_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - # Clamping - only check the class, don't error on range - try: - rv = self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - # adjust the min/max accordingly - if self.min is not None and rv < self.min: - return self.min - - if self.max is not None and rv > self.max: - return self.max - - return rv - - def _describe_range(self) -> str: - """Describe the range for use in help text.""" - if self.min is None: - return f"x<={self.max}" - - if self.max is None: - return f"x>={self.min}" - - return f"{self.min}<=x<={self.max}" - - def __repr__(self) -> str: - clamp = " clamped" if self.clamp else "" - return f"<{type(self).__name__} {self._describe_range()}{clamp}>" - - -class IntRange(_NumberRangeBase): - _number_class = int - """Restrict an `INT` value to a range of accepted values. - - If ``min`` or ``max`` are not passed, any value is accepted in that - direction. - - If ``clamp`` is enabled, a value outside the range is clamped to the - boundary instead of failing. - """ - - name = "integer range" - - -class FloatRange(_NumberRangeBase): - _number_class = float - """Restrict a `FLOAT` value to a range of accepted values. - - If ``min`` or ``max`` are not passed, any value is accepted in that - direction. - - If ``clamp`` is enabled, a value outside the range is clamped to the - boundary instead of failing. - """ - - name = "float range" - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command diff --git a/typer/core.py b/typer/core.py index 6935d65690..79a34bfc94 100644 --- a/typer/core.py +++ b/typer/core.py @@ -18,7 +18,7 @@ from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem from ._typing import Literal -from .utils import parse_boolean_env_var +from .utils import describe_number_range, parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] MARKUP_MODE_KEY = "TYPER_RICH_MARKUP_MODE" @@ -31,6 +31,17 @@ DEFAULT_MARKUP_MODE = None +def get_number_range_help_str(param: "TyperOption | TyperArgument") -> str | None: + if ( + isinstance(param, TyperOption) + and param.count + and param.min == 0 + and param.max is None + ): + return None + return describe_number_range(param.min, param.max) + + # Copy from _click.parser._split_opt def _split_opt(opt: str) -> tuple[str, str]: first = opt[:1] @@ -273,6 +284,9 @@ def __init__( show_envvar: bool = True, help: str | None = None, hidden: bool = False, + # Numbers + min: int | float | None = None, + max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, ): @@ -281,6 +295,8 @@ def __init__( self.show_choices = show_choices self.show_envvar = show_envvar self.hidden = hidden + self.min = min + self.max = max self.rich_help_panel = rich_help_panel super().__init__( @@ -363,6 +379,9 @@ def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: # Typer override end if default_string: extra.append(_("default: {default}").format(default=default_string)) + range_str = get_number_range_help_str(self) + if range_str: + extra.append(range_str) if self.required: extra.append(_("required")) if extra: @@ -469,12 +488,18 @@ def __init__( hidden: bool = False, show_choices: bool = True, show_envvar: bool = False, + # Numbers + min: int | float | None = None, + max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, ): if help: help = inspect.cleandoc(help) + self.min = min + self.max = max + super().__init__( param_decls, type=type, @@ -518,10 +543,16 @@ def __init__( else: self._depr_flag_value = None - # Counting. TODO: test or remove? Not currently in coverage. + # Counting self.count = count if count and type is None: - self.type = types.IntRange(min=0) + self.type = types.PydanticParamType( + types.build_type_adapter(int, min=0), + name="integer range", + repr_name="INT", + ) + if self.min is None: + self.min = 0 self.allow_from_autoenv = allow_from_autoenv self.help = help @@ -813,11 +844,9 @@ def _write_opts(opts: Sequence[str]) -> str: if default_string: extra.append(_("default: {default}").format(default=default_string)) - if isinstance(self.type, types._NumberRangeBase): - range_str = self.type._describe_range() - - if range_str: - extra.append(range_str) + range_str = get_number_range_help_str(self) + if range_str: + extra.append(range_str) if self.required: extra.append(_("required")) diff --git a/typer/main.py b/typer/main.py index d65e4a8cda..9b4da2fdcf 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1560,6 +1560,8 @@ def get_param( envvar=parameter_info.envvar, shell_complete=parameter_info.shell_complete, autocompletion=get_param_completion(parameter_info.autocompletion), + min=parameter_info.min, + max=parameter_info.max, # Rich settings rich_help_panel=parameter_info.rich_help_panel, ) @@ -1589,6 +1591,8 @@ def get_param( envvar=parameter_info.envvar, shell_complete=parameter_info.shell_complete, autocompletion=get_param_completion(parameter_info.autocompletion), + min=parameter_info.min, + max=parameter_info.max, # Rich settings rich_help_panel=parameter_info.rich_help_panel, ) diff --git a/typer/param_types.py b/typer/param_types.py index 0838094a20..e4d63c30be 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -16,6 +16,7 @@ ParameterInfo, TyperPath, ) +from .utils import number_range_repr_name def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: @@ -32,6 +33,22 @@ def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: ) +def _ranged_number_param_type( + number_class: type[Any], + *, + class_name: str, + name: str, + min: int | float | None, + max: int | float | None, + clamp: bool, +) -> types.ParamType: + return types.PydanticParamType( + types.build_type_adapter(number_class, min=min, max=max, clamp=clamp), + name=name, + repr_name=number_range_repr_name(class_name, min, max, clamp=clamp), + ) + + def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: return ( annotation == Path @@ -62,11 +79,21 @@ def param_type_from_annotation( if parameter_info.min is not None or parameter_info.max is not None: min_ = int(parameter_info.min) if parameter_info.min is not None else None max_ = int(parameter_info.max) if parameter_info.max is not None else None - return types.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) + return _ranged_number_param_type( + int, + class_name="IntRange", + name="integer range", + min=min_, + max=max_, + clamp=parameter_info.clamp, + ) return types.INT if annotation is float: if parameter_info.min is not None or parameter_info.max is not None: - return types.FloatRange( + return _ranged_number_param_type( + float, + class_name="FloatRange", + name="float range", min=parameter_info.min, max=parameter_info.max, clamp=parameter_info.clamp, diff --git a/typer/rich_utils.py b/typer/rich_utils.py index e5b106126e..f8fbf7fd9a 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -25,8 +25,12 @@ from typer.models import DeveloperExceptionConfig from . import _click -from ._click import types -from .core import TyperArgument, TyperGroup, TyperOption +from .core import ( + TyperArgument, + TyperGroup, + TyperOption, + get_number_range_help_str, +) # Default styles STYLE_OPTION = "bold cyan" @@ -390,17 +394,9 @@ def _print_options_panel( if metavar_str != "BOOLEAN": metavar.append(metavar_str) - # Range - from - # https://github.com/pallets/click/blob/c63c70dabd3f86ca68678b4f00951f78f52d0270/src/click/core.py#L2698-L2706 # noqa: E501 - # skip count with default range type - if ( - isinstance(param.type, types._NumberRangeBase) - and isinstance(param, TyperOption) - and not (param.count and param.type.min == 0 and param.type.max is None) - ): - range_str = param.type._describe_range() - if range_str: - metavar.append(RANGE_STRING.format(range_str)) + range_str = get_number_range_help_str(param) + if range_str: + metavar.append(RANGE_STRING.format(range_str)) # Required asterisk required: str | Text = "" diff --git a/typer/utils.py b/typer/utils.py index addf9334d4..b60015719b 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -186,6 +186,33 @@ def get_params_from_function(func: Callable[..., Any]) -> dict[str, ParamMeta]: return params +def describe_number_range( + min: int | float | None, + max: int | float | None, +) -> str | None: + if min is None and max is None: + return None + if min is None: + return f"x<={max}" + if max is None: + return f"x>={min}" + return f"{min}<=x<={max}" + + +def number_range_repr_name( + class_name: str, + min: int | float | None, + max: int | float | None, + *, + clamp: bool = False, +) -> str: + range_str = describe_number_range(min, max) + if range_str is None: + return class_name + clamp_suffix = " clamped" if clamp else "" + return f"<{class_name} {range_str}{clamp_suffix}>" + + def parse_boolean_env_var(env_var_value: str | None, default: bool) -> bool: if env_var_value is None: return default From 3953649c02a32f082a59ce2929efdc081a3c38da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:50:11 +0000 Subject: [PATCH 21/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/_click/types.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/typer/_click/types.py b/typer/_click/types.py index 9179ea0310..8256572e9c 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -17,7 +17,13 @@ ) from uuid import UUID as UUIDType -from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter, ValidationError +from pydantic import ( + AfterValidator, + BeforeValidator, + Field, + TypeAdapter, + ValidationError, +) from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter From 943b3356b157e86db4d58b60d80df321681f2c57 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 10 Jun 2026 18:09:14 +0200 Subject: [PATCH 22/73] repr cleanup --- .../test_number/test_tutorial001.py | 13 -------- typer/core.py | 1 - typer/param_types.py | 32 ++++++------------- typer/utils.py | 14 -------- 4 files changed, 9 insertions(+), 51 deletions(-) diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index e660a83f6a..d68bbf6993 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -24,19 +24,6 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: return mod -def test_type_repr(mod: ModuleType): - command = typer.main.get_command(mod.app) - - id_param = next(param for param in command.params if param.name == "id") - assert repr(id_param.type) == "" - - age_param = next(param for param in command.params if param.name == "age") - assert repr(age_param.type) == "=18>" - - score_param = next(param for param in command.params if param.name == "score") - assert repr(score_param.type) == "" - - def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 diff --git a/typer/core.py b/typer/core.py index 79a34bfc94..f677b23810 100644 --- a/typer/core.py +++ b/typer/core.py @@ -549,7 +549,6 @@ def __init__( self.type = types.PydanticParamType( types.build_type_adapter(int, min=0), name="integer range", - repr_name="INT", ) if self.min is None: self.min = 0 diff --git a/typer/param_types.py b/typer/param_types.py index e4d63c30be..5f6e21550f 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -16,7 +16,6 @@ ParameterInfo, TyperPath, ) -from .utils import number_range_repr_name def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: @@ -36,16 +35,13 @@ def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: def _ranged_number_param_type( number_class: type[Any], *, - class_name: str, - name: str, min: int | float | None, max: int | float | None, clamp: bool, ) -> types.ParamType: return types.PydanticParamType( types.build_type_adapter(number_class, min=min, max=max, clamp=clamp), - name=name, - repr_name=number_range_repr_name(class_name, min, max, clamp=clamp), + name="integer range" if number_class is int else "float range", ) @@ -75,30 +71,20 @@ def param_type_from_annotation( annotation: Any, parameter_info: ParameterInfo, ) -> types.ParamType | None: - if annotation is int: + if annotation is int or annotation is float: if parameter_info.min is not None or parameter_info.max is not None: - min_ = int(parameter_info.min) if parameter_info.min is not None else None - max_ = int(parameter_info.max) if parameter_info.max is not None else None + min_ = parameter_info.min + max_ = parameter_info.max + if annotation is int: + min_ = int(min_) if min_ is not None else None + max_ = int(max_) if max_ is not None else None return _ranged_number_param_type( - int, - class_name="IntRange", - name="integer range", + annotation, min=min_, max=max_, clamp=parameter_info.clamp, ) - return types.INT - if annotation is float: - if parameter_info.min is not None or parameter_info.max is not None: - return _ranged_number_param_type( - float, - class_name="FloatRange", - name="float range", - min=parameter_info.min, - max=parameter_info.max, - clamp=parameter_info.clamp, - ) - return types.FLOAT + return types.INT if annotation is int else types.FLOAT if annotation is UUIDType: return types.UUID if annotation is datetime: diff --git a/typer/utils.py b/typer/utils.py index b60015719b..5142921114 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -199,20 +199,6 @@ def describe_number_range( return f"{min}<=x<={max}" -def number_range_repr_name( - class_name: str, - min: int | float | None, - max: int | float | None, - *, - clamp: bool = False, -) -> str: - range_str = describe_number_range(min, max) - if range_str is None: - return class_name - clamp_suffix = " clamped" if clamp else "" - return f"<{class_name} {range_str}{clamp_suffix}>" - - def parse_boolean_env_var(env_var_value: str | None, default: bool) -> bool: if env_var_value is None: return default From 5b5085e01ecef80d1c50341a1283d6e0d11e87f2 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 11 Jun 2026 15:45:25 +0200 Subject: [PATCH 23/73] move _types.py stuff to param_types.py --- typer/_click/termui.py | 2 +- typer/_types.py | 126 ----------------------------------------- typer/param_types.py | 124 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 129 deletions(-) delete mode 100644 typer/_types.py diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 0a8c82574d..e6d5f131db 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -54,7 +54,7 @@ def _build_prompt( type: ParamType | None = None, ) -> str: # prevent circular imports - from .._types import TyperChoice + from ..param_types import TyperChoice prompt = text if type is not None and show_choices and isinstance(type, TyperChoice): diff --git a/typer/_types.py b/typer/_types.py deleted file mode 100644 index f215fbf00c..0000000000 --- a/typer/_types.py +++ /dev/null @@ -1,126 +0,0 @@ -from collections.abc import Iterable, Mapping, Sequence -from enum import Enum -from typing import Annotated, Any, Generic, TypeVar - -from pydantic import BeforeValidator, TypeAdapter, ValidationError - -from . import _click -from ._click import types -from ._click.shell_completion import CompletionItem -from ._click.types import _get_error_msg - -ParamTypeValue = TypeVar("ParamTypeValue") - - -class TyperChoice(types.ParamType, Generic[ParamTypeValue]): - # Code adapted from Click 8.3.1, with Typer using enum values in normalize_choice - name = "choice" - - def __init__( - self, choices: Iterable[ParamTypeValue], case_sensitive: bool = True - ) -> None: - self.choices: Sequence[ParamTypeValue] = tuple(choices) - self.case_sensitive = case_sensitive - - def _normalized_mapping( - self, ctx: _click.Context | None = None - ) -> Mapping[ParamTypeValue, str]: - """ - Returns mapping where keys are the original choices and the values are - the normalized values that are accepted via the command line. - """ - return { - choice: self.normalize_choice( - choice=choice, - ctx=ctx, - ) - for choice in self.choices - } - - def normalize_choice( - self, choice: ParamTypeValue, ctx: _click.Context | None - ) -> str: - normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) - - if ctx is not None and ctx.token_normalize_func is not None: - normed_value = ctx.token_normalize_func(normed_value) - - if not self.case_sensitive: - normed_value = normed_value.casefold() - - return normed_value - - def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: - if param.param_type_name == "option" and not param.show_choices: # type: ignore - choice_metavars = [ - types.convert_type(type(choice)).name.upper() for choice in self.choices - ] - choices_str = "|".join([*dict.fromkeys(choice_metavars)]) - else: - choices_str = "|".join( - [str(i) for i in self._normalized_mapping(ctx=ctx).values()] - ) - - # Use curly braces to indicate a required argument. - if param.required and param.param_type_name == "argument": - return f"{{{choices_str}}}" - - # Use square braces to indicate an option or optional argument. - return f"[{choices_str}]" - - def get_missing_message( - self, param: _click.Parameter, ctx: _click.Context | None - ) -> str: - """Message shown when no choice is passed.""" - choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) - return f"Choose from:\n\t{choices}" - - def _build_class_adapter( - self, ctx: _click.Context | None - ) -> TypeAdapter[ParamTypeValue]: - normalized_mapping = self._normalized_mapping(ctx=ctx) - - def parse_choice(value: Any) -> ParamTypeValue: - normed_value = self.normalize_choice(choice=value, ctx=ctx) - for original, normalized in normalized_mapping.items(): - if normalized == normed_value: - return original - raise ValueError(self.get_invalid_choice_message(value=value, ctx=ctx)) - - return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) - - def convert( - self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None - ) -> ParamTypeValue: - try: - return self._build_class_adapter(ctx).validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param=param, ctx=ctx) - - def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> str: - """Get the error message when the given choice is invalid.""" - choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) - return f"{value!r} is not one of {choices_str}." - - def __repr__(self) -> str: - return f"Choice({list(self.choices)})" - - def _choice_as_str(self, choice: ParamTypeValue) -> str: - if isinstance(choice, Enum): - return str(choice.value) - return str(choice) - - def shell_complete( - self, ctx: _click.Context, param: _click.Parameter, incomplete: str - ) -> list[CompletionItem]: - """Complete choices that start with the incomplete value.""" - - str_choices = map(self._choice_as_str, self.choices) - - if self.case_sensitive: - matched = (c for c in str_choices if c.startswith(incomplete)) - else: - incomplete = incomplete.lower() - matched = (c for c in str_choices if c.lower().startswith(incomplete)) - - return [CompletionItem(c) for c in matched] diff --git a/typer/param_types.py b/typer/param_types.py index 5f6e21550f..c170fe156c 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -1,11 +1,16 @@ +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime from enum import Enum from pathlib import Path -from typing import Any +from typing import Annotated, Any, Generic, TypeVar from uuid import UUID as UUIDType +from pydantic import BeforeValidator, TypeAdapter, ValidationError + +from . import _click from ._click import types -from ._types import TyperChoice +from ._click.shell_completion import CompletionItem +from ._click.types import _get_error_msg from ._typing import is_literal_type, literal_values from .models import ( AnyType, @@ -17,11 +22,126 @@ TyperPath, ) +ParamTypeValue = TypeVar("ParamTypeValue") + def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: return isinstance(cls, type) and issubclass(cls, class_or_tuple) +class TyperChoice(types.ParamType, Generic[ParamTypeValue]): + name = "choice" + + def __init__( + self, choices: Iterable[ParamTypeValue], case_sensitive: bool = True + ) -> None: + self.choices: Sequence[ParamTypeValue] = tuple(choices) + self.case_sensitive = case_sensitive + + def _normalized_mapping( + self, ctx: _click.Context | None = None + ) -> Mapping[ParamTypeValue, str]: + """ + Returns mapping where keys are the original choices and the values are + the normalized values that are accepted via the command line. + """ + return { + choice: self.normalize_choice( + choice=choice, + ctx=ctx, + ) + for choice in self.choices + } + + def normalize_choice( + self, choice: ParamTypeValue, ctx: _click.Context | None + ) -> str: + normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(normed_value) + + if not self.case_sensitive: + normed_value = normed_value.casefold() + + return normed_value + + def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + choice_metavars = [ + types.convert_type(type(choice)).name.upper() for choice in self.choices + ] + choices_str = "|".join([*dict.fromkeys(choice_metavars)]) + else: + choices_str = "|".join( + [str(i) for i in self._normalized_mapping(ctx=ctx).values()] + ) + + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]" + + def get_missing_message( + self, param: _click.Parameter, ctx: _click.Context | None + ) -> str: + """Message shown when no choice is passed.""" + choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) + return f"Choose from:\n\t{choices}" + + def _build_class_adapter( + self, ctx: _click.Context | None + ) -> TypeAdapter[ParamTypeValue]: + normalized_mapping = self._normalized_mapping(ctx=ctx) + + def parse_choice(value: Any) -> ParamTypeValue: + normed_value = self.normalize_choice(choice=value, ctx=ctx) + for original, normalized in normalized_mapping.items(): + if normalized == normed_value: + return original + raise ValueError(self.get_invalid_choice_message(value=value, ctx=ctx)) + + return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) + + def convert( + self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None + ) -> ParamTypeValue: + try: + return self._build_class_adapter(ctx).validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param=param, ctx=ctx) + + def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> str: + """Get the error message when the given choice is invalid.""" + choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) + return f"{value!r} is not one of {choices_str}." + + def __repr__(self) -> str: + return f"Choice({list(self.choices)})" + + def _choice_as_str(self, choice: ParamTypeValue) -> str: + if isinstance(choice, Enum): + return str(choice.value) + return str(choice) + + def shell_complete( + self, ctx: _click.Context, param: _click.Parameter, incomplete: str + ) -> list[CompletionItem]: + """Complete choices that start with the incomplete value.""" + + str_choices = map(self._choice_as_str, self.choices) + + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched] + + def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: return types.File( mode=parameter_info.mode or mode, From 7c201ec0ffd2aad15d2ca5898231c69c851b9739 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 11 Jun 2026 15:59:27 +0200 Subject: [PATCH 24/73] move TyperPath to param_types.py as well --- tests/test_type_conversion.py | 7 +- typer/models.py | 125 ---------------------------------- typer/param_types.py | 120 +++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 131 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 3cc431d11d..f40574acae 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -5,7 +5,8 @@ import pytest import typer -from typer import _click, models +from typer import _click, param_types +from typer.param_types import TyperPath from typer.testing import CliRunner from tests.utils import needs_linux, needs_windows @@ -290,7 +291,7 @@ def warp(loc: str = typer.Option(..., resolve_path=True)): print(loc) param = next(p for p in typer.main.get_command(app).params if p.name == "loc") - assert isinstance(param.type, models.TyperPath) + assert isinstance(param.type, TyperPath) @pytest.mark.parametrize( @@ -324,7 +325,7 @@ def fake_access(path: str, mode: int) -> bool: return False return original_access(path, mode) # pragma: no cover - monkeypatch.setattr(models.os, "access", fake_access) + monkeypatch.setattr(param_types.os, "access", fake_access) path = tmp_path / "some_path" if create_file: diff --git a/typer/models.py b/typer/models.py index b8f2e5b982..6d61443c7f 100644 --- a/typer/models.py +++ b/typer/models.py @@ -1,24 +1,15 @@ import inspect import io -import os -import stat from collections.abc import Callable, Sequence -from pathlib import Path from typing import ( TYPE_CHECKING, Any, - ClassVar, Optional, TypeVar, - cast, ) -from pydantic import ValidationError - from . import _click -from ._click import types from ._click.shell_completion import CompletionItem -from ._click.types import _get_error_msg, build_type_adapter if TYPE_CHECKING: # pragma: no cover from .core import TyperCommand, TyperGroup @@ -634,119 +625,3 @@ def __init__( self.pretty_exceptions_enable = pretty_exceptions_enable self.pretty_exceptions_show_locals = pretty_exceptions_show_locals self.pretty_exceptions_short = pretty_exceptions_short - - -class TyperPath(types.ParamType): - # Based originally on code from Click 8.3.1 - # Partly rewritten and added an override for shell_complete - - envvar_list_splitter: ClassVar[str] = os.path.pathsep - - def __init__( - self, - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: type[Any] | None = None, - ): - self.exists = exists - self.file_okay = file_okay - self.dir_okay = dir_okay - self.readable = readable - self.writable = writable - self.resolve_path = resolve_path - self.allow_dash = allow_dash - self.type = path_type - - if self.file_okay and not self.dir_okay: - self.name = "file" - elif self.dir_okay and not self.file_okay: - self.name = "directory" - else: - self.name = "path" - - def _parse_path_value( - self, - value: Any, - param: _click.Parameter | None, - ctx: Context | None, - ) -> Any: - if self.type is None or self.type is str or self.type is bytes: - return value - if isinstance(self.type, type) and issubclass(self.type, Path): - if isinstance(value, self.type): - return value - if isinstance(value, (str, os.PathLike)): - try: - return build_type_adapter(self.type).validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - return value - - def coerce_path_result( - self, value: str | os.PathLike[str] - ) -> str | bytes | os.PathLike[str]: - if self.type is not None and not isinstance(value, self.type): - if ( - self.type is str - ): # pragma: no cover # TODO: perhaps this branch can't be hit and should be removed - return os.fsdecode(value) - elif self.type is bytes: - return os.fsencode(value) - else: - return cast("os.PathLike[str]", self.type(value)) - - return value - - def convert( # ty: ignore[invalid-method-override] - self, - value: str | os.PathLike[str], - param: _click.Parameter | None, - ctx: Context | None, # type: ignore[override] - ) -> str | bytes | os.PathLike[str]: - rv = self._parse_path_value(value, param, ctx) - - is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") - - if not is_dash: - if self.resolve_path: - rv = os.path.realpath(rv) - - try: - st = os.stat(rv) - except OSError: - if not self.exists: - return self.coerce_path_result(rv) - self.fail( - f"{self.name.title()} {_click.utils.format_filename(value)!r} does not exist.", - param, - ctx, - ) - - name = self.name.title() - loc = repr(_click.utils.format_filename(value)) - if not self.file_okay and stat.S_ISREG(st.st_mode): - self.fail(f"{name} {loc} is a file.", param, ctx) - - if not self.dir_okay and stat.S_ISDIR(st.st_mode): - self.fail(f"{name} {loc} is a directory.", param, ctx) - - if self.readable and not os.access(rv, os.R_OK): - self.fail(f"{name} {loc} is not readable.", param, ctx) - - if self.writable and not os.access(rv, os.W_OK): - self.fail(f"{name} {loc} is not writable.", param, ctx) - - return self.coerce_path_result(rv) - - def shell_complete( - self, ctx: _click.Context, param: _click.Parameter, incomplete: str - ) -> list[CompletionItem]: - """Return an empty list so that the autocompletion functionality - will work properly from the commandline. - """ - return [] diff --git a/typer/param_types.py b/typer/param_types.py index c170fe156c..a4c984975e 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -1,8 +1,10 @@ +import os +import stat from collections.abc import Iterable, Mapping, Sequence from datetime import datetime from enum import Enum from pathlib import Path -from typing import Annotated, Any, Generic, TypeVar +from typing import Annotated, Any, ClassVar, Generic, TypeVar, cast from uuid import UUID as UUIDType from pydantic import BeforeValidator, TypeAdapter, ValidationError @@ -10,7 +12,7 @@ from . import _click from ._click import types from ._click.shell_completion import CompletionItem -from ._click.types import _get_error_msg +from ._click.types import _get_error_msg, build_type_adapter from ._typing import is_literal_type, literal_values from .models import ( AnyType, @@ -19,7 +21,6 @@ FileText, FileTextWrite, ParameterInfo, - TyperPath, ) ParamTypeValue = TypeVar("ParamTypeValue") @@ -142,6 +143,119 @@ def shell_complete( return [CompletionItem(c) for c in matched] +class TyperPath(types.ParamType): + envvar_list_splitter: ClassVar[str] = os.path.pathsep + + def __init__( + self, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: type[Any] | None = None, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.readable = readable + self.writable = writable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name = "file" + elif self.dir_okay and not self.file_okay: + self.name = "directory" + else: + self.name = "path" + + def _parse_path_value( + self, + value: Any, + param: _click.Parameter | None, + ctx: _click.Context | None, + ) -> Any: + if self.type is None or self.type is str or self.type is bytes: + return value + if isinstance(self.type, type) and issubclass(self.type, Path): + if isinstance(value, self.type): + return value + if isinstance(value, (str, os.PathLike)): + try: + return build_type_adapter(self.type).validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + return value + + def coerce_path_result( + self, value: str | os.PathLike[str] + ) -> str | bytes | os.PathLike[str]: + if self.type is not None and not isinstance(value, self.type): + if ( + self.type is str + ): # pragma: no cover # TODO: perhaps this branch can't be hit and should be removed + return os.fsdecode(value) + elif self.type is bytes: + return os.fsencode(value) + else: + return cast("os.PathLike[str]", self.type(value)) + + return value + + def convert( # ty: ignore[invalid-method-override] + self, + value: str | os.PathLike[str], + param: _click.Parameter | None, + ctx: _click.Context | None, + ) -> str | bytes | os.PathLike[str]: + rv = self._parse_path_value(value, param, ctx) + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + rv = os.path.realpath(rv) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + f"{self.name.title()} {_click.utils.format_filename(value)!r} does not exist.", + param, + ctx, + ) + + name = self.name.title() + loc = repr(_click.utils.format_filename(value)) + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail(f"{name} {loc} is a file.", param, ctx) + + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail(f"{name} {loc} is a directory.", param, ctx) + + if self.readable and not os.access(rv, os.R_OK): + self.fail(f"{name} {loc} is not readable.", param, ctx) + + if self.writable and not os.access(rv, os.W_OK): + self.fail(f"{name} {loc} is not writable.", param, ctx) + + return self.coerce_path_result(rv) + + def shell_complete( + self, ctx: _click.Context, param: _click.Parameter, incomplete: str + ) -> list[CompletionItem]: + """Return an empty list so that the autocompletion functionality + will work properly from the commandline. + """ + return [] + + def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: return types.File( mode=parameter_info.mode or mode, From 621280be9feb74040fb66bba1d658d82f72497b3 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 11 Jun 2026 16:01:35 +0200 Subject: [PATCH 25/73] remove unused ignore statement --- typer/param_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/param_types.py b/typer/param_types.py index a4c984975e..3ee9f0feaf 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -206,7 +206,7 @@ def coerce_path_result( return value - def convert( # ty: ignore[invalid-method-override] + def convert( self, value: str | os.PathLike[str], param: _click.Parameter | None, From b6165b7f9a0a2251957f62c00b0c80ff78010be2 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 11 Jun 2026 16:30:20 +0200 Subject: [PATCH 26/73] centralize Pydantic functionality to adapaters.py --- tests/test_type_conversion.py | 12 +-- typer/_click/types.py | 193 +--------------------------------- typer/adapters.py | 93 ++++++++++++++++ typer/core.py | 10 +- typer/param_types.py | 107 +++++++++++++++++-- 5 files changed, 206 insertions(+), 209 deletions(-) create mode 100644 typer/adapters.py diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index f40574acae..8b46cc631e 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -351,23 +351,23 @@ def test_convert_type(): assert isinstance(tuple_type, _click.types.Tuple) assert [type(item) for item in tuple_type.types] == [ type(_click.types.STRING), - type(_click.types.INT), + type(param_types.INT), ] guessed_tuple = convert_type(None, default=[(1, "x")]) assert isinstance(guessed_tuple, _click.types.Tuple) assert [type(item) for item in guessed_tuple.types] == [ - type(_click.types.INT), + type(param_types.INT), type(_click.types.STRING), ] # numbers - assert convert_type(int) is _click.types.INT - assert convert_type(float) is _click.types.FLOAT - assert convert_type(bool) is _click.types.BOOL + assert convert_type(int) is param_types.INT + assert convert_type(float) is param_types.FLOAT + assert convert_type(bool) is param_types.BOOL guessed_int = convert_type(None, default=42) - assert guessed_int is _click.types.INT + assert guessed_int is param_types.INT # custom type class CustomType: diff --git a/typer/_click/types.py b/typer/_click/types.py index 8256572e9c..fc76227168 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -1,29 +1,17 @@ import os import sys from collections.abc import Callable, Sequence -from datetime import datetime from typing import ( IO, TYPE_CHECKING, - Annotated, Any, ClassVar, NoReturn, TypedDict, TypeGuard, - TypeVar, Union, cast, ) -from uuid import UUID as UUIDType - -from pydantic import ( - AfterValidator, - BeforeValidator, - Field, - TypeAdapter, - ValidationError, -) from ._compat import _get_argv_encoding, open_stream from .exceptions import BadParameter @@ -33,104 +21,6 @@ from .core import Context, Parameter from .shell_completion import CompletionItem -ParamTypeValue = TypeVar("ParamTypeValue") - - -def _get_error_msg(exc: ValidationError) -> str: - """Get a string representation of the (first) validation error.""" - errors = exc.errors() - if errors: - return errors[0]["msg"] - return str(exc) - - -def _build_datetime_adapter( - formats: Sequence[str] | None, -) -> TypeAdapter[datetime]: - if formats is None: - return TypeAdapter(datetime) - - def parse_datetime(value: Any) -> datetime: - if isinstance(value, datetime): - return value - for format in formats: - try: - return datetime.strptime(value, format) - except ValueError: - continue - formats_str = ", ".join(map(repr, formats)) - raise ValueError(f"{value!r} does not match the formats {formats_str}.") - - return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) - - -_bool_adapter = TypeAdapter(bool) - - -def _parse_cli_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - stripped = value.strip() - if stripped == "": - return False - value = stripped - return _bool_adapter.validate_python(value) - - -def _make_number_clamp_validator( - number_class: type[Any], - min: float | None, - max: float | None, -) -> Callable[[Any], Any]: - def clamp_number(value: Any) -> Any: - if min is not None and value < min: - return number_class(min) - if max is not None and value > max: - return number_class(max) - return value - - return clamp_number - - -def build_type_adapter( - annotation: Any, - *, - min: float | None = None, - max: float | None = None, - clamp: bool = False, - formats: Sequence[str] | None = None, -) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a CLI annotation and constraints. - - Known constraints (ranges, custom datetime formats, etc.) are applied first, - everything else is delegated to Pydantic. - """ - if annotation is datetime and formats is not None: - return _build_datetime_adapter(formats) - - if annotation is int or annotation is float: - if clamp: - # Use AfterValidator so it runs after coercion - return TypeAdapter( - Annotated[ - annotation, - AfterValidator(_make_number_clamp_validator(annotation, min, max)), - ] - ) - field_kwargs: dict[str, Any] = {} - if min is not None: - field_kwargs["ge"] = min - if max is not None: - field_kwargs["le"] = max - if field_kwargs: - return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) - - if annotation is bool: - return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) - - return TypeAdapter(annotation) - class ParamType: """Represents the type of a parameter. Validates and converts values @@ -218,49 +108,6 @@ def shell_complete( return [] -class PydanticParamType(ParamType): - _class_adapter: TypeAdapter[Any] - - def __init__( - self, - adapter: TypeAdapter[Any], - *, - name: str, - repr_name: str | None = None, - metavar: str | Callable[["Parameter", "Context"], str | None] | None = None, - preprocess: Callable[[Any], Any] | None = None, - ) -> None: - self._class_adapter = adapter - self.name = name - self._repr_name = repr_name or name - self._metavar = metavar - self._preprocess = preprocess - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - if self._preprocess is not None: - value = self._preprocess(value) - try: - return self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) - - def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: - if self._metavar is None: - return None - if isinstance(self._metavar, str): - return self._metavar - return self._metavar(param, ctx) - - def __repr__(self) -> str: - return self._repr_name - - -def _strip_string(value: Any) -> Any: - return value.strip() if isinstance(value, str) else value - - class CompositeParamType(ParamType): is_composite = True @@ -315,18 +162,6 @@ def __repr__(self) -> str: return "STRING" -def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamType: - formats_tuple = tuple(formats) if formats is not None else None - metavar_formats = formats_tuple or ["%Y-%m-%d"] - - return PydanticParamType( - build_type_adapter(datetime, formats=formats_tuple), - name="datetime", - repr_name="DateTime", - metavar=f"[{'|'.join(metavar_formats)}]", - ) - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command @@ -482,6 +317,8 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: type. If the type isn't provided, it can be inferred from a default value. """ + from .. import param_types + guessed_type = False if ty is None and default is not None: @@ -513,13 +350,13 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: return STRING if ty is int: - return INT + return param_types.INT if ty is float: - return FLOAT + return param_types.FLOAT if ty is bool: - return BOOL + return param_types.BOOL if guessed_type: return STRING @@ -531,26 +368,6 @@ def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: # can also be selected by using ``str`` as type. STRING = StringParamType() -# An integer parameter. This can also be selected by using ``int`` as -# type. -INT = PydanticParamType(build_type_adapter(int), name="integer", repr_name="INT") - -# A floating point value parameter. This can also be selected by using -# ``float`` as type. -FLOAT = PydanticParamType(build_type_adapter(float), name="float", repr_name="FLOAT") - -# A boolean parameter. This is the default for boolean flags. This can -# also be selected by using ``bool`` as a type. -BOOL = PydanticParamType(build_type_adapter(bool), name="boolean", repr_name="BOOL") - -# A UUID parameter. -UUID = PydanticParamType( - build_type_adapter(UUIDType), - name="uuid", - repr_name="UUID", - preprocess=_strip_string, -) - class OptionHelpExtra(TypedDict, total=False): envvars: tuple[str, ...] diff --git a/typer/adapters.py b/typer/adapters.py new file mode 100644 index 0000000000..b52019844c --- /dev/null +++ b/typer/adapters.py @@ -0,0 +1,93 @@ +from collections.abc import Callable, Sequence +from datetime import datetime +from typing import Annotated, Any + +from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter + + +def _build_datetime_adapter( + formats: Sequence[str] | None, +) -> TypeAdapter[datetime]: + if formats is None: + return TypeAdapter(datetime) + + def parse_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + for format in formats: + try: + return datetime.strptime(value, format) + except ValueError: + continue + formats_str = ", ".join(map(repr, formats)) + raise ValueError(f"{value!r} does not match the formats {formats_str}.") + + return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) + + +_bool_adapter = TypeAdapter(bool) + + +def _parse_cli_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + stripped = value.strip() + if stripped == "": + return False + value = stripped + return _bool_adapter.validate_python(value) + + +def _make_number_clamp_validator( + number_class: type[Any], + min: float | None, + max: float | None, +) -> Callable[[Any], Any]: + def clamp_number(value: Any) -> Any: + if min is not None and value < min: + return number_class(min) + if max is not None and value > max: + return number_class(max) + return value + + return clamp_number + + +def build_type_adapter( + annotation: Any, + *, + min: float | None = None, + max: float | None = None, + clamp: bool = False, + formats: Sequence[str] | None = None, +) -> TypeAdapter[Any]: + """Build a Pydantic TypeAdapter for a CLI annotation and constraints. + + Known constraints (ranges, custom datetime formats, etc.) are applied first, + everything else is delegated to Pydantic. + """ + if annotation is datetime and formats is not None: + return _build_datetime_adapter(formats) + + if annotation is int or annotation is float: + if clamp: + # Use AfterValidator so it runs after coercion + return TypeAdapter( + Annotated[ + annotation, + AfterValidator(_make_number_clamp_validator(annotation, min, max)), + ] + ) + field_kwargs: dict[str, Any] = {} + if min is not None: + field_kwargs["ge"] = min + if max is not None: + field_kwargs["le"] = max + if field_kwargs: + return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) + + if annotation is bool: + return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) + + return TypeAdapter(annotation) diff --git a/typer/core.py b/typer/core.py index f677b23810..f41e71ddc2 100644 --- a/typer/core.py +++ b/typer/core.py @@ -13,7 +13,7 @@ cast, ) -from . import _click +from . import _click, adapters, param_types from ._click import types from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem @@ -533,10 +533,10 @@ def __init__( # TODO: revisit all of this flag stuff if is_flag and type is None: - self.type: types.ParamType = types.BOOL + self.type: types.ParamType = param_types.BOOL self.is_flag: bool = bool(is_flag) - self.is_bool_flag: bool = bool(is_flag and self.type is types.BOOL) + self.is_bool_flag: bool = bool(is_flag and self.type is param_types.BOOL) if self.is_flag: self._depr_flag_value = True @@ -546,8 +546,8 @@ def __init__( # Counting self.count = count if count and type is None: - self.type = types.PydanticParamType( - types.build_type_adapter(int, min=0), + self.type = param_types.PydanticParamType( + adapters.build_type_adapter(int, min=0), name="integer range", ) if self.min is None: diff --git a/typer/param_types.py b/typer/param_types.py index 3ee9f0feaf..194ca658ff 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -1,6 +1,6 @@ import os import stat -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime from enum import Enum from pathlib import Path @@ -9,10 +9,9 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError -from . import _click +from . import _click, adapters from ._click import types from ._click.shell_completion import CompletionItem -from ._click.types import _get_error_msg, build_type_adapter from ._typing import is_literal_type, literal_values from .models import ( AnyType, @@ -30,6 +29,94 @@ def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) return isinstance(cls, type) and issubclass(cls, class_or_tuple) +def _get_error_msg(exc: ValidationError) -> str: + """Get a string representation of the (first) validation error.""" + errors = exc.errors() + if errors: + return errors[0]["msg"] + return str(exc) + + +def _strip_string(value: Any) -> Any: + return value.strip() if isinstance(value, str) else value + + +class PydanticParamType(types.ParamType): + _class_adapter: TypeAdapter[Any] + + def __init__( + self, + adapter: TypeAdapter[Any], + *, + name: str, + repr_name: str | None = None, + metavar: str + | Callable[[_click.Parameter, _click.Context], str | None] + | None = None, + preprocess: Callable[[Any], Any] | None = None, + ) -> None: + self._class_adapter = adapter + self.name = name + self._repr_name = repr_name or name + self._metavar = metavar + self._preprocess = preprocess + + def convert( + self, + value: Any, + param: _click.Parameter | None, + ctx: _click.Context | None, + ) -> Any: + if self._preprocess is not None: + value = self._preprocess(value) + try: + return self._class_adapter.validate_python(value) + except ValidationError as exc: + self.fail(_get_error_msg(exc), param, ctx) + + def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: + if self._metavar is None: + return None + if isinstance(self._metavar, str): + return self._metavar + return self._metavar(param, ctx) + + def __repr__(self) -> str: + return self._repr_name + + +def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamType: + formats_tuple = tuple(formats) if formats is not None else None + metavar_formats = formats_tuple or ["%Y-%m-%d"] + + return PydanticParamType( + adapters.build_type_adapter(datetime, formats=formats_tuple), + name="datetime", + repr_name="DateTime", + metavar=f"[{'|'.join(metavar_formats)}]", + ) + + +INT = PydanticParamType( + adapters.build_type_adapter(int), name="integer", repr_name="INT" +) + +FLOAT = PydanticParamType( + adapters.build_type_adapter(float), name="float", repr_name="FLOAT" +) + +BOOL = PydanticParamType( + adapters.build_type_adapter(bool), name="boolean", repr_name="BOOL" +) + +UUID = PydanticParamType( + adapters.build_type_adapter(UUIDType), + name="uuid", + repr_name="UUID", + preprocess=_strip_string, +) + + class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -186,7 +273,7 @@ def _parse_path_value( return value if isinstance(value, (str, os.PathLike)): try: - return build_type_adapter(self.type).validate_python(value) + return adapters.build_type_adapter(self.type).validate_python(value) except ValidationError as exc: self.fail(_get_error_msg(exc), param, ctx) return value @@ -273,8 +360,8 @@ def _ranged_number_param_type( max: int | float | None, clamp: bool, ) -> types.ParamType: - return types.PydanticParamType( - types.build_type_adapter(number_class, min=min, max=max, clamp=clamp), + return PydanticParamType( + adapters.build_type_adapter(number_class, min=min, max=max, clamp=clamp), name="integer range" if number_class is int else "float range", ) @@ -318,13 +405,13 @@ def param_type_from_annotation( max=max_, clamp=parameter_info.clamp, ) - return types.INT if annotation is int else types.FLOAT + return INT if annotation is int else FLOAT if annotation is UUIDType: - return types.UUID + return UUID if annotation is datetime: - return types.datetime_param_type(formats=parameter_info.formats) + return datetime_param_type(formats=parameter_info.formats) if annotation is bool: - return types.BOOL + return BOOL if _needs_typer_path(annotation, parameter_info): resolved_path_type: type[Any] | None = parameter_info.path_type if resolved_path_type is None and lenient_issubclass(annotation, Path): From 4332d34ff77e678002190efb9e2fe8f2dfabcb7e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 12 Jun 2026 12:18:08 +0200 Subject: [PATCH 27/73] resolve_param_type instead of convert_type --- tests/test_type_conversion.py | 79 +++++++++++++++++--------- typer/_click/core.py | 4 +- typer/_click/termui.py | 6 +- typer/_click/types.py | 101 +++------------------------------- typer/adapters.py | 28 ++++++++++ typer/main.py | 91 +++++++++++++++++------------- typer/param_types.py | 79 +++++++++++++++++++++++--- 7 files changed, 218 insertions(+), 170 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 8b46cc631e..59e1bb29e0 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -1,4 +1,5 @@ import os +import sys from enum import Enum from pathlib import Path from typing import Any @@ -6,7 +7,7 @@ import pytest import typer from typer import _click, param_types -from typer.param_types import TyperPath +from typer.param_types import BOOL, FLOAT, INT, STRING, TyperPath, resolve_param_type from typer.testing import CliRunner from tests.utils import needs_linux, needs_windows @@ -261,8 +262,8 @@ def show(name: str = typer.Option(...)): name_param = next(param for param in command.params if param.name == "name") assert repr(name_param.type) == "STRING" - monkeypatch.setattr(_click.types, "_get_argv_encoding", lambda: arg_enc) - monkeypatch.setattr(_click.types.sys, "getfilesystemencoding", lambda: system_enc) + monkeypatch.setattr(_click._compat, "_get_argv_encoding", lambda: arg_enc) + monkeypatch.setattr(sys, "getfilesystemencoding", lambda: system_enc) result = runner.invoke(app, [], default_map={"name": raw_value}) assert result.exit_code == 0 @@ -338,45 +339,73 @@ def fake_access(path: str, mode: int) -> bool: assert expected_error in result.output -def test_convert_type(): - from typer._click.types import convert_type +@pytest.mark.parametrize( + ("default", "expected_param_type", "cli_args", "expected_value", "value_type"), + [ + (42, INT, [], 42, int), + (42, INT, ["--val", "99"], 99, int), + (0.5, FLOAT, [], 0.5, float), + ("morty", STRING, [], "morty", str), + ], + ids=["int", "int-cli", "float", "str"], +) +def test_default_infers_param_type( + default: Any, + expected_param_type: Any, + cli_args: list[str], + expected_value: Any, + value_type: type, +) -> None: + app = typer.Typer() + seen: dict[str, Any] = {} + @app.command() + def cmd(val=default): + seen["val"] = val + + param = next(p for p in typer.main.get_command(app).params if p.name == "val") + assert param.type is expected_param_type + + result = runner.invoke(app, cli_args) + assert result.exit_code == 0, result.output + assert seen["val"] == expected_value + assert type(seen["val"]) is value_type + + +def test_convert_type(): # str - assert convert_type(str) is _click.types.STRING - assert convert_type(None) is _click.types.STRING - assert convert_type(None, default=["a"]) is _click.types.STRING + assert resolve_param_type(str) is STRING + assert resolve_param_type(None) is STRING + assert resolve_param_type(None, default=["a"]) is STRING # tuples - tuple_type = convert_type((str, int)) + tuple_type = resolve_param_type((str, int)) assert isinstance(tuple_type, _click.types.Tuple) - assert [type(item) for item in tuple_type.types] == [ - type(_click.types.STRING), - type(param_types.INT), - ] + assert [type(item) for item in tuple_type.types] == [type(STRING), type(INT)] - guessed_tuple = convert_type(None, default=[(1, "x")]) + guessed_tuple = resolve_param_type(None, default=[(1, "x")]) assert isinstance(guessed_tuple, _click.types.Tuple) assert [type(item) for item in guessed_tuple.types] == [ - type(param_types.INT), - type(_click.types.STRING), + type(INT), + type(STRING), ] # numbers - assert convert_type(int) is param_types.INT - assert convert_type(float) is param_types.FLOAT - assert convert_type(bool) is param_types.BOOL + assert resolve_param_type(int) is INT + assert resolve_param_type(float) is FLOAT + assert resolve_param_type(bool) is BOOL - guessed_int = convert_type(None, default=42) - assert guessed_int is param_types.INT + guessed_int = resolve_param_type(None, default=42) + assert guessed_int is INT # custom type class CustomType: pass - guessed_unknown = convert_type(None, default=CustomType()) - assert guessed_unknown is _click.types.STRING + guessed_unknown = resolve_param_type(None, default=CustomType()) + assert guessed_unknown is STRING - func_type = convert_type(CustomType) + func_type = resolve_param_type(CustomType) assert isinstance(func_type, _click.types.FuncParamType) assert func_type.name == "CustomType" @@ -425,5 +454,5 @@ def __init__(self, encoding: str | None) -> None: monkeypatch.setattr(sys, "stdin", FakeStdin(stdin_encoding)) monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem_encoding) - converted = _click.types.STRING.convert(b"\xff", None, None) + converted = STRING.convert(b"\xff", None, None) assert converted == "ÿ" diff --git a/typer/_click/core.py b/typer/_click/core.py index 580b558b9f..6072ec3aae 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -840,7 +840,9 @@ def __init__( self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) - self.type: types.ParamType = types.convert_type(type, default) + from ..param_types import resolve_param_type + + self.type = resolve_param_type(type, default) # Default nargs to what the type tells us if we have that # information available. diff --git a/typer/_click/termui.py b/typer/_click/termui.py index e6d5f131db..e1605ae5a4 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -5,7 +5,7 @@ from .exceptions import Abort, UsageError from .globals import resolve_color_default -from .types import ParamType, convert_type +from .types import ParamType from .utils import LazyFile, echo if TYPE_CHECKING: @@ -108,7 +108,9 @@ def prompt_func(text: str) -> str: raise Abort() from None if value_proc is None: - value_proc = convert_type(type, default) + from ..param_types import resolve_param_type + + value_proc = resolve_param_type(type, default) prompt = _build_prompt( text, prompt_suffix, show_default, default, show_choices, type diff --git a/typer/_click/types.py b/typer/_click/types.py index fc76227168..c04be2a517 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -1,5 +1,4 @@ import os -import sys from collections.abc import Callable, Sequence from typing import ( IO, @@ -7,13 +6,12 @@ Any, ClassVar, NoReturn, - TypedDict, TypeGuard, Union, cast, ) -from ._compat import _get_argv_encoding, open_stream +from ._compat import open_stream from .exceptions import BadParameter from .utils import LazyFile, format_filename, safecall @@ -136,32 +134,6 @@ def convert( self.fail(value, param, ctx) -class StringParamType(ParamType): - name = "text" - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - if isinstance(value, bytes): - enc = _get_argv_encoding() - try: - value = value.decode(enc) - except UnicodeError: - fs_enc = sys.getfilesystemencoding() - if fs_enc != enc: - try: - value = value.decode(fs_enc) - except UnicodeError: - value = value.decode("utf-8", "replace") - else: - value = value.decode("utf-8", "replace") - return value - return str(value) - - def __repr__(self) -> str: - return "STRING" - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command @@ -284,7 +256,12 @@ class Tuple(CompositeParamType): """ def __init__(self, types: Sequence[type[Any] | ParamType]) -> None: - self.types: Sequence[ParamType] = [convert_type(ty) for ty in types] + from ..param_types import resolve_param_type + + self.types: Sequence[ParamType] = [ + item if isinstance(item, ParamType) else resolve_param_type(item) + for item in types + ] @property def name(self) -> str: # type: ignore[override] @@ -310,67 +287,3 @@ def convert( return tuple( ty(x, param, ctx) for ty, x in zip(self.types, value, strict=False) ) - - -def convert_type(ty: Any | None, default: Any | None = None) -> ParamType: - """Find the most appropriate `ParamType` for the given Python - type. If the type isn't provided, it can be inferred from a default - value. - """ - from .. import param_types - - guessed_type = False - - if ty is None and default is not None: - if isinstance(default, (tuple, list)): - # If the default is empty, ty will remain None and will - # return STRING. - if default: - item = default[0] - - # A tuple of tuples needs to detect the inner types. - # Can't call convert recursively because that would - # incorrectly unwind the tuple to a single type. - if isinstance(item, (tuple, list)): - ty = tuple(map(type, item)) - else: - ty = type(item) - else: - ty = type(default) - - guessed_type = True - - if isinstance(ty, tuple): - return Tuple(ty) - - if isinstance(ty, ParamType): - return ty - - if ty is str or ty is None: - return STRING - - if ty is int: - return param_types.INT - - if ty is float: - return param_types.FLOAT - - if ty is bool: - return param_types.BOOL - - if guessed_type: - return STRING - - return FuncParamType(ty) - - -# A unicode string parameter type which is the implicit default. This -# can also be selected by using ``str`` as type. -STRING = StringParamType() - - -class OptionHelpExtra(TypedDict, total=False): - envvars: tuple[str, ...] - default: str - range: str - required: str diff --git a/typer/adapters.py b/typer/adapters.py index b52019844c..6dec2c37e1 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -1,3 +1,4 @@ +import sys from collections.abc import Callable, Sequence from datetime import datetime from typing import Annotated, Any @@ -28,6 +29,30 @@ def parse_datetime(value: Any) -> datetime: _bool_adapter = TypeAdapter(bool) +def decode_cli_bytes(value: Any) -> Any: + """Decode bytes from argv/env; leave other values unchanged.""" + if isinstance(value, bytes): + from ._click import _compat + + enc = _compat._get_argv_encoding() + try: + return value.decode(enc) + except UnicodeError: + fs_enc = sys.getfilesystemencoding() + if fs_enc != enc: + try: + return value.decode(fs_enc) + except UnicodeError: + return value.decode("utf-8", "replace") + return value.decode("utf-8", "replace") + return value + + +def _parse_cli_str(value: Any) -> str: + """Coerce a CLI value to str like legacy StringParamType.convert.""" + return str(decode_cli_bytes(value)) + + def _parse_cli_bool(value: Any) -> bool: if isinstance(value, bool): return value @@ -90,4 +115,7 @@ def build_type_adapter( if annotation is bool: return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) + if annotation is str: + return TypeAdapter(Annotated[str, BeforeValidator(_parse_cli_str)]) + return TypeAdapter(annotation) diff --git a/typer/main.py b/typer/main.py index d78d0b4f82..a0bd2e03f8 100644 --- a/typer/main.py +++ b/typer/main.py @@ -40,7 +40,7 @@ Required, TyperInfo, ) -from .param_types import get_param_type, lenient_issubclass +from .param_types import infer_type_from_default, lenient_issubclass, resolve_param_type from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1471,48 +1471,61 @@ def get_param( else: default_value = param.default parameter_info = OptionInfo() - annotation: Any - if param.annotation is not param.empty: - annotation = param.annotation - else: - annotation = str - main_type = annotation + main_type: Any is_list = False - parameter_type: Any = None is_flag = None - origin = get_origin(main_type) - - if origin is not None: - # Handle SomeType | None and Optional[SomeType] - if is_union(origin): - types = [] - for type_ in get_args(main_type): - if type_ is NoneType: - continue - types.append(type_) - assert len(types) == 1, "Typer Currently doesn't support Union types" - main_type = types[0] - origin = get_origin(main_type) - # 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" - ) - is_list = True - elif lenient_issubclass(origin, tuple): - types = [] - for type_ in get_args(main_type): - assert not get_origin(type_), ( - "Tuple types with complex sub-types are not currently supported" + + if param.annotation is not param.empty: + main_type = param.annotation + origin = get_origin(main_type) + + if origin is not None: + # Handle SomeType | None and Optional[SomeType] + if is_union(origin): + types = [] + for type_ in get_args(main_type): + if type_ is NoneType: + continue + types.append(type_) + assert len(types) == 1, "Typer Currently doesn't support Union types" + main_type = types[0] + origin = get_origin(main_type) + # 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" + ) + is_list = True + parameter_type = resolve_param_type( + main_type, parameter_info=parameter_info ) - types.append( - get_param_type(annotation=type_, parameter_info=parameter_info) + elif lenient_issubclass(origin, tuple): + type_args = get_args(main_type) + for type_ in type_args: + assert not get_origin(type_), ( + "Tuple types with complex sub-types are not currently supported" + ) + parameter_type = resolve_param_type( + tuple(type_args), parameter_info=parameter_info ) - parameter_type = tuple(types) - if parameter_type is None: - parameter_type = get_param_type( - annotation=main_type, parameter_info=parameter_info + else: + parameter_type = resolve_param_type( + main_type, parameter_info=parameter_info + ) + else: + parameter_type = resolve_param_type( + main_type, parameter_info=parameter_info + ) + else: + if default_value is not None: + main_type, _ = infer_type_from_default(default_value) + if main_type is None: + main_type = str + else: + main_type = str + parameter_type = resolve_param_type( + default=default_value, parameter_info=parameter_info ) if isinstance(parameter_info, OptionInfo): if main_type is bool: diff --git a/typer/param_types.py b/typer/param_types.py index 194ca658ff..a5a986aee8 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -116,6 +116,12 @@ def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamTy preprocess=_strip_string, ) +STRING = PydanticParamType( + adapters.build_type_adapter(str), + name="text", + repr_name="STRING", +) + class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -157,7 +163,7 @@ def normalize_choice( def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: if param.param_type_name == "option" and not param.show_choices: # type: ignore choice_metavars = [ - types.convert_type(type(choice)).name.upper() for choice in self.choices + resolve_param_type(type(choice)).name.upper() for choice in self.choices ] choices_str = "|".join([*dict.fromkeys(choice_metavars)]) else: @@ -375,17 +381,72 @@ def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: ) -def get_param_type( - *, annotation: Any, parameter_info: ParameterInfo +def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: + """Infer a type from a default value. Returns (annotation, guessed).""" + if isinstance(default, (tuple, list)): + if not default: + return None, True + item = default[0] + if isinstance(item, (tuple, list)): + return tuple(map(type, item)), True + return type(item), True + return type(default), True + + +def resolve_param_type( + annotation: Any | None = None, + default: Any | None = None, + *, + parameter_info: ParameterInfo | None = None, ) -> types.ParamType: - if parameter_info.parser is not None: + """Resolve a ParamType from a type and/or default value.""" + guessed_type = False + if annotation is None and default is not None: + annotation, guessed_type = infer_type_from_default(default) + + if isinstance(annotation, tuple): + element_types: list[types.ParamType] = [] + for element in annotation: + if isinstance(element, types.ParamType): + element_types.append(element) + else: + element_types.append( + resolve_param_type( + annotation=element, parameter_info=parameter_info + ) + ) + return types.Tuple(element_types) + + if isinstance(annotation, types.ParamType): + return annotation + + if parameter_info is not None and parameter_info.parser is not None: return types.FuncParamType(parameter_info.parser) - param_type = param_type_from_annotation(annotation, parameter_info) - if param_type is not None: - return param_type + if parameter_info is not None and annotation is not None: + param_type = param_type_from_annotation(annotation, parameter_info) + if param_type is not None: + return param_type + + if annotation is str or annotation is None: + return STRING + if annotation is int: + return INT + if annotation is float: + return FLOAT + if annotation is bool: + return BOOL + + if guessed_type: + return STRING + + return types.FuncParamType(annotation) - raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover + +def get_param_type( + *, annotation: Any, parameter_info: ParameterInfo +) -> types.ParamType: + return resolve_param_type(annotation=annotation, parameter_info=parameter_info) def param_type_from_annotation( @@ -437,7 +498,7 @@ def param_type_from_annotation( case_sensitive=parameter_info.case_sensitive, ) if annotation is str: - return types.STRING + return STRING if lenient_issubclass(annotation, FileTextWrite): return _file_param_type(parameter_info, mode="w") if lenient_issubclass(annotation, FileText): From 43f71ad0d18d2b1fb179a724d737fe17346a9432 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 12 Jun 2026 12:20:11 +0200 Subject: [PATCH 28/73] fix type --- typer/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/typer/main.py b/typer/main.py index a0bd2e03f8..450ff4b939 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1472,6 +1472,7 @@ def get_param( default_value = param.default parameter_info = OptionInfo() main_type: Any + parameter_type: _click.types.ParamType | None is_list = False is_flag = None From d6bc20da28c0e0b765ad3e13ceb9a1934984a3ee Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 12 Jun 2026 12:28:44 +0200 Subject: [PATCH 29/73] fix import --- typer/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typer/main.py b/typer/main.py index 450ff4b939..9dcfb77d72 100644 --- a/typer/main.py +++ b/typer/main.py @@ -15,6 +15,7 @@ from . import _click from ._click.globals import get_current_context +from ._click.types import ParamType from ._typing import get_args, get_origin, is_union from .completion import get_completion_inspect_parameters from .core import ( @@ -1472,7 +1473,7 @@ def get_param( default_value = param.default parameter_info = OptionInfo() main_type: Any - parameter_type: _click.types.ParamType | None + parameter_type: ParamType | None is_list = False is_flag = None From fdf297368b8fb59b3f8452f4cc02c5684912c457 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 12 Jun 2026 12:49:52 +0200 Subject: [PATCH 30/73] few more test cases for default type inference --- tests/test_type_conversion.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 59e1bb29e0..9fde8d29ce 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -340,19 +340,18 @@ def fake_access(path: str, mode: int) -> bool: @pytest.mark.parametrize( - ("default", "expected_param_type", "cli_args", "expected_value", "value_type"), + ("default", "expected_param_type", "expected_value", "value_type"), [ - (42, INT, [], 42, int), - (42, INT, ["--val", "99"], 99, int), - (0.5, FLOAT, [], 0.5, float), - ("morty", STRING, [], "morty", str), + (42, INT, 42, int), + (0.5, FLOAT, 0.5, float), + ("morty", STRING, "morty", str), + (False, BOOL, False, bool), + ("False", STRING, "False", str), ], - ids=["int", "int-cli", "float", "str"], ) def test_default_infers_param_type( default: Any, expected_param_type: Any, - cli_args: list[str], expected_value: Any, value_type: type, ) -> None: @@ -366,7 +365,7 @@ def cmd(val=default): param = next(p for p in typer.main.get_command(app).params if p.name == "val") assert param.type is expected_param_type - result = runner.invoke(app, cli_args) + result = runner.invoke(app) assert result.exit_code == 0, result.output assert seen["val"] == expected_value assert type(seen["val"]) is value_type From 0336edf6e4d8bbf21503bd7f71841a014c0d16d3 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 12 Jun 2026 14:19:12 +0200 Subject: [PATCH 31/73] move FuncParamType --- tests/test_type_conversion.py | 12 +++++-- typer/_click/types.py | 22 +----------- typer/adapters.py | 2 +- typer/param_types.py | 66 ++++++++++++++++++++--------------- 4 files changed, 50 insertions(+), 52 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 9fde8d29ce..5db5b3f6c0 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -7,7 +7,15 @@ import pytest import typer from typer import _click, param_types -from typer.param_types import BOOL, FLOAT, INT, STRING, TyperPath, resolve_param_type +from typer.param_types import ( + BOOL, + FLOAT, + INT, + STRING, + FuncParamType, + TyperPath, + resolve_param_type, +) from typer.testing import CliRunner from tests.utils import needs_linux, needs_windows @@ -405,7 +413,7 @@ class CustomType: assert guessed_unknown is STRING func_type = resolve_param_type(CustomType) - assert isinstance(func_type, _click.types.FuncParamType) + assert isinstance(func_type, FuncParamType) assert func_type.name == "CustomType" diff --git a/typer/_click/types.py b/typer/_click/types.py index c04be2a517..b83121778c 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -1,5 +1,5 @@ import os -from collections.abc import Callable, Sequence +from collections.abc import Sequence from typing import ( IO, TYPE_CHECKING, @@ -114,26 +114,6 @@ def arity(self) -> int: # type: ignore raise NotImplementedError() # pragma: no cover -class FuncParamType(ParamType): - def __init__(self, func: Callable[[Any], Any]) -> None: - self.name: str = getattr(func, "__name__", "function") - self.func = func - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - try: - return self.func(value) - except ValueError: - try: - value = str(value) - except UnicodeError: # pragma: no cover - assert isinstance(value, bytes) - value = value.decode("utf-8", "replace") - - self.fail(value, param, ctx) - - class File(ParamType): """Declares a parameter to be a file for reading or writing. The file is automatically closed once the context tears down (after the command diff --git a/typer/adapters.py b/typer/adapters.py index 6dec2c37e1..41b46ee7e9 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -49,7 +49,7 @@ def decode_cli_bytes(value: Any) -> Any: def _parse_cli_str(value: Any) -> str: - """Coerce a CLI value to str like legacy StringParamType.convert.""" + """Coerce a CLI value to str""" return str(decode_cli_bytes(value)) diff --git a/typer/param_types.py b/typer/param_types.py index a5a986aee8..119a98a734 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -10,7 +10,7 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError from . import _click, adapters -from ._click import types +from ._click import Context, Parameter, types from ._click.shell_completion import CompletionItem from ._typing import is_literal_type, literal_values from .models import ( @@ -41,6 +41,24 @@ def _strip_string(value: Any) -> Any: return value.strip() if isinstance(value, str) else value +class FuncParamType(types.ParamType): + def __init__(self, func: Callable[[Any], Any]) -> None: + self.name: str = getattr(func, "__name__", "function") + self.func = func + + def convert(self, value: Any, param: Parameter | None, ctx: Context | None) -> Any: + try: + return self.func(value) + except ValueError: + try: + value = str(value) + except UnicodeError: # pragma: no cover + assert isinstance(value, bytes) + value = value.decode("utf-8", "replace") + + self.fail(value, param, ctx) + + class PydanticParamType(types.ParamType): _class_adapter: TypeAdapter[Any] @@ -50,9 +68,7 @@ def __init__( *, name: str, repr_name: str | None = None, - metavar: str - | Callable[[_click.Parameter, _click.Context], str | None] - | None = None, + metavar: str | Callable[[Parameter, Context], str | None] | None = None, preprocess: Callable[[Any], Any] | None = None, ) -> None: self._class_adapter = adapter @@ -64,8 +80,8 @@ def __init__( def convert( self, value: Any, - param: _click.Parameter | None, - ctx: _click.Context | None, + param: Parameter | None, + ctx: Context | None, ) -> Any: if self._preprocess is not None: value = self._preprocess(value) @@ -74,7 +90,7 @@ def convert( except ValidationError as exc: self.fail(_get_error_msg(exc), param, ctx) - def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: if self._metavar is None: return None if isinstance(self._metavar, str): @@ -133,7 +149,7 @@ def __init__( self.case_sensitive = case_sensitive def _normalized_mapping( - self, ctx: _click.Context | None = None + self, ctx: Context | None = None ) -> Mapping[ParamTypeValue, str]: """ Returns mapping where keys are the original choices and the values are @@ -147,9 +163,7 @@ def _normalized_mapping( for choice in self.choices } - def normalize_choice( - self, choice: ParamTypeValue, ctx: _click.Context | None - ) -> str: + def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) if ctx is not None and ctx.token_normalize_func is not None: @@ -160,7 +174,7 @@ def normalize_choice( return normed_value - def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | None: + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: if param.param_type_name == "option" and not param.show_choices: # type: ignore choice_metavars = [ resolve_param_type(type(choice)).name.upper() for choice in self.choices @@ -178,16 +192,12 @@ def get_metavar(self, param: _click.Parameter, ctx: _click.Context) -> str | Non # Use square braces to indicate an option or optional argument. return f"[{choices_str}]" - def get_missing_message( - self, param: _click.Parameter, ctx: _click.Context | None - ) -> str: + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: """Message shown when no choice is passed.""" choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) return f"Choose from:\n\t{choices}" - def _build_class_adapter( - self, ctx: _click.Context | None - ) -> TypeAdapter[ParamTypeValue]: + def _build_class_adapter(self, ctx: Context | None) -> TypeAdapter[ParamTypeValue]: normalized_mapping = self._normalized_mapping(ctx=ctx) def parse_choice(value: Any) -> ParamTypeValue: @@ -200,14 +210,14 @@ def parse_choice(value: Any) -> ParamTypeValue: return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) def convert( - self, value: Any, param: _click.Parameter | None, ctx: _click.Context | None + self, value: Any, param: Parameter | None, ctx: Context | None ) -> ParamTypeValue: try: return self._build_class_adapter(ctx).validate_python(value) except ValidationError as exc: self.fail(_get_error_msg(exc), param=param, ctx=ctx) - def get_invalid_choice_message(self, value: Any, ctx: _click.Context | None) -> str: + def get_invalid_choice_message(self, value: Any, ctx: Context | None) -> str: """Get the error message when the given choice is invalid.""" choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) return f"{value!r} is not one of {choices_str}." @@ -221,7 +231,7 @@ def _choice_as_str(self, choice: ParamTypeValue) -> str: return str(choice) def shell_complete( - self, ctx: _click.Context, param: _click.Parameter, incomplete: str + self, ctx: Context, param: Parameter, incomplete: str ) -> list[CompletionItem]: """Complete choices that start with the incomplete value.""" @@ -269,8 +279,8 @@ def __init__( def _parse_path_value( self, value: Any, - param: _click.Parameter | None, - ctx: _click.Context | None, + param: Parameter | None, + ctx: Context | None, ) -> Any: if self.type is None or self.type is str or self.type is bytes: return value @@ -302,8 +312,8 @@ def coerce_path_result( def convert( self, value: str | os.PathLike[str], - param: _click.Parameter | None, - ctx: _click.Context | None, + param: Parameter | None, + ctx: Context | None, ) -> str | bytes | os.PathLike[str]: rv = self._parse_path_value(value, param, ctx) @@ -341,7 +351,7 @@ def convert( return self.coerce_path_result(rv) def shell_complete( - self, ctx: _click.Context, param: _click.Parameter, incomplete: str + self, ctx: Context, param: Parameter, incomplete: str ) -> list[CompletionItem]: """Return an empty list so that the autocompletion functionality will work properly from the commandline. @@ -421,7 +431,7 @@ def resolve_param_type( return annotation if parameter_info is not None and parameter_info.parser is not None: - return types.FuncParamType(parameter_info.parser) + return FuncParamType(parameter_info.parser) if parameter_info is not None and annotation is not None: param_type = param_type_from_annotation(annotation, parameter_info) @@ -440,7 +450,7 @@ def resolve_param_type( if guessed_type: return STRING - return types.FuncParamType(annotation) + return FuncParamType(annotation) def get_param_type( From 98e200b54b75d5455240bed711cb0cec5b8f6b1b Mon Sep 17 00:00:00 2001 From: svlandeg Date: Sun, 14 Jun 2026 08:32:25 +0200 Subject: [PATCH 32/73] coercion through RuntimeParam schema, slim down PydanticParamType to DisplayParamType --- tests/test_schema.py | 152 +++++++++++++++++++++++ tests/test_type_conversion.py | 9 +- typer/adapters.py | 205 +++++++++++++++++++++++++++++-- typer/core.py | 48 +++++++- typer/main.py | 151 +++++++++-------------- typer/param_types.py | 101 ++++++--------- typer/schema.py | 225 ++++++++++++++++++++++++++++++++++ 7 files changed, 715 insertions(+), 176 deletions(-) create mode 100644 tests/test_schema.py create mode 100644 typer/schema.py diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000000..0ec5a14fa7 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,152 @@ +from enum import Enum +from typing import Any + +import pytest +import typer +from typer.adapters import build_adapter +from typer.main import get_command +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_simple_command_schema() -> None: + app = typer.Typer() + + @app.command() + def main(name: str, age: int = 0, active: bool = False): + pass + + schema = get_command(app).schema + assert [param.name for param in schema.params] == ["name", "age", "active"] + + name_param = schema.get_param("name") + assert name_param is not None + assert name_param.annotation is str + assert name_param.kind == "argument" + assert name_param.required is True + + age_param = schema.get_param("age") + assert age_param is not None + assert age_param.annotation is int + assert age_param.kind == "option" + assert age_param.default == 0 + + active_param = schema.get_param("active") + assert active_param is not None + assert active_param.is_flag is True + assert active_param.is_bool_flag is True + + +@pytest.mark.parametrize( + ("raw", "expected", "expected_type"), + [ + ("42", 42, int), + ("3.5", 3.5, float), + ("hello", "hello", str), + (True, True, bool), + ], +) +def test_schema_coerce_scalars(raw: Any, expected: Any, expected_type: type) -> None: + adapter = build_adapter(expected_type, typer.models.OptionInfo()) + runtime_value = adapter.validate_python(raw) + + assert runtime_value == expected + assert type(runtime_value) is expected_type + + +def test_schema_coerce_list() -> None: + app = typer.Typer() + + @app.command() + def main(items: list[int]): + pass + + schema = get_command(app).schema + runtime_param = schema.get_param("items") + assert runtime_param is not None + assert runtime_param.annotation == list[int] + assert runtime_param.multiple is True + + assert runtime_param.coerce(("1", "2", "3")) == [1, 2, 3] + + +def test_schema_coerce_enum() -> None: + class Color(str, Enum): + RED = "red" + BLUE = "blue" + + app = typer.Typer() + + @app.command() + def main(color: Color): + pass + + schema = get_command(app).schema + runtime_param = schema.get_param("color") + assert runtime_param is not None + assert runtime_param.coerce("red") is Color.RED + + +def test_schema_coerce_unannotated_default() -> None: + app = typer.Typer() + + @app.command() + def main(val=42): + pass + + schema = get_command(app).schema + runtime_param = schema.get_param("val") + assert runtime_param is not None + assert runtime_param.annotation is int + assert runtime_param.coerce("99") == 99 + + +def test_runtime_coercion_on_invoke() -> None: + app = typer.Typer() + seen: dict[str, Any] = {} + + @app.command() + def main( + items: list[int], + active: bool = False, + val=42, + ): + seen["items"] = items + seen["active"] = active + seen["val"] = val + + result = runner.invoke(app, ["1", "2", "--active", "--val", "7"]) + assert result.exit_code == 0, result.output + assert seen == {"items": [1, 2], "active": True, "val": 7} + assert all(isinstance(v, int) for v in seen["items"]) + assert isinstance(seen["val"], int) + + +def test_runtime_coercion_invalid_value() -> None: + app = typer.Typer() + + @app.command() + def main(age: int): + pass + + result = runner.invoke(app, ["--age", "not-an-int"]) + assert result.exit_code != 0 + + +def test_schema_coerce_command_values() -> None: + app = typer.Typer() + seen: dict[str, Any] = {} + + @app.command() + def main(name: str, count: int = 1): + seen["name"] = name + seen["count"] = count + + schema = get_command(app).schema + coerced = schema.coerce({"name": "Ada", "count": "3"}) + assert coerced == {"name": "Ada", "count": 3} + + result = runner.invoke(app, ["Ada", "--count", "3"]) + assert result.exit_code == 0 + assert seen == {"name": "Ada", "count": 3} diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 5db5b3f6c0..4ef5e06e88 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -12,7 +12,6 @@ FLOAT, INT, STRING, - FuncParamType, TyperPath, resolve_param_type, ) @@ -146,8 +145,9 @@ def tuple_recursive_conversion(container: type_annotation): assert result.exit_code == 0 -def test_tuple_wrong_arity(): +def test_tuple_wrong_arity(monkeypatch): app = typer.Typer() + monkeypatch.setenv("COLUMNS", "200") @app.command() def tuple_arity(value: tuple[str, str] = typer.Option(...)): @@ -413,8 +413,7 @@ class CustomType: assert guessed_unknown is STRING func_type = resolve_param_type(CustomType) - assert isinstance(func_type, FuncParamType) - assert func_type.name == "CustomType" + assert func_type is STRING def test_int_rejects_float_default() -> None: @@ -461,5 +460,5 @@ def __init__(self, encoding: str | None) -> None: monkeypatch.setattr(sys, "stdin", FakeStdin(stdin_encoding)) monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem_encoding) - converted = STRING.convert(b"\xff", None, None) + converted = typer.adapters.build_leaf_adapter(str).validate_python(b"\xff") assert converted == "ÿ" diff --git a/typer/adapters.py b/typer/adapters.py index 41b46ee7e9..af65e1113e 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -1,10 +1,22 @@ import sys from collections.abc import Callable, Sequence from datetime import datetime -from typing import Annotated, Any +from enum import Enum +from pathlib import Path +from typing import Annotated, Any, get_args, get_origin +from uuid import UUID as UUIDType from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter +from ._typing import is_literal_type, literal_values +from .models import ( + FileBinaryRead, + FileBinaryWrite, + FileText, + FileTextWrite, + ParameterInfo, +) + def _build_datetime_adapter( formats: Sequence[str] | None, @@ -79,7 +91,7 @@ def clamp_number(value: Any) -> Any: return clamp_number -def build_type_adapter( +def build_leaf_adapter( annotation: Any, *, min: float | None = None, @@ -87,11 +99,7 @@ def build_type_adapter( clamp: bool = False, formats: Sequence[str] | None = None, ) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a CLI annotation and constraints. - - Known constraints (ranges, custom datetime formats, etc.) are applied first, - everything else is delegated to Pydantic. - """ + """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" if annotation is datetime and formats is not None: return _build_datetime_adapter(formats) @@ -119,3 +127,186 @@ def build_type_adapter( return TypeAdapter(Annotated[str, BeforeValidator(_parse_cli_str)]) return TypeAdapter(annotation) + + +def _build_parser_adapter(parser: Callable[[Any], Any]) -> TypeAdapter[Any]: + def parse_with_parser(value: Any) -> Any: + try: + return parser(value) + except ValueError: + try: + value = str(value) + except UnicodeError: # pragma: no cover + assert isinstance(value, bytes) + value = value.decode("utf-8", "replace") + raise ValueError(value) from None + + return TypeAdapter(Annotated[Any, BeforeValidator(parse_with_parser)]) + + +def build_adapter( + annotation: Any, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any]: + """Build a Pydantic TypeAdapter for a parameter annotation and metadata.""" + if parameter_info.parser is not None: + return _build_parser_adapter(parameter_info.parser) + + origin = get_origin(annotation) + if origin is list: + (item_type,) = get_args(annotation) + item_adapter = build_adapter(item_type, parameter_info) + + def parse_list(value: Any) -> list[Any]: + if not isinstance(value, (list, tuple)): + value = (value,) + return [ + None if item is None else item_adapter.validate_python(item) + for item in value + ] + + return TypeAdapter(Annotated[list[Any], BeforeValidator(parse_list)]) + + if origin is tuple: + item_types = get_args(annotation) + item_adapters = [ + build_adapter(item_type, parameter_info) for item_type in item_types + ] + + def parse_tuple(value: Any) -> tuple[Any, ...]: + if not isinstance(value, (list, tuple)): + raise ValueError("value is not a valid tuple") + if len(value) != len(item_adapters): + raise ValueError( + f"{len(item_adapters)} values are required, but {len(value)} given." + ) + return tuple( + None if item is None else adapter.validate_python(item) + for adapter, item in zip(item_adapters, value, strict=False) + ) + + return TypeAdapter(Annotated[tuple[Any, ...], BeforeValidator(parse_tuple)]) + + if annotation is int or annotation is float: + return build_leaf_adapter( + annotation, + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + ) + if annotation is datetime: + return build_leaf_adapter(annotation, formats=parameter_info.formats) + if annotation is bool: + return build_leaf_adapter(bool) + if annotation is str: + return build_leaf_adapter(str) + if annotation is UUIDType: + return build_leaf_adapter(UUIDType) + + from .param_types import ( + _needs_typer_path, + lenient_issubclass, + ) + + if lenient_issubclass(annotation, Enum): + return _build_choice_adapter( + list(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + if is_literal_type(annotation): + return _build_choice_adapter( + literal_values(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + if _needs_typer_path(annotation, parameter_info): + return _build_path_adapter(annotation, parameter_info) + if lenient_issubclass(annotation, FileTextWrite): + return _build_file_adapter(parameter_info, mode="w") + if lenient_issubclass(annotation, FileText): + return _build_file_adapter(parameter_info, mode="r") + if lenient_issubclass(annotation, FileBinaryRead): + return _build_file_adapter(parameter_info, mode="rb") + if lenient_issubclass(annotation, FileBinaryWrite): + return _build_file_adapter(parameter_info, mode="wb") + return build_leaf_adapter(annotation) + + +def _normalize_choice_value( + choice_or_value: Any, + *, + case_sensitive: bool, + ctx: Any | None, +) -> str: + if isinstance(choice_or_value, Enum): + normed = str(choice_or_value.value) + else: + normed = str(choice_or_value) + if ctx is not None and ctx.token_normalize_func is not None: + normed = ctx.token_normalize_func(normed) + if not case_sensitive: + normed = normed.casefold() + return normed + + +def _build_choice_adapter( + choices: Sequence[Any], + *, + case_sensitive: bool, +) -> TypeAdapter[Any]: + def normalize(choice: Any) -> str: + return _normalize_choice_value(choice, case_sensitive=case_sensitive, ctx=None) + + mapping = {normalize(choice): choice for choice in choices} + + def parse_choice(value: Any) -> Any: + if any(isinstance(choice, Enum) and value is choice for choice in choices): + return value + key = normalize(value) + if key in mapping: + return mapping[key] + choices_str = ", ".join(map(repr, mapping.values())) + raise ValueError(f"{value!r} is not one of {choices_str}.") + + return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) + + +def _build_path_adapter( + annotation: Any, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any]: + from .param_types import TyperPath, lenient_issubclass + + path_type = parameter_info.path_type + if path_type is None and lenient_issubclass(annotation, Path): + path_type = annotation + + typer_path = TyperPath( + exists=parameter_info.exists, + file_okay=parameter_info.file_okay, + dir_okay=parameter_info.dir_okay, + writable=parameter_info.writable, + readable=parameter_info.readable, + resolve_path=parameter_info.resolve_path, + allow_dash=parameter_info.allow_dash, + path_type=path_type, + ) + + def parse_path(value: Any) -> Any: + return typer_path.convert(value, param=None, ctx=None) + + return TypeAdapter(Annotated[Any, BeforeValidator(parse_path)]) + + +def _build_file_adapter( + parameter_info: ParameterInfo, + *, + mode: str, +) -> TypeAdapter[Any]: + from .param_types import _file_param_type + + file_type = _file_param_type(parameter_info, mode=mode) + + def parse_file(value: Any) -> Any: + return file_type.convert(value, param=None, ctx=None) + + return TypeAdapter(Annotated[Any, BeforeValidator(parse_file)]) diff --git a/typer/core.py b/typer/core.py index f41e71ddc2..432682fd1e 100644 --- a/typer/core.py +++ b/typer/core.py @@ -13,11 +13,12 @@ cast, ) -from . import _click, adapters, param_types +from . import _click, param_types from ._click import types from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem from ._typing import Literal +from .schema import CommandSchema, RuntimeParam from .utils import describe_number_range, parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] @@ -92,6 +93,30 @@ def compat_autocompletion( self._custom_shell_complete = compat_autocompletion +def _type_cast_runtime_value( + param: "TyperOption | TyperArgument", + ctx: _click.Context, + value: Any, +) -> Any: + runtime_param = param.runtime_param + assert runtime_param is not None + if value is None: + return () if param.multiple or param.nargs == -1 else None + if (param.multiple or param.nargs == -1) and isinstance(value, str): + raise _click.exceptions.BadParameter( + "Value must be an iterable.", ctx=ctx, param=param + ) + if isinstance(param.type, types.File): + return param.type(value, param=param, ctx=ctx) + if ( + isinstance(param.type, param_types.TyperChoice) + and param.nargs == 1 + and not param.multiple + ): + return param.type(value, param=param, ctx=ctx) + return runtime_param.coerce(value, param=param, ctx=ctx) + + def _get_default_string( obj: Union["TyperArgument", "TyperOption"], *, @@ -289,6 +314,7 @@ def __init__( max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, + runtime_param: RuntimeParam | None = None, ): self.help = help self.show_default = show_default @@ -298,6 +324,7 @@ def __init__( self.min = min self.max = max self.rich_help_panel = rich_help_panel + self.runtime_param = runtime_param super().__init__( param_decls=param_decls, @@ -420,6 +447,11 @@ def make_metavar(self, ctx: _click.Context) -> str: def value_is_missing(self, value: Any) -> bool: return _value_is_missing(self, value) + def type_cast_value(self, ctx: _click.Context, value: Any) -> Any: + if self.runtime_param is None: + return super().type_cast_value(ctx, value) + return _type_cast_runtime_value(self, ctx, value) + def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -493,12 +525,14 @@ def __init__( max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, + runtime_param: RuntimeParam | None = None, ): if help: help = inspect.cleandoc(help) self.min = min self.max = max + self.runtime_param = runtime_param super().__init__( param_decls, @@ -546,9 +580,8 @@ def __init__( # Counting self.count = count if count and type is None: - self.type = param_types.PydanticParamType( - adapters.build_type_adapter(int, min=0), - name="integer range", + self.type = param_types._ranged_number_param_type( + int, min=0, max=None, clamp=False ) if self.min is None: self.min = 0 @@ -568,6 +601,11 @@ def get_error_hint(self, ctx: _click.Context) -> str: result += f" (env var: '{self.envvar}')" return result + def type_cast_value(self, ctx: _click.Context, value: Any) -> Any: + if self.runtime_param is None: + return super().type_cast_value(ctx, value) + return _type_cast_runtime_value(self, ctx, value) + def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -941,6 +979,7 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, + schema: CommandSchema | None = None, ) -> None: super().__init__( name=name, @@ -958,6 +997,7 @@ def __init__( ) self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel + self.schema = schema or CommandSchema.from_params(params or []) def format_options( self, ctx: _click.Context, formatter: _click.HelpFormatter diff --git a/typer/main.py b/typer/main.py index 9dcfb77d72..40dcd06422 100644 --- a/typer/main.py +++ b/typer/main.py @@ -15,8 +15,8 @@ from . import _click from ._click.globals import get_current_context -from ._click.types import ParamType -from ._typing import get_args, get_origin, is_union +from ._click.types import ParamType, Tuple +from ._typing import get_args, get_origin from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -34,14 +34,12 @@ Default, DefaultPlaceholder, DeveloperExceptionConfig, - NoneType, OptionInfo, - ParameterInfo, ParamMeta, - Required, TyperInfo, ) -from .param_types import infer_type_from_default, lenient_issubclass, resolve_param_type +from .param_types import cli_param_type, lenient_issubclass +from .schema import CommandSchema, declare_param, runtime_param_from_declared from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1380,6 +1378,7 @@ def get_command_from_info( command_info.callback ) cls = command_info.cls or TyperCommand + schema = CommandSchema.from_params(params) command = cls( name=name, context_settings=command_info.context_settings, @@ -1401,6 +1400,7 @@ def get_command_from_info( rich_markup_mode=rich_markup_mode, # Rich settings rich_help_panel=command_info.rich_help_panel, + schema=schema, ) return command @@ -1438,9 +1438,9 @@ def get_callback( def wrapper(**kwargs: Any) -> Any: _rich_traceback_guard = pretty_exceptions_short # noqa: F841 for k, v in kwargs.items(): - click_param = params_by_name.get(k) - if click_param is not None: - use_params[k] = _normalize_collection_value(click_param, v) + matched_param = params_by_name.get(k) + if matched_param is not None: + use_params[k] = _normalize_collection_value(matched_param, v) else: use_params[k] = v if context_param_name: @@ -1454,86 +1454,22 @@ def wrapper(**kwargs: Any) -> Any: def get_param( param: ParamMeta, ) -> TyperArgument | TyperOption: - # First, find out what will be: - # * ParamInfo (ArgumentInfo or OptionInfo) - # * default_value - # * required - default_value = None - required = False - if isinstance(param.default, ParameterInfo): - parameter_info = param.default - if parameter_info.default == Required: - required = True - else: - default_value = parameter_info.default - elif param.default == Required or param.default is param.empty: - required = True - parameter_info = ArgumentInfo() - else: - default_value = param.default - parameter_info = OptionInfo() - main_type: Any - parameter_type: ParamType | None - is_list = False - is_flag = None - - if param.annotation is not param.empty: - main_type = param.annotation - origin = get_origin(main_type) - - if origin is not None: - # Handle SomeType | None and Optional[SomeType] - if is_union(origin): - types = [] - for type_ in get_args(main_type): - if type_ is NoneType: - continue - types.append(type_) - assert len(types) == 1, "Typer Currently doesn't support Union types" - main_type = types[0] - origin = get_origin(main_type) - # 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" - ) - is_list = True - parameter_type = resolve_param_type( - main_type, parameter_info=parameter_info - ) - elif lenient_issubclass(origin, tuple): - type_args = get_args(main_type) - for type_ in type_args: - assert not get_origin(type_), ( - "Tuple types with complex sub-types are not currently supported" - ) - parameter_type = resolve_param_type( - tuple(type_args), parameter_info=parameter_info - ) - else: - parameter_type = resolve_param_type( - main_type, parameter_info=parameter_info - ) - else: - parameter_type = resolve_param_type( - main_type, parameter_info=parameter_info - ) - else: - if default_value is not None: - main_type, _ = infer_type_from_default(default_value) - if main_type is None: - main_type = str - else: - main_type = str - parameter_type = resolve_param_type( - default=default_value, parameter_info=parameter_info - ) + declared = declare_param(param) + parameter_info = declared.parameter_info + default_value = declared.default + required = declared.required + is_list = declared.is_list + is_flag = declared.is_flag + parameter_type: ParamType | None = cli_param_type( + annotation=declared.annotation, + parameter_info=parameter_info, + default=default_value, + is_list=is_list, + is_tuple=declared.is_tuple, + ) + if isinstance(parameter_info, OptionInfo): - if main_type is bool: - is_flag = True - # Click doesn't accept a flag of type bool, only None, and then it sets it - # to bool internally + if declared.is_flag: parameter_type = None default_option_name = get_command_name(param.name) if is_flag: @@ -1547,6 +1483,19 @@ def get_param( param_decls.extend(parameter_info.param_decls) else: param_decls.append(default_option_declaration) + runtime_param = runtime_param_from_declared( + declared, + kind="option", + multiple=is_list, + nargs=1, + is_bool_flag=bool( + declared.is_flag + and ( + declared.annotation is bool + or (declared.is_list and get_args(declared.annotation) == (bool,)) + ) + ), + ) return TyperOption( # Option param_decls=param_decls, @@ -1578,12 +1527,23 @@ def get_param( max=parameter_info.max, # Rich settings rich_help_panel=parameter_info.rich_help_panel, + runtime_param=runtime_param, ) elif isinstance(parameter_info, ArgumentInfo): param_decls = [param.name] nargs = None if is_list: nargs = -1 + binding_nargs = nargs if nargs is not None else 1 + if isinstance(parameter_type, Tuple): + binding_nargs = parameter_type.arity + runtime_param = runtime_param_from_declared( + declared, + kind="argument", + multiple=is_list, + nargs=binding_nargs, + is_bool_flag=False, + ) return TyperArgument( # Argument param_decls=param_decls, @@ -1609,6 +1569,7 @@ def get_param( max=parameter_info.max, # Rich settings rich_help_panel=parameter_info.rich_help_panel, + runtime_param=runtime_param, ) raise AssertionError("A Parameter should be returned") # pragma: no cover @@ -1621,26 +1582,26 @@ def get_param_callback( return None parameters = get_params_from_function(callback) ctx_name = None - click_param_name = None + param_arg_name = None value_name = None untyped_names: list[str] = [] for param_name, param_sig in parameters.items(): if lenient_issubclass(param_sig.annotation, _click.Context): ctx_name = param_name elif lenient_issubclass(param_sig.annotation, _click.Parameter): - click_param_name = param_name + param_arg_name = param_name else: untyped_names.append(param_name) # Extract value param name first if untyped_names: value_name = untyped_names.pop() - # If context and Click param were not typed (old/Click callback style) extract them + # If context and parameter were not typed, extract them by position. if untyped_names: if ctx_name is None: ctx_name = untyped_names.pop(0) - if click_param_name is None: + if param_arg_name is None: if untyped_names: - click_param_name = untyped_names.pop(0) + param_arg_name = untyped_names.pop(0) if untyped_names: raise _click.ClickException( "Too many CLI parameter callback function parameters" @@ -1650,8 +1611,8 @@ def wrapper(ctx: _click.Context, param: _click.Parameter, value: Any) -> Any: use_params: dict[str, Any] = {} if ctx_name: use_params[ctx_name] = ctx - if click_param_name: - use_params[click_param_name] = param + if param_arg_name: + use_params[param_arg_name] = param if value_name: use_params[value_name] = _normalize_collection_value(param, value) return callback(**use_params) diff --git a/typer/param_types.py b/typer/param_types.py index 119a98a734..a188ee809c 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -37,45 +37,19 @@ def _get_error_msg(exc: ValidationError) -> str: return str(exc) -def _strip_string(value: Any) -> Any: - return value.strip() if isinstance(value, str) else value - - -class FuncParamType(types.ParamType): - def __init__(self, func: Callable[[Any], Any]) -> None: - self.name: str = getattr(func, "__name__", "function") - self.func = func - - def convert(self, value: Any, param: Parameter | None, ctx: Context | None) -> Any: - try: - return self.func(value) - except ValueError: - try: - value = str(value) - except UnicodeError: # pragma: no cover - assert isinstance(value, bytes) - value = value.decode("utf-8", "replace") - - self.fail(value, param, ctx) - - -class PydanticParamType(types.ParamType): - _class_adapter: TypeAdapter[Any] +class DisplayParamType(types.ParamType): + """Used for metavar/help only.""" def __init__( self, - adapter: TypeAdapter[Any], *, name: str, repr_name: str | None = None, metavar: str | Callable[[Parameter, Context], str | None] | None = None, - preprocess: Callable[[Any], Any] | None = None, ) -> None: - self._class_adapter = adapter self.name = name self._repr_name = repr_name or name self._metavar = metavar - self._preprocess = preprocess def convert( self, @@ -83,12 +57,7 @@ def convert( param: Parameter | None, ctx: Context | None, ) -> Any: - if self._preprocess is not None: - value = self._preprocess(value) - try: - return self._class_adapter.validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) + return value def get_metavar(self, param: Parameter, ctx: Context) -> str | None: if self._metavar is None: @@ -101,42 +70,26 @@ def __repr__(self) -> str: return self._repr_name -def datetime_param_type(formats: Sequence[str] | None = None) -> PydanticParamType: +def datetime_param_type(formats: Sequence[str] | None = None) -> DisplayParamType: formats_tuple = tuple(formats) if formats is not None else None metavar_formats = formats_tuple or ["%Y-%m-%d"] - return PydanticParamType( - adapters.build_type_adapter(datetime, formats=formats_tuple), + return DisplayParamType( name="datetime", repr_name="DateTime", metavar=f"[{'|'.join(metavar_formats)}]", ) -INT = PydanticParamType( - adapters.build_type_adapter(int), name="integer", repr_name="INT" -) +INT = DisplayParamType(name="integer", repr_name="INT") -FLOAT = PydanticParamType( - adapters.build_type_adapter(float), name="float", repr_name="FLOAT" -) +FLOAT = DisplayParamType(name="float", repr_name="FLOAT") -BOOL = PydanticParamType( - adapters.build_type_adapter(bool), name="boolean", repr_name="BOOL" -) +BOOL = DisplayParamType(name="boolean", repr_name="BOOL") -UUID = PydanticParamType( - adapters.build_type_adapter(UUIDType), - name="uuid", - repr_name="UUID", - preprocess=_strip_string, -) +UUID = DisplayParamType(name="uuid", repr_name="UUID") -STRING = PydanticParamType( - adapters.build_type_adapter(str), - name="text", - repr_name="STRING", -) +STRING = DisplayParamType(name="text", repr_name="STRING") class TyperChoice(types.ParamType, Generic[ParamTypeValue]): @@ -289,7 +242,7 @@ def _parse_path_value( return value if isinstance(value, (str, os.PathLike)): try: - return adapters.build_type_adapter(self.type).validate_python(value) + return adapters.build_leaf_adapter(self.type).validate_python(value) except ValidationError as exc: self.fail(_get_error_msg(exc), param, ctx) return value @@ -376,8 +329,7 @@ def _ranged_number_param_type( max: int | float | None, clamp: bool, ) -> types.ParamType: - return PydanticParamType( - adapters.build_type_adapter(number_class, min=min, max=max, clamp=clamp), + return DisplayParamType( name="integer range" if number_class is int else "float range", ) @@ -409,7 +361,7 @@ def resolve_param_type( *, parameter_info: ParameterInfo | None = None, ) -> types.ParamType: - """Resolve a ParamType from a type and/or default value.""" + """Resolve a display ``ParamType`` for metavar/help.""" guessed_type = False if annotation is None and default is not None: annotation, guessed_type = infer_type_from_default(default) @@ -430,9 +382,6 @@ def resolve_param_type( if isinstance(annotation, types.ParamType): return annotation - if parameter_info is not None and parameter_info.parser is not None: - return FuncParamType(parameter_info.parser) - if parameter_info is not None and annotation is not None: param_type = param_type_from_annotation(annotation, parameter_info) if param_type is not None: @@ -450,7 +399,29 @@ def resolve_param_type( if guessed_type: return STRING - return FuncParamType(annotation) + return STRING + + +def cli_param_type( + *, + annotation: Any, + parameter_info: ParameterInfo, + default: Any, + is_list: bool, + is_tuple: bool, +) -> types.ParamType: + """Defer the "type" for metavar/help.""" + from ._typing import get_args as typer_get_args + + if is_tuple: + type_args = typer_get_args(annotation) + return resolve_param_type(tuple(type_args), parameter_info=parameter_info) + if is_list: + (element_type,) = typer_get_args(annotation) + return resolve_param_type(element_type, parameter_info=parameter_info) + return resolve_param_type( + annotation, default=default, parameter_info=parameter_info + ) def get_param_type( diff --git a/typer/schema.py b/typer/schema.py new file mode 100644 index 0000000000..85258538f8 --- /dev/null +++ b/typer/schema.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import TypeAdapter, ValidationError + +from . import adapters +from ._typing import get_args as typer_get_args +from ._typing import get_origin as typer_get_origin +from ._typing import is_union +from .models import ( + ArgumentInfo, + NoneType, + OptionInfo, + ParameterInfo, + ParamMeta, + Required, +) +from .param_types import infer_type_from_default, lenient_issubclass + +ParamKind = Literal["option", "argument"] + + +@dataclass(frozen=True) +class DeclaredParam: + """Parameter metadata declared on a Typer command callback.""" + + name: str + parameter_info: ParameterInfo + default: Any + required: bool + annotation: Any + is_list: bool + is_tuple: bool + is_flag: bool | None + + +@dataclass(frozen=True) +class RuntimeParam: + """Runtime coercion contract for one command parameter.""" + + name: str + parameter_info: ParameterInfo + default: Any + required: bool + annotation: Any + adapter: TypeAdapter[Any] + kind: ParamKind + multiple: bool + nargs: int + is_flag: bool + is_bool_flag: bool + + def coerce( + self, + value: Any, + *, + param: Any | None = None, + ctx: Any | None = None, + ) -> Any: + if value is None: + return None + try: + return self.adapter.validate_python(value) + except ValidationError as exc: + if param is None: + raise + from ._click.exceptions import BadParameter + from .param_types import _get_error_msg + + raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc + except ValueError as exc: + if param is None: + raise + from ._click.exceptions import BadParameter + + raise BadParameter(str(exc), ctx=ctx, param=param) from exc + + +@dataclass(frozen=True) +class CommandSchema: + """Schema for all parameters on a Typer command.""" + + params: tuple[RuntimeParam, ...] + + @classmethod + def from_params(cls, command_params: Sequence[Any]) -> CommandSchema: + runtime_params = [ + param.runtime_param + for param in command_params + if getattr(param, "runtime_param", None) is not None + ] + return cls(params=tuple(runtime_params)) + + def get_param(self, name: str) -> RuntimeParam | None: + for runtime_param in self.params: + if runtime_param.name == name: + return runtime_param + return None + + def coerce(self, values: dict[str, Any]) -> dict[str, Any]: + coerced: dict[str, Any] = {} + for name, value in values.items(): + runtime_param = self.get_param(name) + if runtime_param is not None: + coerced[name] = runtime_param.coerce(value, param=None, ctx=None) + return coerced + + +def declare_param(param: ParamMeta) -> DeclaredParam: + """Declare metadata from a function parameter.""" + default = None + required = False + if isinstance(param.default, ParameterInfo): + parameter_info = param.default + if parameter_info.default == Required: + required = True + else: + default = parameter_info.default + elif param.default == Required or param.default is param.empty: + required = True + parameter_info = ArgumentInfo() + else: + default = param.default + parameter_info = OptionInfo() + + is_list = False + is_tuple = False + is_flag: bool | None = None + pydantic_annotation: Any + + if param.annotation is not param.empty: + main_type = param.annotation + origin = typer_get_origin(main_type) + + if origin is not None: + if is_union(origin): + types = [] + for type_ in typer_get_args(main_type): + if type_ is NoneType: + continue + types.append(type_) + assert len(types) == 1, "Typer Currently doesn't support Union types" + main_type = types[0] + origin = typer_get_origin(main_type) + + if lenient_issubclass(origin, list): + element_type = typer_get_args(main_type)[0] + assert not typer_get_origin(element_type), ( + "List types with complex sub-types are not currently supported" + ) + is_list = True + pydantic_annotation = main_type + elif lenient_issubclass(origin, tuple): + type_args = typer_get_args(main_type) + for type_ in type_args: + assert not typer_get_origin(type_), ( + "Tuple types with complex sub-types are not currently supported" + ) + is_tuple = True + pydantic_annotation = main_type + else: + pydantic_annotation = main_type + else: + pydantic_annotation = main_type + else: + if default is not None: + main_type, guessed = infer_type_from_default(default) + if main_type is None: + main_type = str + elif guessed and main_type not in (int, float, bool, str): + main_type = str + else: + main_type = str + pydantic_annotation = main_type + + if isinstance(parameter_info, OptionInfo) and pydantic_annotation is bool: + is_flag = True + elif ( + is_list + and isinstance(parameter_info, OptionInfo) + and parameter_info.param_decls + and typer_get_args(pydantic_annotation) == (bool,) + ): + for decl in parameter_info.param_decls: + if "/" in decl: + is_flag = True + break + + return DeclaredParam( + name=param.name, + parameter_info=parameter_info, + default=default, + required=required, + annotation=pydantic_annotation, + is_list=is_list, + is_tuple=is_tuple, + is_flag=is_flag, + ) + + +def runtime_param_from_declared( + declared: DeclaredParam, + *, + kind: ParamKind, + multiple: bool, + nargs: int, + is_bool_flag: bool, +) -> RuntimeParam: + adapter = adapters.build_adapter(declared.annotation, declared.parameter_info) + return RuntimeParam( + name=declared.name, + annotation=declared.annotation, + parameter_info=declared.parameter_info, + adapter=adapter, + kind=kind, + multiple=multiple, + nargs=nargs, + is_flag=bool(declared.is_flag), + is_bool_flag=is_bool_flag, + required=declared.required, + default=declared.default, + ) From 12df977b753d7c372b3bf1f6746c872e72e62f3e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Sun, 14 Jun 2026 09:33:59 +0200 Subject: [PATCH 33/73] move out File to param_types and extend RuntimeParam --- tests/test_schema.py | 42 +++++++++++++ typer/_click/types.py | 115 ---------------------------------- typer/adapters.py | 31 +--------- typer/core.py | 2 - typer/param_types.py | 141 +++++++++++++++++++++++++++++++++++++++--- typer/schema.py | 110 ++++++++++++++++++++++++++++---- 6 files changed, 273 insertions(+), 168 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 0ec5a14fa7..89a37ca494 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,10 +1,13 @@ from enum import Enum +from pathlib import Path from typing import Any import pytest import typer from typer.adapters import build_adapter from typer.main import get_command +from typer.param_types import file_coercion_annotation +from typer.schema import FileRuntimeParam from typer.testing import CliRunner runner = CliRunner() @@ -150,3 +153,42 @@ def main(name: str, count: int = 1): result = runner.invoke(app, ["Ada", "--count", "3"]) assert result.exit_code == 0 assert seen == {"name": "Ada", "count": 3} + + +@pytest.mark.parametrize( + ("annotation", "expected"), + [ + (typer.FileText, typer.FileText), + (list[typer.FileText], typer.FileText), + (tuple[typer.FileText], typer.FileText), + (tuple[typer.FileText, typer.FileTextWrite], typer.FileText), + (tuple[typer.FileText, str], None), + (tuple[str, str], None), + ], +) +def test_file_coercion_annotation(annotation: Any, expected: Any) -> None: + assert file_coercion_annotation(annotation) is expected + + +def test_tuple_file_runtime_param(tmp_path: Path) -> None: + first = tmp_path / "first.txt" + second = tmp_path / "second.txt" + first.write_text("first-content\n", encoding="utf-8") + second.write_text("second-content\n", encoding="utf-8") + + app = typer.Typer() + seen: list[str] = [] + + @app.command() + def main(files: tuple[typer.FileText, typer.FileText]): + seen.append(files[0].read()) + seen.append(files[1].read()) + + schema = get_command(app).schema + runtime_param = schema.get_param("files") + assert isinstance(runtime_param, FileRuntimeParam) + assert runtime_param.file_annotation is typer.FileText + + result = runner.invoke(app, [str(first), str(second)]) + assert result.exit_code == 0, result.output + assert seen == ["first-content\n", "second-content\n"] diff --git a/typer/_click/types.py b/typer/_click/types.py index b83121778c..9672f1dcdd 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -1,19 +1,13 @@ -import os from collections.abc import Sequence from typing import ( - IO, TYPE_CHECKING, Any, ClassVar, NoReturn, - TypeGuard, Union, - cast, ) -from ._compat import open_stream from .exceptions import BadParameter -from .utils import LazyFile, format_filename, safecall if TYPE_CHECKING: from .core import Context, Parameter @@ -114,115 +108,6 @@ def arity(self) -> int: # type: ignore raise NotImplementedError() # pragma: no cover -class File(ParamType): - """Declares a parameter to be a file for reading or writing. The file - is automatically closed once the context tears down (after the command - finished working). - - Files can be opened for reading or writing. The special value ``-`` - indicates stdin or stdout depending on the mode. - - By default, the file is opened for reading text data, but it can also be - opened in binary mode or for writing. The encoding parameter can be used - to force a specific encoding. - - The `lazy` flag controls if the file should be opened immediately or upon - first IO. The default is to be non-lazy for standard input and output - streams as well as files opened for reading, `lazy` otherwise. When opening a - file lazily for reading, it is still opened temporarily for validation, but - will not be held open until first IO. lazy is mainly useful when opening - for writing to avoid creating the file until it is needed. - - Files can also be opened atomically in which case all writes go into a - separate file in the same folder and upon completion the file will - be moved over to the original location. This is useful if a file - regularly read by other users is modified. - """ - - name = "filename" - envvar_list_splitter: ClassVar[str] = os.path.pathsep - - def __init__( - self, - mode: str = "r", - encoding: str | None = None, - errors: str | None = "strict", - lazy: bool | None = None, - atomic: bool = False, - ) -> None: - self.mode = mode - self.encoding = encoding - self.errors = errors - self.lazy = lazy - self.atomic = atomic - - def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool: - if self.lazy is not None: - return self.lazy - if os.fspath(value) == "-": - return False - elif "w" in self.mode: - return True - return False - - def convert( - self, - value: str | os.PathLike[str] | IO[Any], - param: Union["Parameter", None], - ctx: Union["Context", None], - ) -> IO[Any]: - if _is_file_like(value): - return value - - value = cast("str | os.PathLike[str]", value) - - try: - lazy = self.resolve_lazy_flag(value) - - if lazy: - lf = LazyFile( - value, self.mode, self.encoding, self.errors, atomic=self.atomic - ) - - if ctx is not None: - ctx.call_on_close(lf.close_intelligently) - - return cast("IO[Any]", lf) - - f, should_close = open_stream( - value, self.mode, self.encoding, self.errors, atomic=self.atomic - ) - - # If a context is provided, we automatically close the file - # at the end of the context execution (or flush out). If a - # context does not exist, it's the caller's responsibility to - # properly close the file. This for instance happens when the - # type is used with prompts. - if ctx is not None: - if should_close: - ctx.call_on_close(safecall(f.close)) - else: - ctx.call_on_close(safecall(f.flush)) - - return f - except OSError as e: # pragma: no cover - self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx) - - def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str - ) -> list["CompletionItem"]: - """Return a special completion marker that tells the completion - system to use the shell to provide file path completions. - """ - from .shell_completion import CompletionItem - - return [CompletionItem(incomplete, type="file")] - - -def _is_file_like(value: Any) -> TypeGuard[IO[Any]]: - return hasattr(value, "read") or hasattr(value, "write") - - class Tuple(CompositeParamType): """The default behavior of Click is to apply a type on a value directly. This works well in most cases, except for when `nargs` is set to a fixed diff --git a/typer/adapters.py b/typer/adapters.py index af65e1113e..d6a1a908f5 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -9,13 +9,7 @@ from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter from ._typing import is_literal_type, literal_values -from .models import ( - FileBinaryRead, - FileBinaryWrite, - FileText, - FileTextWrite, - ParameterInfo, -) +from .models import ParameterInfo def _build_datetime_adapter( @@ -220,14 +214,6 @@ def parse_tuple(value: Any) -> tuple[Any, ...]: ) if _needs_typer_path(annotation, parameter_info): return _build_path_adapter(annotation, parameter_info) - if lenient_issubclass(annotation, FileTextWrite): - return _build_file_adapter(parameter_info, mode="w") - if lenient_issubclass(annotation, FileText): - return _build_file_adapter(parameter_info, mode="r") - if lenient_issubclass(annotation, FileBinaryRead): - return _build_file_adapter(parameter_info, mode="rb") - if lenient_issubclass(annotation, FileBinaryWrite): - return _build_file_adapter(parameter_info, mode="wb") return build_leaf_adapter(annotation) @@ -295,18 +281,3 @@ def parse_path(value: Any) -> Any: return typer_path.convert(value, param=None, ctx=None) return TypeAdapter(Annotated[Any, BeforeValidator(parse_path)]) - - -def _build_file_adapter( - parameter_info: ParameterInfo, - *, - mode: str, -) -> TypeAdapter[Any]: - from .param_types import _file_param_type - - file_type = _file_param_type(parameter_info, mode=mode) - - def parse_file(value: Any) -> Any: - return file_type.convert(value, param=None, ctx=None) - - return TypeAdapter(Annotated[Any, BeforeValidator(parse_file)]) diff --git a/typer/core.py b/typer/core.py index 432682fd1e..d42ce2e968 100644 --- a/typer/core.py +++ b/typer/core.py @@ -106,8 +106,6 @@ def _type_cast_runtime_value( raise _click.exceptions.BadParameter( "Value must be an iterable.", ctx=ctx, param=param ) - if isinstance(param.type, types.File): - return param.type(value, param=param, ctx=ctx) if ( isinstance(param.type, param_types.TyperChoice) and param.nargs == 1 diff --git a/typer/param_types.py b/typer/param_types.py index a188ee809c..ddccb8cb68 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import Annotated, Any, ClassVar, Generic, TypeVar, cast +from typing import IO, Annotated, Any, ClassVar, Generic, TypeGuard, TypeVar, cast from uuid import UUID as UUIDType from pydantic import BeforeValidator, TypeAdapter, ValidationError @@ -92,6 +92,18 @@ def datetime_param_type(formats: Sequence[str] | None = None) -> DisplayParamTyp STRING = DisplayParamType(name="text", repr_name="STRING") +class FileDisplayType(DisplayParamType): + envvar_list_splitter = os.path.pathsep + + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + return [CompletionItem(incomplete, type="file")] + + +FILE = FileDisplayType(name="filename", repr_name="File") + + class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -312,16 +324,129 @@ def shell_complete( return [] -def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> types.File: - return types.File( - mode=parameter_info.mode or mode, - encoding=parameter_info.encoding, - errors=parameter_info.errors, - lazy=parameter_info.lazy, - atomic=parameter_info.atomic, +def is_file_annotation(annotation: Any) -> bool: + return ( + lenient_issubclass(annotation, FileTextWrite) + or lenient_issubclass(annotation, FileText) + or lenient_issubclass(annotation, FileBinaryRead) + or lenient_issubclass(annotation, FileBinaryWrite) ) +def file_coercion_annotation(annotation: Any) -> Any | None: + """Return the file marker type when this parameter opens files.""" + from ._typing import get_args as typer_get_args + from ._typing import get_origin as typer_get_origin + + origin = typer_get_origin(annotation) + if origin is list: + args = typer_get_args(annotation) + if args and all(is_file_annotation(arg) for arg in args): + return args[0] + return None + if origin is tuple: + args = typer_get_args(annotation) + if args and all(is_file_annotation(arg) for arg in args): + return args[0] + return None + if is_file_annotation(annotation): + return annotation + return None + + +def resolve_file_mode(parameter_info: ParameterInfo, annotation: Any) -> str: + if parameter_info.mode is not None: + return parameter_info.mode + if lenient_issubclass(annotation, FileBinaryWrite): + return "wb" + if lenient_issubclass(annotation, FileTextWrite): + return "w" + if lenient_issubclass(annotation, FileBinaryRead): + return "rb" + return "r" + + +def _is_file_like(value: Any) -> TypeGuard[IO[Any]]: + return hasattr(value, "read") or hasattr(value, "write") + + +def _resolve_file_lazy_flag( + value: str | os.PathLike[str], + *, + mode: str, + lazy: bool | None, +) -> bool: + if lazy is not None: + return lazy + if os.fspath(value) == "-": + return False + if "w" in mode: + return True + return False + + +def _open_cli_file( + value: str | os.PathLike[str] | IO[Any], + parameter_info: ParameterInfo, + *, + mode: str, + param: Parameter | None = None, + ctx: Context | None = None, +) -> IO[Any]: + if _is_file_like(value): + return value + + value = cast("str | os.PathLike[str]", value) + from ._click._compat import open_stream + from ._click.exceptions import BadParameter + from ._click.utils import LazyFile, format_filename, safecall + + try: + lazy = _resolve_file_lazy_flag( + value, + mode=mode, + lazy=parameter_info.lazy, + ) + + if lazy: + lf = LazyFile( + value, + mode, + parameter_info.encoding, + parameter_info.errors, + atomic=parameter_info.atomic, + ) + + if ctx is not None: + ctx.call_on_close(lf.close_intelligently) + + return cast("IO[Any]", lf) + + f, should_close = open_stream( + value, + mode, + parameter_info.encoding, + parameter_info.errors, + atomic=parameter_info.atomic, + ) + + if ctx is not None: + if should_close: + ctx.call_on_close(safecall(f.close)) + else: + ctx.call_on_close(safecall(f.flush)) + + return f + except OSError as exc: # pragma: no cover + message = f"'{format_filename(value)}': {exc.strerror}" + raise BadParameter(message, ctx=ctx, param=param) from exc + + +def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> FileDisplayType: + del mode, parameter_info + return FILE + + def _ranged_number_param_type( number_class: type[Any], *, diff --git a/typer/schema.py b/typer/schema.py index 85258538f8..47c8001424 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -1,8 +1,9 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Literal +from typing import IO, Any, Literal from pydantic import TypeAdapter, ValidationError @@ -18,7 +19,13 @@ ParamMeta, Required, ) -from .param_types import infer_type_from_default, lenient_issubclass +from .param_types import ( + _open_cli_file, + file_coercion_annotation, + infer_type_from_default, + lenient_issubclass, + resolve_file_mode, +) ParamKind = Literal["option", "argument"] @@ -38,7 +45,7 @@ class DeclaredParam: @dataclass(frozen=True) -class RuntimeParam: +class RuntimeParam(ABC): """Runtime coercion contract for one command parameter.""" name: str @@ -46,7 +53,6 @@ class RuntimeParam: default: Any required: bool annotation: Any - adapter: TypeAdapter[Any] kind: ParamKind multiple: bool nargs: int @@ -62,6 +68,32 @@ def coerce( ) -> Any: if value is None: return None + return self._coerce_value(value, param=param, ctx=ctx) + + @abstractmethod + def _coerce_value( + self, + value: Any, + *, + param: Any | None, + ctx: Any | None, + ) -> Any: + pass + + +@dataclass(frozen=True) +class AdapterRuntimeParam(RuntimeParam): + """Coercion via a Pydantic TypeAdapter.""" + + adapter: TypeAdapter[Any] + + def _coerce_value( + self, + value: Any, + *, + param: Any | None, + ctx: Any | None, + ) -> Any: try: return self.adapter.validate_python(value) except ValidationError as exc: @@ -79,6 +111,35 @@ def coerce( raise BadParameter(str(exc), ctx=ctx, param=param) from exc +@dataclass(frozen=True) +class FileRuntimeParam(RuntimeParam): + """Coercion by opening CLI file paths into IO streams.""" + + file_annotation: Any + + def _coerce_value( + self, + value: Any, + *, + param: Any | None, + ctx: Any | None, + ) -> Any: + mode = resolve_file_mode(self.parameter_info, self.file_annotation) + + def open_one(item: Any) -> IO[Any]: + return _open_cli_file( + item, + self.parameter_info, + mode=mode, + param=param, + ctx=ctx, + ) + + if isinstance(value, (list, tuple)): + return type(value)(open_one(item) for item in value) + return open_one(value) + + @dataclass(frozen=True) class CommandSchema: """Schema for all parameters on a Typer command.""" @@ -201,6 +262,28 @@ def declare_param(param: ParamMeta) -> DeclaredParam: ) +def _runtime_param_fields( + declared: DeclaredParam, + *, + kind: ParamKind, + multiple: bool, + nargs: int, + is_bool_flag: bool, +) -> dict[str, Any]: + return { + "name": declared.name, + "annotation": declared.annotation, + "parameter_info": declared.parameter_info, + "kind": kind, + "multiple": multiple, + "nargs": nargs, + "is_flag": bool(declared.is_flag), + "is_bool_flag": is_bool_flag, + "required": declared.required, + "default": declared.default, + } + + def runtime_param_from_declared( declared: DeclaredParam, *, @@ -209,17 +292,18 @@ def runtime_param_from_declared( nargs: int, is_bool_flag: bool, ) -> RuntimeParam: - adapter = adapters.build_adapter(declared.annotation, declared.parameter_info) - return RuntimeParam( - name=declared.name, - annotation=declared.annotation, - parameter_info=declared.parameter_info, - adapter=adapter, + common = _runtime_param_fields( + declared, kind=kind, multiple=multiple, nargs=nargs, - is_flag=bool(declared.is_flag), is_bool_flag=is_bool_flag, - required=declared.required, - default=declared.default, + ) + file_annotation = file_coercion_annotation(declared.annotation) + if file_annotation is not None: + return FileRuntimeParam(**common, file_annotation=file_annotation) + assert file_coercion_annotation(declared.annotation) is None + return AdapterRuntimeParam( + **common, + adapter=adapters.build_adapter(declared.annotation, declared.parameter_info), ) From 497263028a823eab4e86a7930f17532236f81e23 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 12:35:24 +0200 Subject: [PATCH 34/73] some cleanup and refactor --- typer/_typing.py | 15 ++- typer/adapters.py | 260 +++++++++++++++++++++---------------------- typer/param_types.py | 4 +- 3 files changed, 138 insertions(+), 141 deletions(-) diff --git a/typer/_typing.py b/typer/_typing.py index 218f674c22..b45965af90 100644 --- a/typer/_typing.py +++ b/typer/_typing.py @@ -1,8 +1,7 @@ -# Copied from pydantic 1.9.2 (the latest version to support python 3.6.) -# https://github.com/pydantic/pydantic/blob/v1.9.2/pydantic/typing.py -# Reduced drastically to only include Typer-specific 3.9+ functionality +# Adapted from pydantic 1.9.2 # mypy: ignore-errors +import numbers import types from collections.abc import Callable from typing import ( @@ -26,6 +25,7 @@ def is_union(tp: type[Any] | None) -> bool: "is_callable_type", "is_literal_type", "all_literal_values", + "is_number_type", "is_union", "Annotated", "Literal", @@ -52,6 +52,15 @@ def is_callable_type(type_: type[Any]) -> bool: return type_ is Callable or get_origin(type_) is Callable +def is_number_type(type_: Any) -> bool: + return ( + isinstance(type_, type) + and type_ is not bool + and type_ is not complex + and issubclass(type_, numbers.Number) + ) + + def is_literal_type(type_: type[Any]) -> bool: return get_origin(type_) is Literal diff --git a/typer/adapters.py b/typer/adapters.py index d6a1a908f5..a45a4b0701 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -4,17 +4,123 @@ from enum import Enum from pathlib import Path from typing import Annotated, Any, get_args, get_origin -from uuid import UUID as UUIDType from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter -from ._typing import is_literal_type, literal_values +from ._typing import is_literal_type, is_number_type, literal_values from .models import ParameterInfo +from .param_types import TyperPath, _needs_typer_path, lenient_issubclass -def _build_datetime_adapter( - formats: Sequence[str] | None, -) -> TypeAdapter[datetime]: +def build_adapter( + annotation: Any, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any]: + """Build a Pydantic TypeAdapter for a parameter annotation and metadata.""" + if parameter_info.parser is not None: + return _build_parser_adapter(parameter_info.parser) + + origin = get_origin(annotation) + if origin is list: + (item_type,) = get_args(annotation) + item_adapter = build_adapter(item_type, parameter_info) + + def parse_list(value: Any) -> list[Any]: + if not isinstance(value, (list, tuple)): + value = (value,) + return [ + None if item is None else item_adapter.validate_python(item) + for item in value + ] + + return TypeAdapter(Annotated[list[Any], BeforeValidator(parse_list)]) + + if origin is tuple: + item_types = get_args(annotation) + item_adapters = [ + build_adapter(item_type, parameter_info) for item_type in item_types + ] + + def parse_tuple(value: Any) -> tuple[Any, ...]: + if not isinstance(value, (list, tuple)): + raise ValueError("value is not a valid tuple") + if len(value) != len(item_adapters): + raise ValueError( + f"{len(item_adapters)} values are required, but {len(value)} given." + ) + return tuple( + None if item is None else adapter.validate_python(item) + for adapter, item in zip(item_adapters, value, strict=False) + ) + + return TypeAdapter(Annotated[tuple[Any, ...], BeforeValidator(parse_tuple)]) + + if is_number_type(annotation): + return build_leaf_adapter( + annotation, + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + ) + if annotation is datetime: + return build_leaf_adapter(annotation, formats=parameter_info.formats) + + if lenient_issubclass(annotation, Enum): + return _build_choice_adapter( + list(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + if is_literal_type(annotation): + return _build_choice_adapter( + literal_values(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + if _needs_typer_path(annotation, parameter_info): + return _build_path_adapter(annotation, parameter_info) + return build_leaf_adapter(annotation) + + +def build_leaf_adapter( + annotation: Any, + *, + min: float | None = None, + max: float | None = None, + clamp: bool = False, + formats: Sequence[str] | None = None, +) -> TypeAdapter[Any]: + """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" + if annotation is datetime and formats is not None: + return _build_datetime_adapter(formats) + + if is_number_type(annotation): + if clamp: + # Use AfterValidator so it runs after coercion + return TypeAdapter( + Annotated[ + annotation, + AfterValidator(_make_number_clamp_validator(annotation, min, max)), + ] + ) + else: + field_kwargs: dict[str, Any] = {} + if min is not None: + field_kwargs["ge"] = min + if max is not None: + field_kwargs["le"] = max + if field_kwargs: + return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) + + if annotation is bool: + return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) + + if annotation is str: + return TypeAdapter(Annotated[str, BeforeValidator(_parse_cli_str)]) + + return TypeAdapter(annotation) + + +# DATE # +def _build_datetime_adapter(formats: Sequence[str] | None) -> TypeAdapter[datetime]: if formats is None: return TypeAdapter(datetime) @@ -32,10 +138,13 @@ def parse_datetime(value: Any) -> datetime: return TypeAdapter(Annotated[datetime, BeforeValidator(parse_datetime)]) -_bool_adapter = TypeAdapter(bool) +# STRING / BYTES # +def _parse_cli_str(value: Any) -> str: + """Coerce a CLI value to str""" + return str(_decode_cli_bytes(value)) -def decode_cli_bytes(value: Any) -> Any: +def _decode_cli_bytes(value: Any) -> Any: """Decode bytes from argv/env; leave other values unchanged.""" if isinstance(value, bytes): from ._click import _compat @@ -54,22 +163,17 @@ def decode_cli_bytes(value: Any) -> Any: return value -def _parse_cli_str(value: Any) -> str: - """Coerce a CLI value to str""" - return str(decode_cli_bytes(value)) - - -def _parse_cli_bool(value: Any) -> bool: - if isinstance(value, bool): - return value +# BOOL # +def _parse_cli_bool(value: Any) -> Any: if isinstance(value, str): stripped = value.strip() if stripped == "": return False - value = stripped - return _bool_adapter.validate_python(value) + return stripped + return value +# NUMBER # def _make_number_clamp_validator( number_class: type[Any], min: float | None, @@ -85,44 +189,7 @@ def clamp_number(value: Any) -> Any: return clamp_number -def build_leaf_adapter( - annotation: Any, - *, - min: float | None = None, - max: float | None = None, - clamp: bool = False, - formats: Sequence[str] | None = None, -) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" - if annotation is datetime and formats is not None: - return _build_datetime_adapter(formats) - - if annotation is int or annotation is float: - if clamp: - # Use AfterValidator so it runs after coercion - return TypeAdapter( - Annotated[ - annotation, - AfterValidator(_make_number_clamp_validator(annotation, min, max)), - ] - ) - field_kwargs: dict[str, Any] = {} - if min is not None: - field_kwargs["ge"] = min - if max is not None: - field_kwargs["le"] = max - if field_kwargs: - return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) - - if annotation is bool: - return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) - - if annotation is str: - return TypeAdapter(Annotated[str, BeforeValidator(_parse_cli_str)]) - - return TypeAdapter(annotation) - - +# PARSER # def _build_parser_adapter(parser: Callable[[Any], Any]) -> TypeAdapter[Any]: def parse_with_parser(value: Any) -> Any: try: @@ -138,85 +205,7 @@ def parse_with_parser(value: Any) -> Any: return TypeAdapter(Annotated[Any, BeforeValidator(parse_with_parser)]) -def build_adapter( - annotation: Any, - parameter_info: ParameterInfo, -) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a parameter annotation and metadata.""" - if parameter_info.parser is not None: - return _build_parser_adapter(parameter_info.parser) - - origin = get_origin(annotation) - if origin is list: - (item_type,) = get_args(annotation) - item_adapter = build_adapter(item_type, parameter_info) - - def parse_list(value: Any) -> list[Any]: - if not isinstance(value, (list, tuple)): - value = (value,) - return [ - None if item is None else item_adapter.validate_python(item) - for item in value - ] - - return TypeAdapter(Annotated[list[Any], BeforeValidator(parse_list)]) - - if origin is tuple: - item_types = get_args(annotation) - item_adapters = [ - build_adapter(item_type, parameter_info) for item_type in item_types - ] - - def parse_tuple(value: Any) -> tuple[Any, ...]: - if not isinstance(value, (list, tuple)): - raise ValueError("value is not a valid tuple") - if len(value) != len(item_adapters): - raise ValueError( - f"{len(item_adapters)} values are required, but {len(value)} given." - ) - return tuple( - None if item is None else adapter.validate_python(item) - for adapter, item in zip(item_adapters, value, strict=False) - ) - - return TypeAdapter(Annotated[tuple[Any, ...], BeforeValidator(parse_tuple)]) - - if annotation is int or annotation is float: - return build_leaf_adapter( - annotation, - min=parameter_info.min, - max=parameter_info.max, - clamp=parameter_info.clamp, - ) - if annotation is datetime: - return build_leaf_adapter(annotation, formats=parameter_info.formats) - if annotation is bool: - return build_leaf_adapter(bool) - if annotation is str: - return build_leaf_adapter(str) - if annotation is UUIDType: - return build_leaf_adapter(UUIDType) - - from .param_types import ( - _needs_typer_path, - lenient_issubclass, - ) - - if lenient_issubclass(annotation, Enum): - return _build_choice_adapter( - list(annotation), - case_sensitive=parameter_info.case_sensitive, - ) - if is_literal_type(annotation): - return _build_choice_adapter( - literal_values(annotation), - case_sensitive=parameter_info.case_sensitive, - ) - if _needs_typer_path(annotation, parameter_info): - return _build_path_adapter(annotation, parameter_info) - return build_leaf_adapter(annotation) - - +# CHOICE # def _normalize_choice_value( choice_or_value: Any, *, @@ -256,12 +245,11 @@ def parse_choice(value: Any) -> Any: return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) +# PATH # def _build_path_adapter( annotation: Any, parameter_info: ParameterInfo, ) -> TypeAdapter[Any]: - from .param_types import TyperPath, lenient_issubclass - path_type = parameter_info.path_type if path_type is None and lenient_issubclass(annotation, Path): path_type = annotation diff --git a/typer/param_types.py b/typer/param_types.py index ddccb8cb68..9ee0d18fd1 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -9,7 +9,7 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError -from . import _click, adapters +from . import _click from ._click import Context, Parameter, types from ._click.shell_completion import CompletionItem from ._typing import is_literal_type, literal_values @@ -254,7 +254,7 @@ def _parse_path_value( return value if isinstance(value, (str, os.PathLike)): try: - return adapters.build_leaf_adapter(self.type).validate_python(value) + return TypeAdapter(self.type).validate_python(value) except ValidationError as exc: self.fail(_get_error_msg(exc), param, ctx) return value From 9d9718441cd9e0d3b29b8b65a5c0e078e03bfe2b Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 13:08:00 +0200 Subject: [PATCH 35/73] cleanup inline imports --- typer/adapters.py | 3 +-- typer/completion.py | 2 -- typer/param_types.py | 40 +++++++++++++--------------------------- typer/schema.py | 6 ++---- 4 files changed, 16 insertions(+), 35 deletions(-) diff --git a/typer/adapters.py b/typer/adapters.py index a45a4b0701..469e5943f9 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -7,6 +7,7 @@ from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter +from ._click import _compat from ._typing import is_literal_type, is_number_type, literal_values from .models import ParameterInfo from .param_types import TyperPath, _needs_typer_path, lenient_issubclass @@ -147,8 +148,6 @@ def _parse_cli_str(value: Any) -> str: def _decode_cli_bytes(value: Any) -> Any: """Decode bytes from argv/env; leave other values unchanged.""" if isinstance(value, bytes): - from ._click import _compat - enc = _compat._get_argv_encoding() try: return value.decode(enc) diff --git a/typer/completion.py b/typer/completion.py index f63692ddf3..c6b92ad00d 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -109,8 +109,6 @@ def shell_complete( complete_var: str, instruction: str, ) -> int: - from . import _click - if "_" not in instruction: _click.echo("Invalid completion instruction.", err=True) return 1 diff --git a/typer/param_types.py b/typer/param_types.py index 9ee0d18fd1..f416dcb8cd 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -9,9 +9,13 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError -from . import _click from ._click import Context, Parameter, types +from ._click._compat import open_stream +from ._click.exceptions import BadParameter from ._click.shell_completion import CompletionItem +from ._click.utils import LazyFile, format_filename, safecall +from ._typing import get_args as typer_get_args +from ._typing import get_origin as typer_get_origin from ._typing import is_literal_type, literal_values from .models import ( AnyType, @@ -103,6 +107,8 @@ def shell_complete( FILE = FileDisplayType(name="filename", repr_name="File") +CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) + class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -294,13 +300,13 @@ def convert( if not self.exists: return self.coerce_path_result(rv) self.fail( - f"{self.name.title()} {_click.utils.format_filename(value)!r} does not exist.", + f"{self.name.title()} {format_filename(value)!r} does not exist.", param, ctx, ) name = self.name.title() - loc = repr(_click.utils.format_filename(value)) + loc = repr(format_filename(value)) if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail(f"{name} {loc} is a file.", param, ctx) @@ -325,19 +331,11 @@ def shell_complete( def is_file_annotation(annotation: Any) -> bool: - return ( - lenient_issubclass(annotation, FileTextWrite) - or lenient_issubclass(annotation, FileText) - or lenient_issubclass(annotation, FileBinaryRead) - or lenient_issubclass(annotation, FileBinaryWrite) - ) + return lenient_issubclass(annotation, CLI_FILE_TYPES) def file_coercion_annotation(annotation: Any) -> Any | None: """Return the file marker type when this parameter opens files.""" - from ._typing import get_args as typer_get_args - from ._typing import get_origin as typer_get_origin - origin = typer_get_origin(annotation) if origin is list: args = typer_get_args(annotation) @@ -397,9 +395,6 @@ def _open_cli_file( return value value = cast("str | os.PathLike[str]", value) - from ._click._compat import open_stream - from ._click.exceptions import BadParameter - from ._click.utils import LazyFile, format_filename, safecall try: lazy = _resolve_file_lazy_flag( @@ -442,8 +437,7 @@ def _open_cli_file( raise BadParameter(message, ctx=ctx, param=param) from exc -def _file_param_type(parameter_info: ParameterInfo, *, mode: str) -> FileDisplayType: - del mode, parameter_info +def _file_param_type() -> FileDisplayType: return FILE @@ -536,8 +530,6 @@ def cli_param_type( is_tuple: bool, ) -> types.ParamType: """Defer the "type" for metavar/help.""" - from ._typing import get_args as typer_get_args - if is_tuple: type_args = typer_get_args(annotation) return resolve_param_type(tuple(type_args), parameter_info=parameter_info) @@ -605,12 +597,6 @@ def param_type_from_annotation( ) if annotation is str: return STRING - if lenient_issubclass(annotation, FileTextWrite): - return _file_param_type(parameter_info, mode="w") - if lenient_issubclass(annotation, FileText): - return _file_param_type(parameter_info, mode="r") - if lenient_issubclass(annotation, FileBinaryRead): - return _file_param_type(parameter_info, mode="rb") - if lenient_issubclass(annotation, FileBinaryWrite): - return _file_param_type(parameter_info, mode="wb") + if lenient_issubclass(annotation, CLI_FILE_TYPES): + return _file_param_type() return None diff --git a/typer/schema.py b/typer/schema.py index 47c8001424..9bfeef9020 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -8,6 +8,7 @@ from pydantic import TypeAdapter, ValidationError from . import adapters +from ._click.exceptions import BadParameter from ._typing import get_args as typer_get_args from ._typing import get_origin as typer_get_origin from ._typing import is_union @@ -20,6 +21,7 @@ Required, ) from .param_types import ( + _get_error_msg, _open_cli_file, file_coercion_annotation, infer_type_from_default, @@ -99,14 +101,11 @@ def _coerce_value( except ValidationError as exc: if param is None: raise - from ._click.exceptions import BadParameter - from .param_types import _get_error_msg raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc except ValueError as exc: if param is None: raise - from ._click.exceptions import BadParameter raise BadParameter(str(exc), ctx=ctx, param=param) from exc @@ -302,7 +301,6 @@ def runtime_param_from_declared( file_annotation = file_coercion_annotation(declared.annotation) if file_annotation is not None: return FileRuntimeParam(**common, file_annotation=file_annotation) - assert file_coercion_annotation(declared.annotation) is None return AdapterRuntimeParam( **common, adapter=adapters.build_adapter(declared.annotation, declared.parameter_info), From bab9056618d9976a4aa85b77245a42f86e10d458 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 14:56:38 +0200 Subject: [PATCH 36/73] TyperTuple into param_types --- tests/test_type_conversion.py | 5 +-- typer/_click/types.py | 59 +++-------------------------------- typer/main.py | 6 ++-- typer/param_types.py | 32 ++++++++++++++++++- 4 files changed, 41 insertions(+), 61 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 4ef5e06e88..5c34dbbdaa 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -13,6 +13,7 @@ INT, STRING, TyperPath, + TyperTuple, resolve_param_type, ) from typer.testing import CliRunner @@ -387,11 +388,11 @@ def test_convert_type(): # tuples tuple_type = resolve_param_type((str, int)) - assert isinstance(tuple_type, _click.types.Tuple) + assert isinstance(tuple_type, TyperTuple) assert [type(item) for item in tuple_type.types] == [type(STRING), type(INT)] guessed_tuple = resolve_param_type(None, default=[(1, "x")]) - assert isinstance(guessed_tuple, _click.types.Tuple) + assert isinstance(guessed_tuple, TyperTuple) assert [type(item) for item in guessed_tuple.types] == [ type(INT), type(STRING), diff --git a/typer/_click/types.py b/typer/_click/types.py index 9672f1dcdd..0d43fac7dd 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -33,9 +33,12 @@ class ParamType: """ is_composite: ClassVar[bool] = False - arity: ClassVar[int] = 1 name: str + @property + def arity(self) -> int: + return 1 + # if a list of this type is expected and the value is pulled from a # string environment variable, this is what splits it up. `None` # means any whitespace. For all parameters the general rule is that @@ -98,57 +101,3 @@ def shell_complete( completions as well. """ return [] - - -class CompositeParamType(ParamType): - is_composite = True - - @property - def arity(self) -> int: # type: ignore - raise NotImplementedError() # pragma: no cover - - -class Tuple(CompositeParamType): - """The default behavior of Click is to apply a type on a value directly. - This works well in most cases, except for when `nargs` is set to a fixed - count and different types should be used for different items. In this - case the `Tuple` type can be used. This type can only be used - if `nargs` is set to a fixed number. - - For more information see `tuple-type`. - - This can be selected by using a Python tuple literal as a type. - """ - - def __init__(self, types: Sequence[type[Any] | ParamType]) -> None: - from ..param_types import resolve_param_type - - self.types: Sequence[ParamType] = [ - item if isinstance(item, ParamType) else resolve_param_type(item) - for item in types - ] - - @property - def name(self) -> str: # type: ignore[override] - return f"<{' '.join(ty.name for ty in self.types)}>" - - @property - def arity(self) -> int: # type: ignore - return len(self.types) - - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - len_type = len(self.types) - len_value = len(value) - - if len_value != len_type: - self.fail( - f"{len_type} values are required, but {len_value} given.", - param=param, - ctx=ctx, - ) - - return tuple( - ty(x, param, ctx) for ty, x in zip(self.types, value, strict=False) - ) diff --git a/typer/main.py b/typer/main.py index 40dcd06422..53fcf47141 100644 --- a/typer/main.py +++ b/typer/main.py @@ -15,7 +15,7 @@ from . import _click from ._click.globals import get_current_context -from ._click.types import ParamType, Tuple +from ._click.types import ParamType from ._typing import get_args, get_origin from .completion import get_completion_inspect_parameters from .core import ( @@ -38,7 +38,7 @@ ParamMeta, TyperInfo, ) -from .param_types import cli_param_type, lenient_issubclass +from .param_types import TyperTuple, cli_param_type, lenient_issubclass from .schema import CommandSchema, declare_param, runtime_param_from_declared from .utils import get_params_from_function @@ -1535,7 +1535,7 @@ def get_param( if is_list: nargs = -1 binding_nargs = nargs if nargs is not None else 1 - if isinstance(parameter_type, Tuple): + if isinstance(parameter_type, TyperTuple): binding_nargs = parameter_type.arity runtime_param = runtime_param_from_declared( declared, diff --git a/typer/param_types.py b/typer/param_types.py index f416dcb8cd..a7774a0fb9 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -110,6 +110,36 @@ def shell_complete( CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) +class TyperTuple(types.ParamType): + """Metavar and nargs information for tuple parameters.""" + + is_composite = True + + def __init__(self, element_types: Sequence[types.ParamType]) -> None: + self.types: tuple[types.ParamType, ...] = tuple(element_types) + self.name = f"<{' '.join(t.name for t in self.types)}>" + + @property + def arity(self) -> int: + return len(self.types) + + def convert( + self, + value: Any, + param: Parameter | None, + ctx: Context | None, + ) -> Any: + len_type = len(self.types) + len_value = len(value) + if len_value != len_type: + self.fail( + f"{len_type} values are required, but {len_value} given.", + param=param, + ctx=ctx, + ) + return value + + class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -496,7 +526,7 @@ def resolve_param_type( annotation=element, parameter_info=parameter_info ) ) - return types.Tuple(element_types) + return TyperTuple(element_types) if isinstance(annotation, types.ParamType): return annotation From 0809456df23dcf90c58934c6c4fe755d2415f0ca Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 15:27:21 +0200 Subject: [PATCH 37/73] ChoiceRuntimeParam --- tests/test_schema.py | 19 ++++++++-- typer/adapters.py | 42 +++++++--------------- typer/core.py | 6 ---- typer/param_types.py | 83 ++++++++++++++++++++++++++++---------------- typer/schema.py | 37 ++++++++++++++++++++ 5 files changed, 119 insertions(+), 68 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 89a37ca494..1de0cd79c4 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,8 +6,8 @@ import typer from typer.adapters import build_adapter from typer.main import get_command -from typer.param_types import file_coercion_annotation -from typer.schema import FileRuntimeParam +from typer.param_types import choice_coercion_annotation, file_coercion_annotation +from typer.schema import ChoiceRuntimeParam, FileRuntimeParam from typer.testing import CliRunner runner = CliRunner() @@ -88,6 +88,7 @@ def main(color: Color): schema = get_command(app).schema runtime_param = schema.get_param("color") assert runtime_param is not None + assert isinstance(runtime_param, ChoiceRuntimeParam) assert runtime_param.coerce("red") is Color.RED @@ -170,6 +171,20 @@ def test_file_coercion_annotation(annotation: Any, expected: Any) -> None: assert file_coercion_annotation(annotation) is expected +def test_choice_coercion_annotation() -> None: + class Color(str, Enum): + RED = "red" + BLUE = "blue" + + info = typer.models.OptionInfo() + result = choice_coercion_annotation(Color, info) + assert result is not None + choices, case_sensitive = result + assert Color.RED in choices + assert case_sensitive is True + assert choice_coercion_annotation(str, info) is None + + def test_tuple_file_runtime_param(tmp_path: Path) -> None: first = tmp_path / "first.txt" second = tmp_path / "second.txt" diff --git a/typer/adapters.py b/typer/adapters.py index 469e5943f9..c8bb61f967 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -10,7 +10,12 @@ from ._click import _compat from ._typing import is_literal_type, is_number_type, literal_values from .models import ParameterInfo -from .param_types import TyperPath, _needs_typer_path, lenient_issubclass +from .param_types import ( + TyperPath, + _needs_typer_path, + coerce_cli_choice, + lenient_issubclass, +) def build_adapter( @@ -205,41 +210,18 @@ def parse_with_parser(value: Any) -> Any: # CHOICE # -def _normalize_choice_value( - choice_or_value: Any, - *, - case_sensitive: bool, - ctx: Any | None, -) -> str: - if isinstance(choice_or_value, Enum): - normed = str(choice_or_value.value) - else: - normed = str(choice_or_value) - if ctx is not None and ctx.token_normalize_func is not None: - normed = ctx.token_normalize_func(normed) - if not case_sensitive: - normed = normed.casefold() - return normed - - def _build_choice_adapter( choices: Sequence[Any], *, case_sensitive: bool, ) -> TypeAdapter[Any]: - def normalize(choice: Any) -> str: - return _normalize_choice_value(choice, case_sensitive=case_sensitive, ctx=None) - - mapping = {normalize(choice): choice for choice in choices} - def parse_choice(value: Any) -> Any: - if any(isinstance(choice, Enum) and value is choice for choice in choices): - return value - key = normalize(value) - if key in mapping: - return mapping[key] - choices_str = ", ".join(map(repr, mapping.values())) - raise ValueError(f"{value!r} is not one of {choices_str}.") + return coerce_cli_choice( + value, + choices=choices, + case_sensitive=case_sensitive, + ctx=None, + ) return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) diff --git a/typer/core.py b/typer/core.py index d42ce2e968..81c8d2bf90 100644 --- a/typer/core.py +++ b/typer/core.py @@ -106,12 +106,6 @@ def _type_cast_runtime_value( raise _click.exceptions.BadParameter( "Value must be an iterable.", ctx=ctx, param=param ) - if ( - isinstance(param.type, param_types.TyperChoice) - and param.nargs == 1 - and not param.multiple - ): - return param.type(value, param=param, ctx=ctx) return runtime_param.coerce(value, param=param, ctx=ctx) diff --git a/typer/param_types.py b/typer/param_types.py index a7774a0fb9..65cac59eef 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -4,10 +4,10 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import IO, Annotated, Any, ClassVar, Generic, TypeGuard, TypeVar, cast +from typing import IO, Any, ClassVar, Generic, TypeGuard, TypeVar, cast from uuid import UUID as UUIDType -from pydantic import BeforeValidator, TypeAdapter, ValidationError +from pydantic import TypeAdapter, ValidationError from ._click import Context, Parameter, types from ._click._compat import open_stream @@ -110,6 +110,52 @@ def shell_complete( CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) +def normalize_choice_value( + choice: Any, + *, + case_sensitive: bool, + ctx: Context | None, +) -> str: + normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(normed_value) + if not case_sensitive: + normed_value = normed_value.casefold() + return normed_value + + +def coerce_cli_choice( + value: Any, + *, + choices: Sequence[Any], + case_sensitive: bool, + ctx: Context | None, +) -> Any: + if any(isinstance(choice, Enum) and value is choice for choice in choices): + return value + normalized_mapping = { + choice: normalize_choice_value(choice, case_sensitive=case_sensitive, ctx=ctx) + for choice in choices + } + normed_value = normalize_choice_value(value, case_sensitive=case_sensitive, ctx=ctx) + for original, normalized in normalized_mapping.items(): + if normalized == normed_value: + return original + choices_str = ", ".join(map(repr, normalized_mapping.values())) + raise ValueError(f"{value!r} is not one of {choices_str}.") + + +def choice_coercion_annotation( + annotation: Any, + parameter_info: ParameterInfo, +) -> tuple[tuple[Any, ...], bool] | None: + if lenient_issubclass(annotation, Enum): + return tuple(annotation), parameter_info.case_sensitive + if is_literal_type(annotation): + return literal_values(annotation), parameter_info.case_sensitive + return None + + class TyperTuple(types.ParamType): """Metavar and nargs information for tuple parameters.""" @@ -165,15 +211,9 @@ def _normalized_mapping( } def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: - normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) - - if ctx is not None and ctx.token_normalize_func is not None: - normed_value = ctx.token_normalize_func(normed_value) - - if not self.case_sensitive: - normed_value = normed_value.casefold() - - return normed_value + return normalize_choice_value( + choice, case_sensitive=self.case_sensitive, ctx=ctx + ) def get_metavar(self, param: Parameter, ctx: Context) -> str | None: if param.param_type_name == "option" and not param.show_choices: # type: ignore @@ -198,25 +238,8 @@ def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) return f"Choose from:\n\t{choices}" - def _build_class_adapter(self, ctx: Context | None) -> TypeAdapter[ParamTypeValue]: - normalized_mapping = self._normalized_mapping(ctx=ctx) - - def parse_choice(value: Any) -> ParamTypeValue: - normed_value = self.normalize_choice(choice=value, ctx=ctx) - for original, normalized in normalized_mapping.items(): - if normalized == normed_value: - return original - raise ValueError(self.get_invalid_choice_message(value=value, ctx=ctx)) - - return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) - - def convert( - self, value: Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: - try: - return self._build_class_adapter(ctx).validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param=param, ctx=ctx) + def convert(self, value: Any, param: Parameter | None, ctx: Context | None) -> Any: + return value def get_invalid_choice_message(self, value: Any, ctx: Context | None) -> str: """Get the error message when the given choice is invalid.""" diff --git a/typer/schema.py b/typer/schema.py index 9bfeef9020..cd5831a987 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -23,6 +23,8 @@ from .param_types import ( _get_error_msg, _open_cli_file, + choice_coercion_annotation, + coerce_cli_choice, file_coercion_annotation, infer_type_from_default, lenient_issubclass, @@ -139,6 +141,33 @@ def open_one(item: Any) -> IO[Any]: return open_one(value) +@dataclass(frozen=True) +class ChoiceRuntimeParam(RuntimeParam): + """Coercion for enum and literal choice parameters.""" + + choices: tuple[Any, ...] + case_sensitive: bool + + def _coerce_value( + self, + value: Any, + *, + param: Any | None, + ctx: Any | None, + ) -> Any: + try: + return coerce_cli_choice( + value, + choices=self.choices, + case_sensitive=self.case_sensitive, + ctx=ctx, + ) + except ValueError as exc: + if param is None: + raise + raise BadParameter(str(exc), ctx=ctx, param=param) from exc + + @dataclass(frozen=True) class CommandSchema: """Schema for all parameters on a Typer command.""" @@ -301,6 +330,14 @@ def runtime_param_from_declared( file_annotation = file_coercion_annotation(declared.annotation) if file_annotation is not None: return FileRuntimeParam(**common, file_annotation=file_annotation) + choice = choice_coercion_annotation(declared.annotation, declared.parameter_info) + if choice is not None: + choices, case_sensitive = choice + return ChoiceRuntimeParam( + **common, + choices=choices, + case_sensitive=case_sensitive, + ) return AdapterRuntimeParam( **common, adapter=adapters.build_adapter(declared.annotation, declared.parameter_info), From 80495f9a0b07ad34b3f3b7e69f1b80b0c01805bb Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 16:38:37 +0200 Subject: [PATCH 38/73] ChoiceRuntimeParam --- tests/test_schema.py | 40 ++++++++- typer/adapters.py | 31 +++---- typer/param_types.py | 189 ++++++++++++++++++++++++++----------------- typer/schema.py | 30 +++++++ 4 files changed, 193 insertions(+), 97 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 1de0cd79c4..fe4bc5cd11 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,8 +6,16 @@ import typer from typer.adapters import build_adapter from typer.main import get_command -from typer.param_types import choice_coercion_annotation, file_coercion_annotation -from typer.schema import ChoiceRuntimeParam, FileRuntimeParam +from typer.param_types import ( + choice_coercion_annotation, + file_coercion_annotation, + path_uses_coercion, +) +from typer.schema import ( + ChoiceRuntimeParam, + FileRuntimeParam, + PathRuntimeParam, +) from typer.testing import CliRunner runner = CliRunner() @@ -185,6 +193,34 @@ class Color(str, Enum): assert choice_coercion_annotation(str, info) is None +def test_path_uses_coercion() -> None: + info = typer.models.OptionInfo() + assert path_uses_coercion(Path, info) is True + assert path_uses_coercion(str, info) is False + assert path_uses_coercion(str, typer.models.OptionInfo(resolve_path=True)) is True + + +def test_path_runtime_param(tmp_path: Path) -> None: + target = tmp_path / "config.txt" + target.write_text("hello\n", encoding="utf-8") + + app = typer.Typer() + seen: list[Path] = [] + + @app.command() + def main(config: Path = typer.Option(..., exists=True)): + seen.append(config) + + schema = get_command(app).schema + runtime_param = schema.get_param("config") + assert isinstance(runtime_param, PathRuntimeParam) + assert runtime_param.path_type is Path + + result = runner.invoke(app, ["--config", str(target)]) + assert result.exit_code == 0, result.output + assert seen == [target] + + def test_tuple_file_runtime_param(tmp_path: Path) -> None: first = tmp_path / "first.txt" second = tmp_path / "second.txt" diff --git a/typer/adapters.py b/typer/adapters.py index c8bb61f967..ca50fef133 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -2,7 +2,6 @@ from collections.abc import Callable, Sequence from datetime import datetime from enum import Enum -from pathlib import Path from typing import Annotated, Any, get_args, get_origin from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter @@ -11,10 +10,11 @@ from ._typing import is_literal_type, is_number_type, literal_values from .models import ParameterInfo from .param_types import ( - TyperPath, _needs_typer_path, coerce_cli_choice, + coerce_cli_path, lenient_issubclass, + resolve_path_type, ) @@ -82,7 +82,7 @@ def parse_tuple(value: Any) -> tuple[Any, ...]: case_sensitive=parameter_info.case_sensitive, ) if _needs_typer_path(annotation, parameter_info): - return _build_path_adapter(annotation, parameter_info) + return build_path_adapter(annotation, parameter_info) return build_leaf_adapter(annotation) @@ -227,26 +227,19 @@ def parse_choice(value: Any) -> Any: # PATH # -def _build_path_adapter( +def build_path_adapter( annotation: Any, parameter_info: ParameterInfo, ) -> TypeAdapter[Any]: - path_type = parameter_info.path_type - if path_type is None and lenient_issubclass(annotation, Path): - path_type = annotation - - typer_path = TyperPath( - exists=parameter_info.exists, - file_okay=parameter_info.file_okay, - dir_okay=parameter_info.dir_okay, - writable=parameter_info.writable, - readable=parameter_info.readable, - resolve_path=parameter_info.resolve_path, - allow_dash=parameter_info.allow_dash, - path_type=path_type, - ) + path_type = resolve_path_type(annotation, parameter_info) def parse_path(value: Any) -> Any: - return typer_path.convert(value, param=None, ctx=None) + return coerce_cli_path( + value, + parameter_info, + path_type=path_type, + param=None, + ctx=None, + ) return TypeAdapter(Annotated[Any, BeforeValidator(parse_path)]) diff --git a/typer/param_types.py b/typer/param_types.py index 65cac59eef..d03412354d 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -291,7 +291,7 @@ def __init__( self.writable = writable self.resolve_path = resolve_path self.allow_dash = allow_dash - self.type = path_type + self.path_type = path_type if self.file_okay and not self.dir_okay: self.name = "file" @@ -300,87 +300,136 @@ def __init__( else: self.name = "path" - def _parse_path_value( + def convert( self, value: Any, param: Parameter | None, ctx: Context | None, ) -> Any: - if self.type is None or self.type is str or self.type is bytes: - return value - if isinstance(self.type, type) and issubclass(self.type, Path): - if isinstance(value, self.type): - return value - if isinstance(value, (str, os.PathLike)): - try: - return TypeAdapter(self.type).validate_python(value) - except ValidationError as exc: - self.fail(_get_error_msg(exc), param, ctx) return value - def coerce_path_result( - self, value: str | os.PathLike[str] - ) -> str | bytes | os.PathLike[str]: - if self.type is not None and not isinstance(value, self.type): - if ( - self.type is str - ): # pragma: no cover # TODO: perhaps this branch can't be hit and should be removed - return os.fsdecode(value) - elif self.type is bytes: - return os.fsencode(value) - else: - return cast("os.PathLike[str]", self.type(value)) + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + """Return an empty list so that the autocompletion functionality + will work properly from the commandline. + """ + return [] - return value - def convert( - self, - value: str | os.PathLike[str], - param: Parameter | None, - ctx: Context | None, - ) -> str | bytes | os.PathLike[str]: - rv = self._parse_path_value(value, param, ctx) +def _path_display_name(parameter_info: ParameterInfo) -> str: + if parameter_info.file_okay and not parameter_info.dir_okay: + return "file" + if parameter_info.dir_okay and not parameter_info.file_okay: + return "directory" + return "path" + + +def resolve_path_type( + annotation: Any, + parameter_info: ParameterInfo, +) -> type[Any] | None: + path_type = parameter_info.path_type + if path_type is None and lenient_issubclass(annotation, Path): + path_type = annotation + return path_type + - is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") +def path_uses_coercion(annotation: Any, parameter_info: ParameterInfo) -> bool: + return _needs_typer_path(annotation, parameter_info) - if not is_dash: - if self.resolve_path: - rv = os.path.realpath(rv) +def _coerce_path_result( + value: str | os.PathLike[str], + path_type: type[Any] | None, +) -> str | bytes | os.PathLike[str]: + if path_type is not None and not isinstance(value, path_type): + if path_type is bytes: + return os.fsencode(value) + return cast("os.PathLike[str]", path_type(value)) + return value + + +def coerce_cli_path( + value: str | os.PathLike[str], + parameter_info: ParameterInfo, + *, + path_type: type[Any] | None, + param: Parameter | None = None, + ctx: Context | None = None, +) -> str | bytes | os.PathLike[str] | Path: + if path_type is None or path_type is str or path_type is bytes: + rv: Any = value + elif isinstance(path_type, type) and issubclass(path_type, Path): + if isinstance(value, path_type): + rv = value + elif isinstance(value, (str, os.PathLike)): try: - st = os.stat(rv) - except OSError: - if not self.exists: - return self.coerce_path_result(rv) - self.fail( - f"{self.name.title()} {format_filename(value)!r} does not exist.", - param, - ctx, - ) + rv = TypeAdapter(path_type).validate_python(value) + except ValidationError as exc: + raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc + else: + rv = value + else: + rv = value - name = self.name.title() - loc = repr(format_filename(value)) - if not self.file_okay and stat.S_ISREG(st.st_mode): - self.fail(f"{name} {loc} is a file.", param, ctx) + is_dash = ( + parameter_info.file_okay and parameter_info.allow_dash and rv in (b"-", "-") + ) - if not self.dir_okay and stat.S_ISDIR(st.st_mode): - self.fail(f"{name} {loc} is a directory.", param, ctx) + if not is_dash: + if parameter_info.resolve_path: + rv = os.path.realpath(rv) + + name = _path_display_name(parameter_info) + try: + st = os.stat(rv) + except OSError: + if not parameter_info.exists: + return _coerce_path_result(rv, path_type) + raise BadParameter( + f"{name.title()} {format_filename(value)!r} does not exist.", + ctx=ctx, + param=param, + ) from None - if self.readable and not os.access(rv, os.R_OK): - self.fail(f"{name} {loc} is not readable.", param, ctx) + name_title = name.title() + loc = repr(format_filename(value)) + if not parameter_info.file_okay and stat.S_ISREG(st.st_mode): + raise BadParameter(f"{name_title} {loc} is a file.", ctx=ctx, param=param) - if self.writable and not os.access(rv, os.W_OK): - self.fail(f"{name} {loc} is not writable.", param, ctx) + if not parameter_info.dir_okay and stat.S_ISDIR(st.st_mode): + raise BadParameter( + f"{name_title} {loc} is a directory.", ctx=ctx, param=param + ) - return self.coerce_path_result(rv) + if parameter_info.readable and not os.access(rv, os.R_OK): + raise BadParameter( + f"{name_title} {loc} is not readable.", ctx=ctx, param=param + ) - def shell_complete( - self, ctx: Context, param: Parameter, incomplete: str - ) -> list[CompletionItem]: - """Return an empty list so that the autocompletion functionality - will work properly from the commandline. - """ - return [] + if parameter_info.writable and not os.access(rv, os.W_OK): + raise BadParameter( + f"{name_title} {loc} is not writable.", ctx=ctx, param=param + ) + + return _coerce_path_result(rv, path_type) + + +def typer_path_display_type( + annotation: Any, + parameter_info: ParameterInfo, +) -> TyperPath: + return TyperPath( + exists=parameter_info.exists, + file_okay=parameter_info.file_okay, + dir_okay=parameter_info.dir_okay, + writable=parameter_info.writable, + readable=parameter_info.readable, + resolve_path=parameter_info.resolve_path, + allow_dash=parameter_info.allow_dash, + path_type=resolve_path_type(annotation, parameter_info), + ) def is_file_annotation(annotation: Any) -> bool: @@ -625,19 +674,7 @@ def param_type_from_annotation( if annotation is bool: return BOOL if _needs_typer_path(annotation, parameter_info): - resolved_path_type: type[Any] | None = parameter_info.path_type - if resolved_path_type is None and lenient_issubclass(annotation, Path): - resolved_path_type = annotation - return TyperPath( - exists=parameter_info.exists, - file_okay=parameter_info.file_okay, - dir_okay=parameter_info.dir_okay, - writable=parameter_info.writable, - readable=parameter_info.readable, - resolve_path=parameter_info.resolve_path, - allow_dash=parameter_info.allow_dash, - path_type=resolved_path_type, - ) + return typer_path_display_type(annotation, parameter_info) if lenient_issubclass(annotation, Enum): return TyperChoice( list(annotation), diff --git a/typer/schema.py b/typer/schema.py index cd5831a987..adea8211a5 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -25,10 +25,13 @@ _open_cli_file, choice_coercion_annotation, coerce_cli_choice, + coerce_cli_path, file_coercion_annotation, infer_type_from_default, lenient_issubclass, + path_uses_coercion, resolve_file_mode, + resolve_path_type, ) ParamKind = Literal["option", "argument"] @@ -141,6 +144,28 @@ def open_one(item: Any) -> IO[Any]: return open_one(value) +@dataclass(frozen=True) +class PathRuntimeParam(RuntimeParam): + """Coercion for path parameters.""" + + path_type: type[Any] | None + + def _coerce_value( + self, + value: Any, + *, + param: Any | None, + ctx: Any | None, + ) -> Any: + return coerce_cli_path( + value, + self.parameter_info, + path_type=self.path_type, + param=param, + ctx=ctx, + ) + + @dataclass(frozen=True) class ChoiceRuntimeParam(RuntimeParam): """Coercion for enum and literal choice parameters.""" @@ -330,6 +355,11 @@ def runtime_param_from_declared( file_annotation = file_coercion_annotation(declared.annotation) if file_annotation is not None: return FileRuntimeParam(**common, file_annotation=file_annotation) + if path_uses_coercion(declared.annotation, declared.parameter_info): + return PathRuntimeParam( + **common, + path_type=resolve_path_type(declared.annotation, declared.parameter_info), + ) choice = choice_coercion_annotation(declared.annotation, declared.parameter_info) if choice is not None: choices, case_sensitive = choice From 199b28b7663c605bfbc63f719d41041b548a325c Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 15 Jun 2026 17:53:25 +0200 Subject: [PATCH 39/73] clean up ParamType methods and introduce TyperParameter --- tests/test_core.py | 16 ++++-- tests/test_schema.py | 26 ++++++++++ typer/_click/core.py | 58 ++-------------------- typer/_click/termui.py | 4 +- typer/_click/types.py | 32 +----------- typer/core.py | 110 +++++++++++++++++++++++++---------------- typer/param_types.py | 35 ------------- typer/schema.py | 59 +++++++++++++++++++++- 8 files changed, 169 insertions(+), 171 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index ccaf960476..0b9fd111c1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -43,15 +43,21 @@ def cmd(name: Annotated[str, typer.Option(metavar="CUSTOM")]) -> None: def test_parameter_nargs_gt_1() -> None: - param = TyperArgument(param_decls=["value"], type=str, nargs=2) - ctx = _click.Context(TyperCommand(name="cmd")) + app = typer.Typer() + + @app.command() + def cmd(value: tuple[str, str]): + pass # pragma: no cover - assert param.type_cast_value(ctx, ("one", "two")) == ("one", "two") + param = next(p for p in typer.main.get_command(app).params if p.name == "value") + ctx = _click.Context(TyperCommand(name="cmd")) + assert param.runtime_param is not None + assert param.process_value(ctx, ("one", "two")) == ("one", "two") with pytest.raises( - _click.exceptions.BadParameter, match="Takes 2 values but 1 given." + _click.exceptions.BadParameter, match="2 values are required, but 1 given" ): - param.type_cast_value(ctx, ("one",)) + param.process_value(ctx, ("one",)) def test_parameter_constructor() -> None: diff --git a/tests/test_schema.py b/tests/test_schema.py index fe4bc5cd11..56c8add4f7 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -7,11 +7,13 @@ from typer.adapters import build_adapter from typer.main import get_command from typer.param_types import ( + TyperTuple, choice_coercion_annotation, file_coercion_annotation, path_uses_coercion, ) from typer.schema import ( + AdapterRuntimeParam, ChoiceRuntimeParam, FileRuntimeParam, PathRuntimeParam, @@ -221,6 +223,30 @@ def main(config: Path = typer.Option(..., exists=True)): assert seen == [target] +def test_tuple_arity_via_runtime_param() -> None: + app = typer.Typer() + + @app.command() + def main(coords: tuple[int, int] = typer.Option(...)): + pass + + command = get_command(app) + param = next(p for p in command.params if p.name == "coords") + assert isinstance(param.type, TyperTuple) + assert param.type.arity == 2 + + runtime_param = command.schema.get_param("coords") + assert runtime_param is not None + assert isinstance(runtime_param, AdapterRuntimeParam) + assert runtime_param.coerce(["4", "2"]) == (4, 2) + + with pytest.raises(ValueError, match="2 values are required, but 1 given"): + runtime_param.coerce(["4"]) + + with pytest.raises(ValueError, match="2 values are required, but 3 given"): + runtime_param.coerce(["4", "2", "0"]) + + def test_tuple_file_runtime_param(tmp_path: Path) -> None: first = tmp_path / "first.txt" second = tmp_path / "second.txt" diff --git a/typer/_click/core.py b/typer/_click/core.py index 6072ec3aae..09d9cda5c9 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -21,7 +21,6 @@ Abort, BadParameter, Exit, - MissingParameter, NoArgsIsHelpError, UsageError, ) @@ -940,65 +939,14 @@ def consume_value( return value, source - def type_cast_value(self, ctx: Context, value: Any) -> Any: - """Convert and validate a value against the parameter's - `type`, `multiple`, and `nargs`. - """ - if value is None: - return () if self.multiple or self.nargs == -1 else None - - def check_iter(value: Any) -> Iterator[Any]: - if isinstance(value, str): - raise BadParameter("Value must be an iterable.", ctx=ctx, param=self) - else: - return iter(value) - - # Define the conversion function based on nargs and type. - if self.nargs == 1 or self.type.is_composite: - - def convert(value: Any) -> Any: - return self.type(value, param=self, ctx=ctx) - - elif self.nargs == -1: - - def convert(value: Any) -> Any: # tuple[t.Any, ...] - return tuple(self.type(x, self, ctx) for x in check_iter(value)) - - # TODO: evaluate whether we need to keep this in Typer - else: # nargs > 1 - - def convert(value: Any) -> Any: # tuple[t.Any, ...] - value = tuple(check_iter(value)) - - if len(value) != self.nargs: - raise BadParameter( - f"Takes {self.nargs} values but {len(value)} given.", - ctx=ctx, - param=self, - ) - - return tuple(self.type(x, self, ctx) for x in value) - - if self.multiple: - return tuple(convert(x) for x in check_iter(value)) - - return convert(value) - @abstractmethod def value_is_missing(self, value: Any) -> bool: pass # pragma: no cover + @abstractmethod def process_value(self, ctx: Context, value: Any) -> Any: - """Process the value of this parameter""" - value = self.type_cast_value(ctx, value) - - if self.required and self.value_is_missing(value): - raise MissingParameter(ctx=ctx, param=self) - - if self.callback is not None: - value = self.callback(ctx, self, value) - - return value + """Process the value of this parameter.""" + pass # pragma: no cover def resolve_envvar_value(self, ctx: Context) -> str | None: """Returns the value found in the environment variable(s) attached to this diff --git a/typer/_click/termui.py b/typer/_click/termui.py index e1605ae5a4..8f0ab731a6 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -109,8 +109,10 @@ def prompt_func(text: str) -> str: if value_proc is None: from ..param_types import resolve_param_type + from ..schema import prompt_value_proc - value_proc = resolve_param_type(type, default) + value_proc = prompt_value_proc(type, default) + type = resolve_param_type(type, default) prompt = _build_prompt( text, prompt_suffix, show_default, default, show_choices, type diff --git a/typer/_click/types.py b/typer/_click/types.py index 0d43fac7dd..8fdc1b7098 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import ( TYPE_CHECKING, - Any, ClassVar, NoReturn, Union, @@ -15,22 +14,7 @@ class ParamType: - """Represents the type of a parameter. Validates and converts values - from the command line or Python into the correct type. - - To implement a custom type, subclass and implement at least the - following: - - - The `name` class attribute must be set. - - Calling an instance of the type with ``None`` must return - ``None``. This is already implemented by default. - - `convert` must convert string values to the correct type. - - `convert` must accept values that are already the correct - type. - - It must be able to convert a value if the ``ctx`` and ``param`` - arguments are ``None``. This can occur when converting prompt - input. - """ + """Display and plumbing metadata for a CLI parameter type.""" is_composite: ClassVar[bool] = False name: str @@ -47,15 +31,6 @@ def arity(self) -> int: # Windows). envvar_list_splitter: ClassVar[str | None] = None - def __call__( - self, - value: Any, - param: Union["Parameter", None] = None, - ctx: Union["Context", None] = None, - ) -> Any: - if value is not None: - return self.convert(value, param, ctx) - def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: """Returns the metavar default for this param if it provides one.""" pass # pragma: no cover @@ -68,11 +43,6 @@ def get_missing_message( """ pass # pragma: no cover - def convert( - self, value: Any, param: Union["Parameter", None], ctx: Union["Context", None] - ) -> Any: - pass # pragma: no cover - def split_envvar_value(self, rv: str) -> Sequence[str]: """Given a value from an environment variable this splits it up into small chunks depending on the defined envvar list splitter. diff --git a/typer/core.py b/typer/core.py index 81c8d2bf90..94fe4e6a28 100644 --- a/typer/core.py +++ b/typer/core.py @@ -18,7 +18,14 @@ from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem from ._typing import Literal -from .schema import CommandSchema, RuntimeParam +from .models import OptionInfo +from .schema import ( + CommandSchema, + DeclaredParam, + RuntimeParam, + bool_flag_runtime_param, + runtime_param_from_declared, +) from .utils import describe_number_range, parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] @@ -93,20 +100,35 @@ def compat_autocompletion( self._custom_shell_complete = compat_autocompletion -def _type_cast_runtime_value( - param: "TyperOption | TyperArgument", - ctx: _click.Context, - value: Any, -) -> Any: - runtime_param = param.runtime_param - assert runtime_param is not None +def _value_is_missing(param: _click.Parameter, value: Any) -> bool: if value is None: - return () if param.multiple or param.nargs == -1 else None - if (param.multiple or param.nargs == -1) and isinstance(value, str): - raise _click.exceptions.BadParameter( - "Value must be an iterable.", ctx=ctx, param=param - ) - return runtime_param.coerce(value, param=param, ctx=ctx) + return True + + if (param.nargs != 1 or param.multiple) and value == (): + return True # pragma: no cover + + return False + + +class TyperParameter(_click.core.Parameter): + """Typer parameter with runtime coercion.""" + + runtime_param: RuntimeParam | None + + def process_value(self, ctx: _click.Context, value: Any) -> Any: + if self.runtime_param is None: + raise TypeError( + f"{self.__class__.__name__} {self.name!r} requires runtime_param" + ) + value = self.runtime_param.coerce(value, param=self, ctx=ctx) + if self.required and self.value_is_missing(value): + raise _click.exceptions.MissingParameter(ctx=ctx, param=self) + if self.callback is not None: + value = self.callback(ctx, self, value) + return value + + def value_is_missing(self, value: Any) -> bool: + return _value_is_missing(self, value) def _get_default_string( @@ -270,7 +292,7 @@ def _main( sys.exit(1) -class TyperArgument(_click.core.Parameter): +class TyperArgument(TyperParameter): param_type_name = "argument" def __init__( @@ -436,14 +458,6 @@ def make_metavar(self, ctx: _click.Context) -> str: var += "..." return var - def value_is_missing(self, value: Any) -> bool: - return _value_is_missing(self, value) - - def type_cast_value(self, ctx: _click.Context, value: Any) -> Any: - if self.runtime_param is None: - return super().type_cast_value(ctx, value) - return _type_cast_runtime_value(self, ctx, value) - def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -471,7 +485,7 @@ def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) -class TyperOption(_click.Parameter): +class TyperOption(TyperParameter): param_type_name = "option" _depr_flag_value: bool | None @@ -587,17 +601,42 @@ def __init__( _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) self.rich_help_panel = rich_help_panel + if self.runtime_param is None and self.is_bool_flag: + default_flag = self.default if isinstance(self.default, bool) else False + self.runtime_param = bool_flag_runtime_param( + name=self.name or "flag", + default=default_flag, + ) + elif self.runtime_param is None and self.count: + count_info = OptionInfo() + if self.min is not None: + count_info.min = self.min + if self.max is not None: + count_info.max = self.max + declared = DeclaredParam( + name=self.name or "count", + parameter_info=count_info, + default=self.default if self.default is not None else 0, + required=required, + annotation=int, + is_list=False, + is_tuple=False, + is_flag=False, + ) + self.runtime_param = runtime_param_from_declared( + declared, + kind="option", + multiple=False, + nargs=1, + is_bool_flag=False, + ) + def get_error_hint(self, ctx: _click.Context) -> str: result = super().get_error_hint(ctx) if self.show_envvar and self.envvar is not None: result += f" (env var: '{self.envvar}')" return result - def type_cast_value(self, ctx: _click.Context, value: Any) -> Any: - if self.runtime_param is None: - return super().type_cast_value(ctx, value) - return _type_cast_runtime_value(self, ctx, value) - def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -896,19 +935,6 @@ def _write_opts(opts: Sequence[str]) -> str: return ("; " if any_prefix_is_slash else " / ").join(rv), help - def value_is_missing(self, value: Any) -> bool: - return _value_is_missing(self, value) - - -def _value_is_missing(param: _click.Parameter, value: Any) -> bool: - if value is None: - return True - - if (param.nargs != 1 or param.multiple) and value == (): - return True # pragma: no cover - - return False - def _typer_format_options( self: _click.core.Command, *, ctx: _click.Context, formatter: _click.HelpFormatter diff --git a/typer/param_types.py b/typer/param_types.py index d03412354d..01e3bfe2c0 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -55,14 +55,6 @@ def __init__( self._repr_name = repr_name or name self._metavar = metavar - def convert( - self, - value: Any, - param: Parameter | None, - ctx: Context | None, - ) -> Any: - return value - def get_metavar(self, param: Parameter, ctx: Context) -> str | None: if self._metavar is None: return None @@ -169,22 +161,6 @@ def __init__(self, element_types: Sequence[types.ParamType]) -> None: def arity(self) -> int: return len(self.types) - def convert( - self, - value: Any, - param: Parameter | None, - ctx: Context | None, - ) -> Any: - len_type = len(self.types) - len_value = len(value) - if len_value != len_type: - self.fail( - f"{len_type} values are required, but {len_value} given.", - param=param, - ctx=ctx, - ) - return value - class TyperChoice(types.ParamType, Generic[ParamTypeValue]): name = "choice" @@ -238,9 +214,6 @@ def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) return f"Choose from:\n\t{choices}" - def convert(self, value: Any, param: Parameter | None, ctx: Context | None) -> Any: - return value - def get_invalid_choice_message(self, value: Any, ctx: Context | None) -> str: """Get the error message when the given choice is invalid.""" choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) @@ -300,14 +273,6 @@ def __init__( else: self.name = "path" - def convert( - self, - value: Any, - param: Parameter | None, - ctx: Context | None, - ) -> Any: - return value - def shell_complete( self, ctx: Context, param: Parameter, incomplete: str ) -> list[CompletionItem]: diff --git a/typer/schema.py b/typer/schema.py index adea8211a5..1161224f3e 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -1,14 +1,15 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import IO, Any, Literal from pydantic import TypeAdapter, ValidationError from . import adapters -from ._click.exceptions import BadParameter +from ._click.exceptions import BadParameter, UsageError +from ._click.types import ParamType from ._typing import get_args as typer_get_args from ._typing import get_origin as typer_get_origin from ._typing import is_union @@ -74,7 +75,13 @@ def coerce( ctx: Any | None = None, ) -> Any: if value is None: + if self.multiple or self.nargs == -1: + return () return None + if (self.multiple or self.nargs == -1) and isinstance(value, str): + if param is None: + raise ValueError("Value must be an iterable.") + raise BadParameter("Value must be an iterable.", ctx=ctx, param=param) return self._coerce_value(value, param=param, ctx=ctx) @abstractmethod @@ -337,6 +344,54 @@ def _runtime_param_fields( } +def bool_flag_runtime_param(*, name: str, default: bool = False) -> RuntimeParam: + """Build runtime coercion for a standalone boolean flag option.""" + declared = DeclaredParam( + name=name, + parameter_info=OptionInfo(), + default=default, + required=False, + annotation=bool, + is_list=False, + is_tuple=False, + is_flag=True, + ) + return runtime_param_from_declared( + declared, + kind="option", + multiple=False, + nargs=1, + is_bool_flag=True, + ) + + +def prompt_value_proc( + type: Any | None = None, + default: Any | None = None, +) -> Callable[[Any], Any]: + """Coerce interactive prompt input via the runtime adapter layer.""" + annotation = type + if isinstance(annotation, ParamType): + annotation = None + if annotation is None and default is not None: + annotation, _ = infer_type_from_default(default) + if annotation is None: + annotation = str + + parameter_info = OptionInfo() + adapter = adapters.build_adapter(annotation, parameter_info) + + def coerce(value: Any) -> Any: + try: + return adapter.validate_python(value) + except ValidationError as exc: + raise UsageError(_get_error_msg(exc)) from exc + except ValueError as exc: + raise UsageError(str(exc)) from exc + + return coerce + + def runtime_param_from_declared( declared: DeclaredParam, *, From 78a1c372e65d8d02f21df47ef12620717299584b Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 16 Jun 2026 11:26:43 +0200 Subject: [PATCH 40/73] refactors and simplifications --- tests/test_core.py | 2 +- .../test_number/test_tutorial001.py | 4 +- tests/test_type_conversion.py | 11 ++- typer/adapters.py | 75 +++++++++---------- typer/core.py | 4 +- typer/param_types.py | 45 +++-------- typer/schema.py | 20 +++-- 7 files changed, 69 insertions(+), 92 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 0b9fd111c1..c2104bbda6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -99,7 +99,7 @@ def test_parameter_constructor() -> None: required=False, count=True, ) - assert option.type.name == "integer range" + assert option.type.name == "int range" assert option.min == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index d68bbf6993..1940d5d022 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -28,7 +28,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INTEGER RANGE" in result.output + assert "INT RANGE" in result.output assert "--score" in result.output assert "FLOAT RANGE" in result.output @@ -38,7 +38,7 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INTEGER RANGE" in result.output + assert "INT RANGE" in result.output assert "--score" in result.output assert "FLOAT RANGE" in result.output diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 5c34dbbdaa..fb415d5d99 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -447,6 +447,12 @@ def test_argv_encoding( stdin_encoding: str | None, filesystem_encoding: str, ) -> None: + app = typer.Typer() + + @app.command() + def show(name: str = typer.Option(...)): + print(name) + sys = _click._compat.sys if platform_case == "windows": import locale @@ -461,5 +467,6 @@ def __init__(self, encoding: str | None) -> None: monkeypatch.setattr(sys, "stdin", FakeStdin(stdin_encoding)) monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem_encoding) - converted = typer.adapters.build_leaf_adapter(str).validate_python(b"\xff") - assert converted == "ÿ" + result = runner.invoke(app, [], default_map={"name": b"\xff"}) + assert result.exit_code == 0 + assert "ÿ" in result.output diff --git a/typer/adapters.py b/typer/adapters.py index ca50fef133..861a166676 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -22,83 +22,80 @@ def build_adapter( annotation: Any, parameter_info: ParameterInfo, ) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a parameter annotation and metadata.""" - if parameter_info.parser is not None: - return _build_parser_adapter(parameter_info.parser) - + """Build a Pydantic TypeAdapter for a parameter annotation and metadata. + Check for list/tuple and call this function recursively. + Otherwise, delegate to build_leaf_adapter. + """ origin = get_origin(annotation) if origin is list: - (item_type,) = get_args(annotation) - item_adapter = build_adapter(item_type, parameter_info) + args = get_args(annotation) + if len(args) != 1: + raise ValueError(f"Expected one list item type, got: {args!r}") + list_type = args[0] + adapter = build_adapter(list_type, parameter_info) def parse_list(value: Any) -> list[Any]: if not isinstance(value, (list, tuple)): - value = (value,) + value = [value] return [ - None if item is None else item_adapter.validate_python(item) + None if item is None else adapter.validate_python(item) for item in value ] return TypeAdapter(Annotated[list[Any], BeforeValidator(parse_list)]) if origin is tuple: - item_types = get_args(annotation) - item_adapters = [ - build_adapter(item_type, parameter_info) for item_type in item_types - ] + types = get_args(annotation) + adapters = [build_adapter(t, parameter_info) for t in types] def parse_tuple(value: Any) -> tuple[Any, ...]: if not isinstance(value, (list, tuple)): raise ValueError("value is not a valid tuple") - if len(value) != len(item_adapters): + if len(value) != len(adapters): raise ValueError( - f"{len(item_adapters)} values are required, but {len(value)} given." + f"{len(adapters)} values are required, but {len(value)} given." ) return tuple( None if item is None else adapter.validate_python(item) - for adapter, item in zip(item_adapters, value, strict=False) + for adapter, item in zip(adapters, value, strict=False) ) return TypeAdapter(Annotated[tuple[Any, ...], BeforeValidator(parse_tuple)]) - if is_number_type(annotation): - return build_leaf_adapter( - annotation, - min=parameter_info.min, - max=parameter_info.max, - clamp=parameter_info.clamp, - ) - if annotation is datetime: - return build_leaf_adapter(annotation, formats=parameter_info.formats) + return build_leaf_adapter(annotation, parameter_info=parameter_info) + + +def build_leaf_adapter( + annotation: Any, + *, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any]: + """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" + if parameter_info.parser is not None: + return _build_parser_adapter(parameter_info.parser) if lenient_issubclass(annotation, Enum): + case_sensitive = parameter_info.case_sensitive return _build_choice_adapter( list(annotation), - case_sensitive=parameter_info.case_sensitive, + case_sensitive=case_sensitive, ) if is_literal_type(annotation): + case_sensitive = parameter_info.case_sensitive return _build_choice_adapter( literal_values(annotation), - case_sensitive=parameter_info.case_sensitive, + case_sensitive=case_sensitive, ) if _needs_typer_path(annotation, parameter_info): return build_path_adapter(annotation, parameter_info) - return build_leaf_adapter(annotation) - -def build_leaf_adapter( - annotation: Any, - *, - min: float | None = None, - max: float | None = None, - clamp: bool = False, - formats: Sequence[str] | None = None, -) -> TypeAdapter[Any]: - """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" - if annotation is datetime and formats is not None: - return _build_datetime_adapter(formats) + if annotation is datetime: + return _build_datetime_adapter(parameter_info.formats) if is_number_type(annotation): + clamp = parameter_info.clamp + min = parameter_info.min + max = parameter_info.max if clamp: # Use AfterValidator so it runs after coercion return TypeAdapter( diff --git a/typer/core.py b/typer/core.py index 94fe4e6a28..1bc4abffff 100644 --- a/typer/core.py +++ b/typer/core.py @@ -586,9 +586,7 @@ def __init__( # Counting self.count = count if count and type is None: - self.type = param_types._ranged_number_param_type( - int, min=0, max=None, clamp=False - ) + self.type = param_types._ranged_number_param_type(int) if self.min is None: self.min = 0 diff --git a/typer/param_types.py b/typer/param_types.py index 01e3bfe2c0..cde94159b8 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -14,9 +14,7 @@ from ._click.exceptions import BadParameter from ._click.shell_completion import CompletionItem from ._click.utils import LazyFile, format_filename, safecall -from ._typing import get_args as typer_get_args -from ._typing import get_origin as typer_get_origin -from ._typing import is_literal_type, literal_values +from ._typing import get_args, get_origin, is_literal_type, literal_values from .models import ( AnyType, FileBinaryRead, @@ -179,10 +177,7 @@ def _normalized_mapping( the normalized values that are accepted via the command line. """ return { - choice: self.normalize_choice( - choice=choice, - ctx=ctx, - ) + choice: self.normalize_choice(choice=choice, ctx=ctx) for choice in self.choices } @@ -403,14 +398,14 @@ def is_file_annotation(annotation: Any) -> bool: def file_coercion_annotation(annotation: Any) -> Any | None: """Return the file marker type when this parameter opens files.""" - origin = typer_get_origin(annotation) + origin = get_origin(annotation) if origin is list: - args = typer_get_args(annotation) + args = get_args(annotation) if args and all(is_file_annotation(arg) for arg in args): return args[0] return None if origin is tuple: - args = typer_get_args(annotation) + args = get_args(annotation) if args and all(is_file_annotation(arg) for arg in args): return args[0] return None @@ -508,16 +503,8 @@ def _file_param_type() -> FileDisplayType: return FILE -def _ranged_number_param_type( - number_class: type[Any], - *, - min: int | float | None, - max: int | float | None, - clamp: bool, -) -> types.ParamType: - return DisplayParamType( - name="integer range" if number_class is int else "float range", - ) +def _ranged_number_param_type(number_class: type[Any]) -> types.ParamType: + return DisplayParamType(name=f"{number_class.__name__} range") def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: @@ -547,7 +534,7 @@ def resolve_param_type( *, parameter_info: ParameterInfo | None = None, ) -> types.ParamType: - """Resolve a display ``ParamType`` for metavar/help.""" + """Resolve a display ParamType for metavar/help.""" guessed_type = False if annotation is None and default is not None: annotation, guessed_type = infer_type_from_default(default) @@ -598,10 +585,10 @@ def cli_param_type( ) -> types.ParamType: """Defer the "type" for metavar/help.""" if is_tuple: - type_args = typer_get_args(annotation) + type_args = get_args(annotation) return resolve_param_type(tuple(type_args), parameter_info=parameter_info) if is_list: - (element_type,) = typer_get_args(annotation) + (element_type,) = get_args(annotation) return resolve_param_type(element_type, parameter_info=parameter_info) return resolve_param_type( annotation, default=default, parameter_info=parameter_info @@ -620,17 +607,7 @@ def param_type_from_annotation( ) -> types.ParamType | None: if annotation is int or annotation is float: if parameter_info.min is not None or parameter_info.max is not None: - min_ = parameter_info.min - max_ = parameter_info.max - if annotation is int: - min_ = int(min_) if min_ is not None else None - max_ = int(max_) if max_ is not None else None - return _ranged_number_param_type( - annotation, - min=min_, - max=max_, - clamp=parameter_info.clamp, - ) + return _ranged_number_param_type(annotation) return INT if annotation is int else FLOAT if annotation is UUIDType: return UUID diff --git a/typer/schema.py b/typer/schema.py index 1161224f3e..66d2d56347 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -10,9 +10,7 @@ from . import adapters from ._click.exceptions import BadParameter, UsageError from ._click.types import ParamType -from ._typing import get_args as typer_get_args -from ._typing import get_origin as typer_get_origin -from ._typing import is_union +from ._typing import get_args, get_origin, is_union from .models import ( ArgumentInfo, NoneType, @@ -254,30 +252,30 @@ def declare_param(param: ParamMeta) -> DeclaredParam: if param.annotation is not param.empty: main_type = param.annotation - origin = typer_get_origin(main_type) + origin = get_origin(main_type) if origin is not None: if is_union(origin): types = [] - for type_ in typer_get_args(main_type): + for type_ in get_args(main_type): if type_ is NoneType: continue types.append(type_) assert len(types) == 1, "Typer Currently doesn't support Union types" main_type = types[0] - origin = typer_get_origin(main_type) + origin = get_origin(main_type) if lenient_issubclass(origin, list): - element_type = typer_get_args(main_type)[0] - assert not typer_get_origin(element_type), ( + element_type = get_args(main_type)[0] + assert not get_origin(element_type), ( "List types with complex sub-types are not currently supported" ) is_list = True pydantic_annotation = main_type elif lenient_issubclass(origin, tuple): - type_args = typer_get_args(main_type) + type_args = get_args(main_type) for type_ in type_args: - assert not typer_get_origin(type_), ( + assert not get_origin(type_), ( "Tuple types with complex sub-types are not currently supported" ) is_tuple = True @@ -303,7 +301,7 @@ def declare_param(param: ParamMeta) -> DeclaredParam: is_list and isinstance(parameter_info, OptionInfo) and parameter_info.param_decls - and typer_get_args(pydantic_annotation) == (bool,) + and get_args(pydantic_annotation) == (bool,) ): for decl in parameter_info.param_decls: if "/" in decl: From cfccba815225566648d16856f89a0839a4167538 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 16 Jun 2026 15:06:23 +0200 Subject: [PATCH 41/73] fix test --- tests/test_type_conversion.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index fb415d5d99..4b667c6871 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -453,18 +453,13 @@ def test_argv_encoding( def show(name: str = typer.Option(...)): print(name) - sys = _click._compat.sys if platform_case == "windows": import locale monkeypatch.setattr(locale, "getpreferredencoding", lambda: "latin-1") else: - - class FakeStdin: - def __init__(self, encoding: str | None) -> None: - self.encoding = encoding - - monkeypatch.setattr(sys, "stdin", FakeStdin(stdin_encoding)) + argv_encoding = stdin_encoding or filesystem_encoding + monkeypatch.setattr(_click._compat, "_get_argv_encoding", lambda: argv_encoding) monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem_encoding) result = runner.invoke(app, [], default_map={"name": b"\xff"}) From 9a9bee7db3a85ef80e646f1e3bf4ec802570bca0 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 16 Jun 2026 17:24:22 +0200 Subject: [PATCH 42/73] some more cleanup in RuntimeParam/DeclaredParam/Parameter --- tests/test_schema.py | 214 +------------------------------------ typer/_click/core.py | 7 +- typer/_click/exceptions.py | 15 +-- typer/core.py | 24 +---- typer/main.py | 46 ++++---- typer/param_types.py | 10 +- typer/schema.py | 130 +++++----------------- 7 files changed, 56 insertions(+), 390 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index 56c8add4f7..713c08e9a4 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,131 +1,17 @@ -from enum import Enum from pathlib import Path -from typing import Any -import pytest import typer -from typer.adapters import build_adapter -from typer.main import get_command -from typer.param_types import ( - TyperTuple, - choice_coercion_annotation, - file_coercion_annotation, - path_uses_coercion, -) -from typer.schema import ( - AdapterRuntimeParam, - ChoiceRuntimeParam, - FileRuntimeParam, - PathRuntimeParam, -) from typer.testing import CliRunner runner = CliRunner() -def test_simple_command_schema() -> None: - app = typer.Typer() - - @app.command() - def main(name: str, age: int = 0, active: bool = False): - pass - - schema = get_command(app).schema - assert [param.name for param in schema.params] == ["name", "age", "active"] - - name_param = schema.get_param("name") - assert name_param is not None - assert name_param.annotation is str - assert name_param.kind == "argument" - assert name_param.required is True - - age_param = schema.get_param("age") - assert age_param is not None - assert age_param.annotation is int - assert age_param.kind == "option" - assert age_param.default == 0 - - active_param = schema.get_param("active") - assert active_param is not None - assert active_param.is_flag is True - assert active_param.is_bool_flag is True - - -@pytest.mark.parametrize( - ("raw", "expected", "expected_type"), - [ - ("42", 42, int), - ("3.5", 3.5, float), - ("hello", "hello", str), - (True, True, bool), - ], -) -def test_schema_coerce_scalars(raw: Any, expected: Any, expected_type: type) -> None: - adapter = build_adapter(expected_type, typer.models.OptionInfo()) - runtime_value = adapter.validate_python(raw) - - assert runtime_value == expected - assert type(runtime_value) is expected_type - - -def test_schema_coerce_list() -> None: - app = typer.Typer() - - @app.command() - def main(items: list[int]): - pass - - schema = get_command(app).schema - runtime_param = schema.get_param("items") - assert runtime_param is not None - assert runtime_param.annotation == list[int] - assert runtime_param.multiple is True - - assert runtime_param.coerce(("1", "2", "3")) == [1, 2, 3] - - -def test_schema_coerce_enum() -> None: - class Color(str, Enum): - RED = "red" - BLUE = "blue" - - app = typer.Typer() - - @app.command() - def main(color: Color): - pass - - schema = get_command(app).schema - runtime_param = schema.get_param("color") - assert runtime_param is not None - assert isinstance(runtime_param, ChoiceRuntimeParam) - assert runtime_param.coerce("red") is Color.RED - - -def test_schema_coerce_unannotated_default() -> None: - app = typer.Typer() - - @app.command() - def main(val=42): - pass - - schema = get_command(app).schema - runtime_param = schema.get_param("val") - assert runtime_param is not None - assert runtime_param.annotation is int - assert runtime_param.coerce("99") == 99 - - def test_runtime_coercion_on_invoke() -> None: app = typer.Typer() - seen: dict[str, Any] = {} + seen: dict[str, object] = {} @app.command() - def main( - items: list[int], - active: bool = False, - val=42, - ): + def main(items: list[int], active: bool = False, val=42): seen["items"] = items seen["active"] = active seen["val"] = val @@ -133,8 +19,6 @@ def main( result = runner.invoke(app, ["1", "2", "--active", "--val", "7"]) assert result.exit_code == 0, result.output assert seen == {"items": [1, 2], "active": True, "val": 7} - assert all(isinstance(v, int) for v in seen["items"]) - assert isinstance(seen["val"], int) def test_runtime_coercion_invalid_value() -> None: @@ -148,64 +32,9 @@ def main(age: int): assert result.exit_code != 0 -def test_schema_coerce_command_values() -> None: - app = typer.Typer() - seen: dict[str, Any] = {} - - @app.command() - def main(name: str, count: int = 1): - seen["name"] = name - seen["count"] = count - - schema = get_command(app).schema - coerced = schema.coerce({"name": "Ada", "count": "3"}) - assert coerced == {"name": "Ada", "count": 3} - - result = runner.invoke(app, ["Ada", "--count", "3"]) - assert result.exit_code == 0 - assert seen == {"name": "Ada", "count": 3} - - -@pytest.mark.parametrize( - ("annotation", "expected"), - [ - (typer.FileText, typer.FileText), - (list[typer.FileText], typer.FileText), - (tuple[typer.FileText], typer.FileText), - (tuple[typer.FileText, typer.FileTextWrite], typer.FileText), - (tuple[typer.FileText, str], None), - (tuple[str, str], None), - ], -) -def test_file_coercion_annotation(annotation: Any, expected: Any) -> None: - assert file_coercion_annotation(annotation) is expected - - -def test_choice_coercion_annotation() -> None: - class Color(str, Enum): - RED = "red" - BLUE = "blue" - - info = typer.models.OptionInfo() - result = choice_coercion_annotation(Color, info) - assert result is not None - choices, case_sensitive = result - assert Color.RED in choices - assert case_sensitive is True - assert choice_coercion_annotation(str, info) is None - - -def test_path_uses_coercion() -> None: - info = typer.models.OptionInfo() - assert path_uses_coercion(Path, info) is True - assert path_uses_coercion(str, info) is False - assert path_uses_coercion(str, typer.models.OptionInfo(resolve_path=True)) is True - - -def test_path_runtime_param(tmp_path: Path) -> None: +def test_path_runtime_coercion_on_invoke(tmp_path: Path) -> None: target = tmp_path / "config.txt" target.write_text("hello\n", encoding="utf-8") - app = typer.Typer() seen: list[Path] = [] @@ -213,46 +42,16 @@ def test_path_runtime_param(tmp_path: Path) -> None: def main(config: Path = typer.Option(..., exists=True)): seen.append(config) - schema = get_command(app).schema - runtime_param = schema.get_param("config") - assert isinstance(runtime_param, PathRuntimeParam) - assert runtime_param.path_type is Path - result = runner.invoke(app, ["--config", str(target)]) assert result.exit_code == 0, result.output assert seen == [target] -def test_tuple_arity_via_runtime_param() -> None: - app = typer.Typer() - - @app.command() - def main(coords: tuple[int, int] = typer.Option(...)): - pass - - command = get_command(app) - param = next(p for p in command.params if p.name == "coords") - assert isinstance(param.type, TyperTuple) - assert param.type.arity == 2 - - runtime_param = command.schema.get_param("coords") - assert runtime_param is not None - assert isinstance(runtime_param, AdapterRuntimeParam) - assert runtime_param.coerce(["4", "2"]) == (4, 2) - - with pytest.raises(ValueError, match="2 values are required, but 1 given"): - runtime_param.coerce(["4"]) - - with pytest.raises(ValueError, match="2 values are required, but 3 given"): - runtime_param.coerce(["4", "2", "0"]) - - -def test_tuple_file_runtime_param(tmp_path: Path) -> None: +def test_tuple_file_runtime_coercion_on_invoke(tmp_path: Path) -> None: first = tmp_path / "first.txt" second = tmp_path / "second.txt" first.write_text("first-content\n", encoding="utf-8") second.write_text("second-content\n", encoding="utf-8") - app = typer.Typer() seen: list[str] = [] @@ -261,11 +60,6 @@ def main(files: tuple[typer.FileText, typer.FileText]): seen.append(files[0].read()) seen.append(files[1].read()) - schema = get_command(app).schema - runtime_param = schema.get_param("files") - assert isinstance(runtime_param, FileRuntimeParam) - assert runtime_param.file_annotation is typer.FileText - result = runner.invoke(app, [str(first), str(second)]) assert result.exit_code == 0, result.output assert seen == ["first-content\n", "second-content\n"] diff --git a/typer/_click/core.py b/typer/_click/core.py index 09d9cda5c9..fd2cb19c15 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -986,15 +986,14 @@ def resolve_envvar_value(self, ctx: Context) -> str | None: return None def value_from_envvar(self, ctx: Context) -> str | Sequence[str] | None: - """Process the raw environment variable string for this parameter. + """Process the value from the environment variable. Returns the string as-is or splits it into a sequence of strings if the - parameter is expecting multiple values (i.e. its `nargs` property is set - to a value other than ``1``). + parameter is expecting multiple values. """ rv: Any | None = self.resolve_envvar_value(ctx) - if rv is not None and self.nargs != 1: + if rv is not None and (self.nargs != 1 or self.multiple): rv = self.type.split_envvar_value(rv) return rv diff --git a/typer/_click/exceptions.py b/typer/_click/exceptions.py index af2af260a6..3aae052d10 100644 --- a/typer/_click/exceptions.py +++ b/typer/_click/exceptions.py @@ -151,19 +151,10 @@ def format_message(self) -> str: else: msg = msg_extra - msg = f" {msg}" if msg else "" - - # Translate param_type for known types. - if param_type == "argument": - missing = "Missing argument" - elif param_type == "option": - missing = "Missing option" - elif param_type == "parameter": - missing = "Missing parameter" - else: - missing = f"Missing {param_type}" + if msg: + return f"Missing {param_type}{param_hint}. {msg}" - return f"{missing}{param_hint}.{msg}" + return f"Missing {param_type}{param_hint}." def __str__(self) -> str: if not self.message: diff --git a/typer/core.py b/typer/core.py index 1bc4abffff..73a534335f 100644 --- a/typer/core.py +++ b/typer/core.py @@ -617,17 +617,8 @@ def __init__( default=self.default if self.default is not None else 0, required=required, annotation=int, - is_list=False, - is_tuple=False, - is_flag=False, - ) - self.runtime_param = runtime_param_from_declared( - declared, - kind="option", - multiple=False, - nargs=1, - is_bool_flag=False, ) + self.runtime_param = runtime_param_from_declared(declared) def get_error_hint(self, ctx: _click.Context) -> str: result = super().get_error_hint(ctx) @@ -761,19 +752,6 @@ def prompt_for_value(self, ctx: _click.Context) -> Any: **prompt_kwargs, ) - def value_from_envvar(self, ctx: _click.Context) -> Any: - # TODO: clean up - rv = self.resolve_envvar_value(ctx) - - # Absent environment variable or an empty string is interpreted as unset. - if rv is None: - return None - - if self.nargs != 1 or self.multiple: - return self.type.split_envvar_value(rv) - - return rv - def resolve_envvar_value(self, ctx: _click.Context) -> str | None: rv = super().resolve_envvar_value(ctx) diff --git a/typer/main.py b/typer/main.py index 53fcf47141..62c3347f29 100644 --- a/typer/main.py +++ b/typer/main.py @@ -38,7 +38,7 @@ ParamMeta, TyperInfo, ) -from .param_types import TyperTuple, cli_param_type, lenient_issubclass +from .param_types import cli_param_type, lenient_issubclass from .schema import CommandSchema, declare_param, runtime_param_from_declared from .utils import get_params_from_function @@ -1458,18 +1458,29 @@ def get_param( parameter_info = declared.parameter_info default_value = declared.default required = declared.required - is_list = declared.is_list - is_flag = declared.is_flag + annotation_args = get_args(declared.annotation) + is_list = get_origin(declared.annotation) is list + is_flag = None + if isinstance(parameter_info, OptionInfo): + if declared.annotation is bool: + is_flag = True + elif ( + is_list + and annotation_args == (bool,) + and parameter_info.param_decls + and any("/" in decl for decl in parameter_info.param_decls) + ): + is_flag = True parameter_type: ParamType | None = cli_param_type( annotation=declared.annotation, parameter_info=parameter_info, default=default_value, is_list=is_list, - is_tuple=declared.is_tuple, + is_tuple=get_origin(declared.annotation) is tuple, ) if isinstance(parameter_info, OptionInfo): - if declared.is_flag: + if is_flag: parameter_type = None default_option_name = get_command_name(param.name) if is_flag: @@ -1483,19 +1494,7 @@ def get_param( param_decls.extend(parameter_info.param_decls) else: param_decls.append(default_option_declaration) - runtime_param = runtime_param_from_declared( - declared, - kind="option", - multiple=is_list, - nargs=1, - is_bool_flag=bool( - declared.is_flag - and ( - declared.annotation is bool - or (declared.is_list and get_args(declared.annotation) == (bool,)) - ) - ), - ) + runtime_param = runtime_param_from_declared(declared) return TyperOption( # Option param_decls=param_decls, @@ -1534,16 +1533,7 @@ def get_param( nargs = None if is_list: nargs = -1 - binding_nargs = nargs if nargs is not None else 1 - if isinstance(parameter_type, TyperTuple): - binding_nargs = parameter_type.arity - runtime_param = runtime_param_from_declared( - declared, - kind="argument", - multiple=is_list, - nargs=binding_nargs, - is_bool_flag=False, - ) + runtime_param = runtime_param_from_declared(declared) return TyperArgument( # Argument param_decls=param_decls, diff --git a/typer/param_types.py b/typer/param_types.py index cde94159b8..dbd11c3d3f 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -1,6 +1,6 @@ import os import stat -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime from enum import Enum from pathlib import Path @@ -47,18 +47,14 @@ def __init__( *, name: str, repr_name: str | None = None, - metavar: str | Callable[[Parameter, Context], str | None] | None = None, + metavar: str | None = None, ) -> None: self.name = name self._repr_name = repr_name or name self._metavar = metavar def get_metavar(self, param: Parameter, ctx: Context) -> str | None: - if self._metavar is None: - return None - if isinstance(self._metavar, str): - return self._metavar - return self._metavar(param, ctx) + return self._metavar def __repr__(self) -> str: return self._repr_name diff --git a/typer/schema.py b/typer/schema.py index 66d2d56347..fb3399f5c1 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -3,11 +3,12 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import IO, Any, Literal +from typing import IO, TYPE_CHECKING, Any from pydantic import TypeAdapter, ValidationError from . import adapters +from ._click import Context from ._click.exceptions import BadParameter, UsageError from ._click.types import ParamType from ._typing import get_args, get_origin, is_union @@ -19,6 +20,9 @@ ParamMeta, Required, ) + +if TYPE_CHECKING: + from .core import TyperParameter from .param_types import ( _get_error_msg, _open_cli_file, @@ -33,8 +37,6 @@ resolve_path_type, ) -ParamKind = Literal["option", "argument"] - @dataclass(frozen=True) class DeclaredParam: @@ -45,9 +47,6 @@ class DeclaredParam: default: Any required: bool annotation: Any - is_list: bool - is_tuple: bool - is_flag: bool | None @dataclass(frozen=True) @@ -56,29 +55,21 @@ class RuntimeParam(ABC): name: str parameter_info: ParameterInfo - default: Any - required: bool annotation: Any - kind: ParamKind - multiple: bool - nargs: int - is_flag: bool - is_bool_flag: bool def coerce( self, value: Any, *, - param: Any | None = None, - ctx: Any | None = None, + param: TyperParameter, + ctx: Context, ) -> Any: + is_multi_value = param.multiple or param.nargs == -1 if value is None: - if self.multiple or self.nargs == -1: + if is_multi_value: return () return None - if (self.multiple or self.nargs == -1) and isinstance(value, str): - if param is None: - raise ValueError("Value must be an iterable.") + if is_multi_value and isinstance(value, str): raise BadParameter("Value must be an iterable.", ctx=ctx, param=param) return self._coerce_value(value, param=param, ctx=ctx) @@ -87,8 +78,8 @@ def _coerce_value( self, value: Any, *, - param: Any | None, - ctx: Any | None, + param: TyperParameter, + ctx: Context, ) -> Any: pass @@ -103,20 +94,14 @@ def _coerce_value( self, value: Any, *, - param: Any | None, - ctx: Any | None, + param: TyperParameter, + ctx: Context, ) -> Any: try: return self.adapter.validate_python(value) except ValidationError as exc: - if param is None: - raise - raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc except ValueError as exc: - if param is None: - raise - raise BadParameter(str(exc), ctx=ctx, param=param) from exc @@ -130,8 +115,8 @@ def _coerce_value( self, value: Any, *, - param: Any | None, - ctx: Any | None, + param: TyperParameter, + ctx: Context, ) -> Any: mode = resolve_file_mode(self.parameter_info, self.file_annotation) @@ -159,8 +144,8 @@ def _coerce_value( self, value: Any, *, - param: Any | None, - ctx: Any | None, + param: TyperParameter, + ctx: Context, ) -> Any: return coerce_cli_path( value, @@ -182,8 +167,8 @@ def _coerce_value( self, value: Any, *, - param: Any | None, - ctx: Any | None, + param: TyperParameter, + ctx: Context, ) -> Any: try: return coerce_cli_choice( @@ -193,8 +178,6 @@ def _coerce_value( ctx=ctx, ) except ValueError as exc: - if param is None: - raise raise BadParameter(str(exc), ctx=ctx, param=param) from exc @@ -219,14 +202,6 @@ def get_param(self, name: str) -> RuntimeParam | None: return runtime_param return None - def coerce(self, values: dict[str, Any]) -> dict[str, Any]: - coerced: dict[str, Any] = {} - for name, value in values.items(): - runtime_param = self.get_param(name) - if runtime_param is not None: - coerced[name] = runtime_param.coerce(value, param=None, ctx=None) - return coerced - def declare_param(param: ParamMeta) -> DeclaredParam: """Declare metadata from a function parameter.""" @@ -245,9 +220,6 @@ def declare_param(param: ParamMeta) -> DeclaredParam: default = param.default parameter_info = OptionInfo() - is_list = False - is_tuple = False - is_flag: bool | None = None pydantic_annotation: Any if param.annotation is not param.empty: @@ -270,7 +242,6 @@ def declare_param(param: ParamMeta) -> DeclaredParam: assert not get_origin(element_type), ( "List types with complex sub-types are not currently supported" ) - is_list = True pydantic_annotation = main_type elif lenient_issubclass(origin, tuple): type_args = get_args(main_type) @@ -278,7 +249,6 @@ def declare_param(param: ParamMeta) -> DeclaredParam: assert not get_origin(type_), ( "Tuple types with complex sub-types are not currently supported" ) - is_tuple = True pydantic_annotation = main_type else: pydantic_annotation = main_type @@ -295,50 +265,20 @@ def declare_param(param: ParamMeta) -> DeclaredParam: main_type = str pydantic_annotation = main_type - if isinstance(parameter_info, OptionInfo) and pydantic_annotation is bool: - is_flag = True - elif ( - is_list - and isinstance(parameter_info, OptionInfo) - and parameter_info.param_decls - and get_args(pydantic_annotation) == (bool,) - ): - for decl in parameter_info.param_decls: - if "/" in decl: - is_flag = True - break - return DeclaredParam( name=param.name, parameter_info=parameter_info, default=default, required=required, annotation=pydantic_annotation, - is_list=is_list, - is_tuple=is_tuple, - is_flag=is_flag, ) -def _runtime_param_fields( - declared: DeclaredParam, - *, - kind: ParamKind, - multiple: bool, - nargs: int, - is_bool_flag: bool, -) -> dict[str, Any]: +def _runtime_param_fields(declared: DeclaredParam) -> dict[str, Any]: return { "name": declared.name, "annotation": declared.annotation, "parameter_info": declared.parameter_info, - "kind": kind, - "multiple": multiple, - "nargs": nargs, - "is_flag": bool(declared.is_flag), - "is_bool_flag": is_bool_flag, - "required": declared.required, - "default": declared.default, } @@ -350,17 +290,8 @@ def bool_flag_runtime_param(*, name: str, default: bool = False) -> RuntimeParam default=default, required=False, annotation=bool, - is_list=False, - is_tuple=False, - is_flag=True, - ) - return runtime_param_from_declared( - declared, - kind="option", - multiple=False, - nargs=1, - is_bool_flag=True, ) + return runtime_param_from_declared(declared) def prompt_value_proc( @@ -390,21 +321,8 @@ def coerce(value: Any) -> Any: return coerce -def runtime_param_from_declared( - declared: DeclaredParam, - *, - kind: ParamKind, - multiple: bool, - nargs: int, - is_bool_flag: bool, -) -> RuntimeParam: - common = _runtime_param_fields( - declared, - kind=kind, - multiple=multiple, - nargs=nargs, - is_bool_flag=is_bool_flag, - ) +def runtime_param_from_declared(declared: DeclaredParam) -> RuntimeParam: + common = _runtime_param_fields(declared) file_annotation = file_coercion_annotation(declared.annotation) if file_annotation is not None: return FileRuntimeParam(**common, file_annotation=file_annotation) From b7531f55b22c39f6533b3bc4025c1379336ac826 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 16 Jun 2026 19:41:15 +0200 Subject: [PATCH 43/73] remove ParamType's repr functionality --- .../test_bool/test_tutorial001.py | 7 ------- .../test_datetime/test_tutorial001.py | 7 ------- .../test_index/test_tutorial001.py | 11 ---------- .../test_uuid/test_tutorial001.py | 7 ------- tests/test_type_conversion.py | 4 ---- tests/test_types.py | 7 ------- typer/param_types.py | 21 ++++++------------- 7 files changed, 6 insertions(+), 58 deletions(-) diff --git a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py index 32c07214c0..6941a35c37 100644 --- a/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_bool/test_tutorial001.py @@ -4,7 +4,6 @@ from types import ModuleType import pytest -import typer from typer.testing import CliRunner runner = CliRunner() @@ -23,12 +22,6 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: return mod -def test_type_repr(mod: ModuleType): - command = typer.main.get_command(mod.app) - force_param = next(param for param in command.params if param.name == "force") - assert repr(force_param.type) == "BOOL" - - def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index 44f844f9ce..80a01ba3b2 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -2,7 +2,6 @@ import sys from datetime import datetime -import typer from typer.testing import CliRunner from docs_src.parameter_types.datetime import tutorial001_py310 as mod @@ -11,12 +10,6 @@ app = mod.app -def test_type_repr(): - command = typer.main.get_command(app) - birth_param = next(param for param in command.params if param.name == "birth") - assert repr(birth_param.type) == "DateTime" - - def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index b0c9ea9942..ae3e634fc4 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -1,7 +1,6 @@ import subprocess import sys -import typer from typer.testing import CliRunner from docs_src.parameter_types.index import tutorial001_py310 as mod @@ -10,16 +9,6 @@ app = mod.app -def test_type_repr(): - command = typer.main.get_command(app) - age_param = next(param for param in command.params if param.name == "age") - height_meters_param = next( - param for param in command.params if param.name == "height_meters" - ) - assert repr(age_param.type) == "INT" - assert repr(height_meters_param.type) == "FLOAT" - - def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py index c4c8cfac5d..9f90d76547 100644 --- a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py @@ -2,7 +2,6 @@ import sys import uuid -import typer from typer.testing import CliRunner from docs_src.parameter_types.uuid import tutorial001_py310 as mod @@ -11,12 +10,6 @@ app = mod.app -def test_type_repr(): - command = typer.main.get_command(app) - user_id_param = next(param for param in command.params if param.name == "user_id") - assert repr(user_id_param.type) == "UUID" - - def test_main(): result = runner.invoke(app, ["d48edaa6-871a-4082-a196-4daab372d4a1"]) assert result.exit_code == 0 diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 4b667c6871..7b585faee4 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -267,10 +267,6 @@ def test_string_param_type_converts_bytes( def show(name: str = typer.Option(...)): print(name) - command = typer.main.get_command(app) - name_param = next(param for param in command.params if param.name == "name") - assert repr(name_param.type) == "STRING" - monkeypatch.setattr(_click._compat, "_get_argv_encoding", lambda: arg_enc) monkeypatch.setattr(sys, "getfilesystemencoding", lambda: system_enc) diff --git a/tests/test_types.py b/tests/test_types.py index a1c19bf266..8beb9de87b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -81,13 +81,6 @@ def test_enum_choice() -> None: assert "Hello Rick!" in result.output -def test_enum_choice_repr() -> None: - root_command = typer.main.get_command(app) - command = root_command.commands["hello-option"] - name_param = next(param for param in command.params if param.name == "name") - assert repr(name_param.type).startswith("Choice([") - - def test_enum_choice_help() -> None: result = runner.invoke(app, ["hello-argument", "--help"]) assert result.exit_code == 0 diff --git a/typer/param_types.py b/typer/param_types.py index dbd11c3d3f..73d00fc4d2 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -46,19 +46,14 @@ def __init__( self, *, name: str, - repr_name: str | None = None, metavar: str | None = None, ) -> None: self.name = name - self._repr_name = repr_name or name self._metavar = metavar def get_metavar(self, param: Parameter, ctx: Context) -> str | None: return self._metavar - def __repr__(self) -> str: - return self._repr_name - def datetime_param_type(formats: Sequence[str] | None = None) -> DisplayParamType: formats_tuple = tuple(formats) if formats is not None else None @@ -66,20 +61,19 @@ def datetime_param_type(formats: Sequence[str] | None = None) -> DisplayParamTyp return DisplayParamType( name="datetime", - repr_name="DateTime", metavar=f"[{'|'.join(metavar_formats)}]", ) -INT = DisplayParamType(name="integer", repr_name="INT") +INT = DisplayParamType(name="integer") -FLOAT = DisplayParamType(name="float", repr_name="FLOAT") +FLOAT = DisplayParamType(name="float") -BOOL = DisplayParamType(name="boolean", repr_name="BOOL") +BOOL = DisplayParamType(name="boolean") -UUID = DisplayParamType(name="uuid", repr_name="UUID") +UUID = DisplayParamType(name="uuid") -STRING = DisplayParamType(name="text", repr_name="STRING") +STRING = DisplayParamType(name="text") class FileDisplayType(DisplayParamType): @@ -91,7 +85,7 @@ def shell_complete( return [CompletionItem(incomplete, type="file")] -FILE = FileDisplayType(name="filename", repr_name="File") +FILE = FileDisplayType(name="filename") CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) @@ -210,9 +204,6 @@ def get_invalid_choice_message(self, value: Any, ctx: Context | None) -> str: choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) return f"{value!r} is not one of {choices_str}." - def __repr__(self) -> str: - return f"Choice({list(self.choices)})" - def _choice_as_str(self, choice: ParamTypeValue) -> str: if isinstance(choice, Enum): return str(choice.value) From f98d3110a925f39df60a94b47176d7dfab7fc64a Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 16 Jun 2026 23:42:49 +0200 Subject: [PATCH 44/73] consolidate metavar functionality and discard the short-lived DisplayParamType --- tests/assets/cli/multiapp-docs-title.md | 4 +- tests/assets/cli/multiapp-docs.md | 4 +- tests/test_core.py | 1 - .../test_first_steps/test_tutorial005.py | 2 +- .../test_help/test_tutorial003.py | 2 +- .../test_help/test_tutorial004.py | 2 +- .../test_name/test_tutorial001.py | 2 +- .../test_name/test_tutorial002.py | 2 +- .../test_name/test_tutorial003.py | 2 +- .../test_name/test_tutorial004.py | 2 +- .../test_name/test_tutorial005.py | 2 +- .../test_password/test_tutorial001.py | 2 +- .../test_password/test_tutorial002.py | 2 +- .../test_prompt/test_tutorial001.py | 2 +- .../test_prompt/test_tutorial002.py | 2 +- .../test_prompt/test_tutorial003.py | 2 +- .../test_tutorial001_tutorial002.py | 4 +- .../test_index/test_tutorial001.py | 2 +- tests/test_type_conversion.py | 94 +++--- typer/_click/core.py | 14 - typer/_click/types.py | 5 - typer/core.py | 31 +- typer/param_types.py | 279 +++++++++++------- typer/rich_utils.py | 14 +- typer/schema.py | 6 + 25 files changed, 243 insertions(+), 241 deletions(-) diff --git a/tests/assets/cli/multiapp-docs-title.md b/tests/assets/cli/multiapp-docs-title.md index ffde843736..49b9cdb5c1 100644 --- a/tests/assets/cli/multiapp-docs-title.md +++ b/tests/assets/cli/multiapp-docs-title.md @@ -65,8 +65,8 @@ $ multiapp sub hello [OPTIONS] **Options**: -* `--name TEXT`: [default: World] -* `--age INTEGER`: The age of the user [default: 0] +* `--name STR`: [default: World] +* `--age INT`: The age of the user [default: 0] * `--help`: Show this message and exit. ### `multiapp sub hi` diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index 67d02568db..3de4e39668 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -65,8 +65,8 @@ $ multiapp sub hello [OPTIONS] **Options**: -* `--name TEXT`: [default: World] -* `--age INTEGER`: The age of the user [default: 0] +* `--name STR`: [default: World] +* `--age INT`: The age of the user [default: 0] * `--help`: Show this message and exit. ### `multiapp sub hi` diff --git a/tests/test_core.py b/tests/test_core.py index c2104bbda6..addeb17fa2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -99,7 +99,6 @@ def test_parameter_constructor() -> None: required=False, count=True, ) - assert option.type.name == "int range" assert option.min == 0 diff --git a/tests/test_tutorial/test_first_steps/test_tutorial005.py b/tests/test_tutorial/test_first_steps/test_tutorial005.py index 87ce389410..a8675b9504 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial005.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial005.py @@ -19,7 +19,7 @@ def test_help(): assert "NAME" in result.output assert "[required]" in result.output assert "--lastname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "--formal" in result.output assert "--no-formal" in result.output diff --git a/tests/test_tutorial/test_options/test_help/test_tutorial003.py b/tests/test_tutorial/test_options/test_help/test_tutorial003.py index de99469dfa..934fbdc160 100644 --- a/tests/test_tutorial/test_options/test_help/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_help/test_tutorial003.py @@ -32,7 +32,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--fullname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[default: Wade Wilson]" not in result.output diff --git a/tests/test_tutorial/test_options/test_help/test_tutorial004.py b/tests/test_tutorial/test_options/test_help/test_tutorial004.py index 22f902197b..d0bdedc4ea 100644 --- a/tests/test_tutorial/test_options/test_help/test_tutorial004.py +++ b/tests/test_tutorial/test_options/test_help/test_tutorial004.py @@ -34,7 +34,7 @@ def test_help(monkeypatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--fullname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[default: (Deadpoolio the amazing's name)]" in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial001.py b/tests/test_tutorial/test_options/test_name/test_tutorial001.py index 1c4f2a7cf0..8b32b6714d 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial001.py @@ -26,7 +26,7 @@ def test_option_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--name" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "--user-name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial002.py b/tests/test_tutorial/test_options/test_name/test_tutorial002.py index ac9b3db6f1..6b4224a283 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial002.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--name" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "--user-name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial003.py b/tests/test_tutorial/test_options/test_name/test_tutorial003.py index 0503b410af..99e168d7da 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial003.py @@ -26,7 +26,7 @@ def test_option_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "-n" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "--user-name" not in result.output assert "--name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial004.py b/tests/test_tutorial/test_options/test_name/test_tutorial004.py index f200021042..126e8f1e44 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial004.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial004.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--user-name" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "--name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial005.py b/tests/test_tutorial/test_options/test_name/test_tutorial005.py index 492a0fc4ef..4eb44c8d2a 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial005.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial005.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--name" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "-f" in result.output assert "--formal" in result.output diff --git a/tests/test_tutorial/test_options/test_password/test_tutorial001.py b/tests/test_tutorial/test_options/test_password/test_tutorial001.py index 4aca1c0bd8..d39970e4d7 100644 --- a/tests/test_tutorial/test_options/test_password/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_password/test_tutorial001.py @@ -44,7 +44,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 output_without_double_spaces = strip_double_spaces(result.output) - assert "--email TEXT [required]" in output_without_double_spaces + assert "--email STR [required]" in output_without_double_spaces def test_script(mod: ModuleType): diff --git a/tests/test_tutorial/test_options/test_password/test_tutorial002.py b/tests/test_tutorial/test_options/test_password/test_tutorial002.py index 08d43ff87e..afd9e58302 100644 --- a/tests/test_tutorial/test_options/test_password/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_password/test_tutorial002.py @@ -48,7 +48,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 output_without_double_spaces = strip_double_spaces(result.output) - assert "--password TEXT [required]" in output_without_double_spaces + assert "--password STR [required]" in output_without_double_spaces def test_script(mod: ModuleType): diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py index 1236f33e57..53bdbf096a 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py @@ -39,7 +39,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py index 0947a5e77e..97f2630bea 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py @@ -39,7 +39,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py index 6016dc4acf..dee2e3fab6 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py @@ -48,7 +48,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--project-name" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py b/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py index 72ad6e04cd..94570b7bef 100644 --- a/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py +++ b/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py @@ -41,7 +41,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[required]" in result.output @@ -50,7 +50,7 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "TEXT" in result.output + assert "STR" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index ae3e634fc4..65ef1afcff 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -13,7 +13,7 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INTEGER" in result.output + assert "INT" in result.output assert "--height-meters" in result.output assert "FLOAT" in result.output diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 7b585faee4..7d55052d92 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -2,20 +2,12 @@ import sys from enum import Enum from pathlib import Path -from typing import Any +from typing import Annotated, Any, get_args, get_origin import pytest import typer from typer import _click, param_types -from typer.param_types import ( - BOOL, - FLOAT, - INT, - STRING, - TyperPath, - TyperTuple, - resolve_param_type, -) +from typer.param_types import TyperPath from typer.testing import CliRunner from tests.utils import needs_linux, needs_windows @@ -345,20 +337,19 @@ def fake_access(path: str, mode: int) -> bool: @pytest.mark.parametrize( - ("default", "expected_param_type", "expected_value", "value_type"), + ("default", "expected_annotation"), [ - (42, INT, 42, int), - (0.5, FLOAT, 0.5, float), - ("morty", STRING, "morty", str), - (False, BOOL, False, bool), - ("False", STRING, "False", str), + (42, int), + (0.5, float), + ("morty", str), + (False, bool), + ("False", str), + ((1, "x"), tuple[int, str]), ], ) def test_default_infers_param_type( default: Any, - expected_param_type: Any, - expected_value: Any, - value_type: type, + expected_annotation: Any, ) -> None: app = typer.Typer() seen: dict[str, Any] = {} @@ -368,49 +359,42 @@ def cmd(val=default): seen["val"] = val param = next(p for p in typer.main.get_command(app).params if p.name == "val") - assert param.type is expected_param_type + assert param.runtime_param is not None + if get_origin(expected_annotation) is tuple: + assert get_origin(param.runtime_param.annotation) is tuple + assert get_args(param.runtime_param.annotation) == get_args(expected_annotation) + else: + assert param.runtime_param.annotation is expected_annotation result = runner.invoke(app) assert result.exit_code == 0, result.output - assert seen["val"] == expected_value - assert type(seen["val"]) is value_type - - -def test_convert_type(): - # str - assert resolve_param_type(str) is STRING - assert resolve_param_type(None) is STRING - assert resolve_param_type(None, default=["a"]) is STRING - - # tuples - tuple_type = resolve_param_type((str, int)) - assert isinstance(tuple_type, TyperTuple) - assert [type(item) for item in tuple_type.types] == [type(STRING), type(INT)] - - guessed_tuple = resolve_param_type(None, default=[(1, "x")]) - assert isinstance(guessed_tuple, TyperTuple) - assert [type(item) for item in guessed_tuple.types] == [ - type(INT), - type(STRING), - ] - - # numbers - assert resolve_param_type(int) is INT - assert resolve_param_type(float) is FLOAT - assert resolve_param_type(bool) is BOOL + assert seen["val"] == default + if expected_annotation in (int, float, bool, str): + assert type(seen["val"]) is expected_annotation + elif get_origin(expected_annotation) is tuple: + assert isinstance(seen["val"], tuple) - guessed_int = resolve_param_type(None, default=42) - assert guessed_int is INT - # custom type - class CustomType: - pass +@pytest.mark.parametrize( + ("annotation", "expected_metavar"), + [ + (str, "STR"), + (int, "INT"), + (float, "FLOAT"), + (tuple[str, int], ""), + ], +) +def test_param_type_help_metavar(annotation: type, expected_metavar: str) -> None: + app = typer.Typer() + option = Annotated[annotation, typer.Option(...)] - guessed_unknown = resolve_param_type(None, default=CustomType()) - assert guessed_unknown is STRING + @app.command() + def main(value: option): + pass # pragma: no cover - func_type = resolve_param_type(CustomType) - assert func_type is STRING + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert expected_metavar in result.output def test_int_rejects_float_default() -> None: diff --git a/typer/_click/core.py b/typer/_click/core.py index fd2cb19c15..1a9006ac8f 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -879,20 +879,6 @@ def human_readable_name(self) -> str: assert self.name is not None, "self.name should be set" return self.name - def make_metavar(self, ctx: Context) -> str: - if self.metavar is not None: - return self.metavar - - metavar = self.type.get_metavar(param=self, ctx=ctx) - - if metavar is None: - metavar = self.type.name.upper() - - if self.nargs != 1: - metavar += "..." - - return metavar - @overload def get_default(self, ctx: Context, call: Literal[True] = True) -> Any | None: ... diff --git a/typer/_click/types.py b/typer/_click/types.py index 8fdc1b7098..c11c1bd354 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -17,7 +17,6 @@ class ParamType: """Display and plumbing metadata for a CLI parameter type.""" is_composite: ClassVar[bool] = False - name: str @property def arity(self) -> int: @@ -31,10 +30,6 @@ def arity(self) -> int: # Windows). envvar_list_splitter: ClassVar[str | None] = None - def get_metavar(self, param: "Parameter", ctx: "Context") -> str | None: - """Returns the metavar default for this param if it provides one.""" - pass # pragma: no cover - def get_missing_message( self, param: "Parameter", ctx: Union["Context", None] ) -> str | None: diff --git a/typer/core.py b/typer/core.py index 73a534335f..d117f8cda1 100644 --- a/typer/core.py +++ b/typer/core.py @@ -130,6 +130,9 @@ def process_value(self, ctx: _click.Context, value: Any) -> Any: def value_is_missing(self, value: Any) -> bool: return _value_is_missing(self, value) + def make_metavar(self, ctx: _click.Context) -> str: + return param_types.resolve_metavar(self, ctx=ctx) + def _get_default_string( obj: Union["TyperArgument", "TyperOption"], @@ -440,24 +443,6 @@ def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: help = f"{help} {extra_str}" if help else f"{extra_str}" return name, help - def make_metavar(self, ctx: _click.Context) -> str: - # Modified version of _click.core.Argument.make_metavar() - # to include Argument name - if self.metavar is not None: - var = self.metavar - if not self.required and not var.startswith("["): - var = f"[{var}]" - return var - var = (self.name or "").upper() - if not self.required: - var = f"[{var}]" - type_var = self.type.get_metavar(self, ctx=ctx) - if type_var: - var += f":{type_var}" - if self.nargs != 1: - var += "..." - return var - def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -572,11 +557,12 @@ def __init__( self.hidden = hidden # TODO: revisit all of this flag stuff - if is_flag and type is None: - self.type: types.ParamType = param_types.BOOL + inferred_bool_flag = bool(is_flag and type is None and not count) + if inferred_bool_flag: + self.type: types.ParamType = param_types.DEFAULT_PARAM_TYPE self.is_flag: bool = bool(is_flag) - self.is_bool_flag: bool = bool(is_flag and self.type is param_types.BOOL) + self.is_bool_flag: bool = inferred_bool_flag if self.is_flag: self._depr_flag_value = True @@ -815,9 +801,6 @@ def _extract_default_help_str( ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) - def make_metavar(self, ctx: _click.Context) -> str: - return super().make_metavar(ctx=ctx) - def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: # Duplicate all of Click's logic only to modify a single line, to allow boolean # flags with only names for False values as it's currently supported by Typer diff --git a/typer/param_types.py b/typer/param_types.py index 73d00fc4d2..059f0a4633 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -5,7 +5,6 @@ from enum import Enum from pathlib import Path from typing import IO, Any, ClassVar, Generic, TypeGuard, TypeVar, cast -from uuid import UUID as UUIDType from pydantic import TypeAdapter, ValidationError @@ -39,44 +38,24 @@ def _get_error_msg(exc: ValidationError) -> str: return str(exc) -class DisplayParamType(types.ParamType): - """Used for metavar/help only.""" +DEFAULT_PARAM_TYPE = types.ParamType() - def __init__( - self, - *, - name: str, - metavar: str | None = None, - ) -> None: - self.name = name - self._metavar = metavar - - def get_metavar(self, param: Parameter, ctx: Context) -> str | None: - return self._metavar - - -def datetime_param_type(formats: Sequence[str] | None = None) -> DisplayParamType: - formats_tuple = tuple(formats) if formats is not None else None - metavar_formats = formats_tuple or ["%Y-%m-%d"] - - return DisplayParamType( - name="datetime", - metavar=f"[{'|'.join(metavar_formats)}]", - ) +class _DatetimeDisplayType(types.ParamType): + def __init__(self, *, formats: tuple[str, ...]) -> None: + self.formats = formats -INT = DisplayParamType(name="integer") -FLOAT = DisplayParamType(name="float") +def datetime_value_metavar(formats: Sequence[str]) -> str: + return f"[{'|'.join(formats)}]" -BOOL = DisplayParamType(name="boolean") -UUID = DisplayParamType(name="uuid") +def datetime_param_type(formats: Sequence[str] | None = None) -> _DatetimeDisplayType: + formats_tuple = tuple(formats) if formats is not None else ("%Y-%m-%d",) + return _DatetimeDisplayType(formats=formats_tuple) -STRING = DisplayParamType(name="text") - -class FileDisplayType(DisplayParamType): +class FileDisplayType(types.ParamType): envvar_list_splitter = os.path.pathsep def shell_complete( @@ -85,14 +64,68 @@ def shell_complete( return [CompletionItem(incomplete, type="file")] -FILE = FileDisplayType(name="filename") +FILE = FileDisplayType() + + +class _RangedNumberParamType(types.ParamType): + def __init__(self, annotation: type[Any]) -> None: + self.annotation = annotation + + +def _param_annotation(param: Parameter) -> Any | None: + runtime_param = getattr(param, "runtime_param", None) + if runtime_param is not None: + return runtime_param.annotation + return None + + +def _annotation_metavar_label(annotation: Any) -> str: + if annotation is None: + return "STR" + origin = get_origin(annotation) + if origin is list: + args = get_args(annotation) + if len(args) == 1: + return _annotation_metavar_label(args[0]) + if origin is tuple: + labels = [_annotation_metavar_label(arg) for arg in get_args(annotation)] + return f"<{' '.join(labels)}>" + if isinstance(annotation, type): + return annotation.__name__.upper() + return str(annotation).upper() + + +def param_type_metavar_label( + param_type: types.ParamType, + *, + annotation: Any | None = None, +) -> str: + if isinstance(param_type, _DatetimeDisplayType): + return datetime_value_metavar(param_type.formats) + if isinstance(param_type, _RangedNumberParamType): + return f"{param_type.annotation.__name__.upper()} RANGE" + if isinstance(param_type, TyperTuple): + labels = [ + _annotation_metavar_label(element) + for element in param_type.element_annotations + ] + return f"<{' '.join(labels)}>" + if isinstance(param_type, TyperPath): + if param_type.file_okay and not param_type.dir_okay: + return "FILE" + if param_type.dir_okay and not param_type.file_okay: + return "DIRECTORY" + return "PATH" + if annotation is not None: + return _annotation_metavar_label(annotation) + return "STR" + CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) def normalize_choice_value( choice: Any, - *, case_sensitive: bool, ctx: Context | None, ) -> str: @@ -114,10 +147,9 @@ def coerce_cli_choice( if any(isinstance(choice, Enum) and value is choice for choice in choices): return value normalized_mapping = { - choice: normalize_choice_value(choice, case_sensitive=case_sensitive, ctx=ctx) - for choice in choices + c: normalize_choice_value(c, case_sensitive, ctx) for c in choices } - normed_value = normalize_choice_value(value, case_sensitive=case_sensitive, ctx=ctx) + normed_value = normalize_choice_value(value, case_sensitive, ctx) for original, normalized in normalized_mapping.items(): if normalized == normed_value: return original @@ -141,13 +173,12 @@ class TyperTuple(types.ParamType): is_composite = True - def __init__(self, element_types: Sequence[types.ParamType]) -> None: - self.types: tuple[types.ParamType, ...] = tuple(element_types) - self.name = f"<{' '.join(t.name for t in self.types)}>" + def __init__(self, element_annotations: Sequence[Any]) -> None: + self.element_annotations: tuple[Any, ...] = tuple(element_annotations) @property def arity(self) -> int: - return len(self.types) + return len(self.element_annotations) class TyperChoice(types.ParamType, Generic[ParamTypeValue]): @@ -172,27 +203,7 @@ def _normalized_mapping( } def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: - return normalize_choice_value( - choice, case_sensitive=self.case_sensitive, ctx=ctx - ) - - def get_metavar(self, param: Parameter, ctx: Context) -> str | None: - if param.param_type_name == "option" and not param.show_choices: # type: ignore - choice_metavars = [ - resolve_param_type(type(choice)).name.upper() for choice in self.choices - ] - choices_str = "|".join([*dict.fromkeys(choice_metavars)]) - else: - choices_str = "|".join( - [str(i) for i in self._normalized_mapping(ctx=ctx).values()] - ) - - # Use curly braces to indicate a required argument. - if param.required and param.param_type_name == "argument": - return f"{{{choices_str}}}" - - # Use square braces to indicate an option or optional argument. - return f"[{choices_str}]" + return normalize_choice_value(choice, self.case_sensitive, ctx) def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: """Message shown when no choice is passed.""" @@ -248,13 +259,6 @@ def __init__( self.allow_dash = allow_dash self.path_type = path_type - if self.file_okay and not self.dir_okay: - self.name = "file" - elif self.dir_okay and not self.file_okay: - self.name = "directory" - else: - self.name = "path" - def shell_complete( self, ctx: Context, param: Parameter, incomplete: str ) -> list[CompletionItem]: @@ -490,8 +494,8 @@ def _file_param_type() -> FileDisplayType: return FILE -def _ranged_number_param_type(number_class: type[Any]) -> types.ParamType: - return DisplayParamType(name=f"{number_class.__name__} range") +def _ranged_number_param_type(annotation: type[Any]) -> _RangedNumberParamType: + return _RangedNumberParamType(annotation) def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: @@ -505,6 +509,9 @@ def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: """Infer a type from a default value. Returns (annotation, guessed).""" + if isinstance(default, tuple) and default: + if not isinstance(default[0], (tuple, list)): + return tuple(map(type, default)), True if isinstance(default, (tuple, list)): if not default: return None, True @@ -522,22 +529,11 @@ def resolve_param_type( parameter_info: ParameterInfo | None = None, ) -> types.ParamType: """Resolve a display ParamType for metavar/help.""" - guessed_type = False if annotation is None and default is not None: - annotation, guessed_type = infer_type_from_default(default) + annotation, _ = infer_type_from_default(default) if isinstance(annotation, tuple): - element_types: list[types.ParamType] = [] - for element in annotation: - if isinstance(element, types.ParamType): - element_types.append(element) - else: - element_types.append( - resolve_param_type( - annotation=element, parameter_info=parameter_info - ) - ) - return TyperTuple(element_types) + return TyperTuple(annotation) if isinstance(annotation, types.ParamType): return annotation @@ -547,19 +543,7 @@ def resolve_param_type( if param_type is not None: return param_type - if annotation is str or annotation is None: - return STRING - if annotation is int: - return INT - if annotation is float: - return FLOAT - if annotation is bool: - return BOOL - - if guessed_type: - return STRING - - return STRING + return DEFAULT_PARAM_TYPE def cli_param_type( @@ -595,27 +579,100 @@ def param_type_from_annotation( if annotation is int or annotation is float: if parameter_info.min is not None or parameter_info.max is not None: return _ranged_number_param_type(annotation) - return INT if annotation is int else FLOAT - if annotation is UUIDType: - return UUID + return None if annotation is datetime: return datetime_param_type(formats=parameter_info.formats) - if annotation is bool: - return BOOL if _needs_typer_path(annotation, parameter_info): return typer_path_display_type(annotation, parameter_info) if lenient_issubclass(annotation, Enum): - return TyperChoice( - list(annotation), - case_sensitive=parameter_info.case_sensitive, - ) + return TyperChoice(list(annotation), parameter_info.case_sensitive) if is_literal_type(annotation): - return TyperChoice( - literal_values(annotation), - case_sensitive=parameter_info.case_sensitive, - ) - if annotation is str: - return STRING + return TyperChoice(literal_values(annotation), parameter_info.case_sensitive) if lenient_issubclass(annotation, CLI_FILE_TYPES): return _file_param_type() return None + + +def choice_value_metavar( + param: Parameter, + ctx: Context, + *, + choices: Sequence[Any], + case_sensitive: bool, +) -> str: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + metavars = [_annotation_metavar_label(type(c)) for c in choices] + choices_str = "|".join([*dict.fromkeys(metavars)]) + else: + normalized_mapping = { + c: normalize_choice_value(c, case_sensitive, ctx) for c in choices + } + choices_str = "|".join(normalized_mapping.values()) + + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + return f"[{choices_str}]" + + +def resolve_value_metavar(param: Parameter, ctx: Context) -> str | None: + param_type = param.type + if isinstance(param_type, TyperChoice): + return choice_value_metavar( + param, + ctx, + choices=param_type.choices, + case_sensitive=param_type.case_sensitive, + ) + if isinstance(param_type, _DatetimeDisplayType): + return datetime_value_metavar(param_type.formats) + if getattr(param, "param_type_name", None) == "argument": + return None + return param_type_metavar_label(param_type, annotation=_param_annotation(param)) + + +def _format_option_metavar(param: Parameter, value_metavar: str) -> str: + if param.nargs != 1: + value_metavar += "..." + return value_metavar + + +def _format_argument_metavar(param: Parameter, value_metavar: str | None) -> str: + var = (param.name or "").upper() + if not param.required: + var = f"[{var}]" + if value_metavar: + var += f":{value_metavar}" + if param.nargs != 1: + var += "..." + return var + + +def resolve_metavar(param: Parameter, ctx: Context) -> str: + if param.metavar is not None: + var = param.metavar + if getattr(param, "param_type_name", None) == "argument": + if not param.required and not var.startswith("["): + var = f"[{var}]" + return var + + value_metavar = resolve_value_metavar(param, ctx) + if getattr(param, "param_type_name", None) == "argument": + return _format_argument_metavar(param, value_metavar) + assert value_metavar is not None + return _format_option_metavar(param, value_metavar) + + +def resolve_rich_metavar(param: Parameter, ctx: Context) -> str | None: + metavar_str = resolve_metavar(param, ctx) + if ( + getattr(param, "param_type_name", None) == "argument" + and param.name + and metavar_str == param.name.upper() + ): + metavar_str = param_type_metavar_label( + param.type, annotation=_param_annotation(param) + ) + if metavar_str == "BOOL": + return None + return metavar_str diff --git a/typer/rich_utils.py b/typer/rich_utils.py index f8fbf7fd9a..0684259a04 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -31,6 +31,7 @@ TyperOption, get_number_range_help_str, ) +from .param_types import resolve_rich_metavar # Default styles STYLE_OPTION = "bold cyan" @@ -381,17 +382,8 @@ def _print_options_panel( # Column for a metavar, if we have one metavar = Text(style=STYLE_METAVAR, overflow="fold") - metavar_str = param.make_metavar(ctx=ctx) - # Do it ourselves if this is a positional argument - if ( - isinstance(param, TyperArgument) - and param.name - and metavar_str == param.name.upper() - ): - metavar_str = param.type.name.upper() - - # Skip booleans and choices (handled above) - if metavar_str != "BOOLEAN": + metavar_str = resolve_rich_metavar(param, ctx=ctx) + if metavar_str is not None: metavar.append(metavar_str) range_str = get_number_range_help_str(param) diff --git a/typer/schema.py b/typer/schema.py index fb3399f5c1..2111f5780e 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -259,6 +259,12 @@ def declare_param(param: ParamMeta) -> DeclaredParam: main_type, guessed = infer_type_from_default(default) if main_type is None: main_type = str + elif ( + guessed + and isinstance(main_type, tuple) + and all(isinstance(item, type) for item in main_type) + ): + main_type = tuple.__class_getitem__(main_type) elif guessed and main_type not in (int, float, bool, str): main_type = str else: From 46e3fec49773fac84d9fcdefce8224cc07ebac85 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 17 Jun 2026 12:37:07 +0200 Subject: [PATCH 45/73] return metavar in <> and lowercased --- tests/assets/cli/multiapp-docs-title.md | 4 ++-- tests/assets/cli/multiapp-docs.md | 4 ++-- .../test_first_steps/test_tutorial005.py | 2 +- .../test_help/test_tutorial001.py | 3 ++- .../test_help/test_tutorial003.py | 2 +- .../test_help/test_tutorial004.py | 2 +- .../test_name/test_tutorial001.py | 2 +- .../test_name/test_tutorial002.py | 2 +- .../test_name/test_tutorial003.py | 2 +- .../test_name/test_tutorial004.py | 2 +- .../test_name/test_tutorial005.py | 2 +- .../test_password/test_tutorial001.py | 2 +- .../test_password/test_tutorial002.py | 2 +- .../test_prompt/test_tutorial001.py | 2 +- .../test_prompt/test_tutorial002.py | 2 +- .../test_prompt/test_tutorial003.py | 2 +- .../test_tutorial001_tutorial002.py | 4 ++-- .../test_index/test_tutorial001.py | 4 ++-- tests/test_type_conversion.py | 8 +++---- typer/param_types.py | 23 +++++++++++-------- 20 files changed, 41 insertions(+), 35 deletions(-) diff --git a/tests/assets/cli/multiapp-docs-title.md b/tests/assets/cli/multiapp-docs-title.md index 49b9cdb5c1..1bcd798ca2 100644 --- a/tests/assets/cli/multiapp-docs-title.md +++ b/tests/assets/cli/multiapp-docs-title.md @@ -65,8 +65,8 @@ $ multiapp sub hello [OPTIONS] **Options**: -* `--name STR`: [default: World] -* `--age INT`: The age of the user [default: 0] +* `--name `: [default: World] +* `--age `: The age of the user [default: 0] * `--help`: Show this message and exit. ### `multiapp sub hi` diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index 3de4e39668..08d708332c 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -65,8 +65,8 @@ $ multiapp sub hello [OPTIONS] **Options**: -* `--name STR`: [default: World] -* `--age INT`: The age of the user [default: 0] +* `--name `: [default: World] +* `--age `: The age of the user [default: 0] * `--help`: Show this message and exit. ### `multiapp sub hi` diff --git a/tests/test_tutorial/test_first_steps/test_tutorial005.py b/tests/test_tutorial/test_first_steps/test_tutorial005.py index a8675b9504..05c38a11fc 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial005.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial005.py @@ -19,7 +19,7 @@ def test_help(): assert "NAME" in result.output assert "[required]" in result.output assert "--lastname" in result.output - assert "STR" in result.output + assert "" in result.output assert "--formal" in result.output assert "--no-formal" in result.output diff --git a/tests/test_tutorial/test_options/test_help/test_tutorial001.py b/tests/test_tutorial/test_options/test_help/test_tutorial001.py index 067bdffe51..6432aa035e 100644 --- a/tests/test_tutorial/test_options/test_help/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_help/test_tutorial001.py @@ -22,7 +22,8 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: return mod -def test_help(mod: ModuleType): +def test_help(mod: ModuleType, monkeypatch): + monkeypatch.setenv("COLUMNS", "200") result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "Say hi to NAME, optionally with a --lastname." in result.output diff --git a/tests/test_tutorial/test_options/test_help/test_tutorial003.py b/tests/test_tutorial/test_options/test_help/test_tutorial003.py index 934fbdc160..4bb1cc10a9 100644 --- a/tests/test_tutorial/test_options/test_help/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_help/test_tutorial003.py @@ -32,7 +32,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--fullname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[default: Wade Wilson]" not in result.output diff --git a/tests/test_tutorial/test_options/test_help/test_tutorial004.py b/tests/test_tutorial/test_options/test_help/test_tutorial004.py index d0bdedc4ea..5936ebca97 100644 --- a/tests/test_tutorial/test_options/test_help/test_tutorial004.py +++ b/tests/test_tutorial/test_options/test_help/test_tutorial004.py @@ -34,7 +34,7 @@ def test_help(monkeypatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--fullname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[default: (Deadpoolio the amazing's name)]" in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial001.py b/tests/test_tutorial/test_options/test_name/test_tutorial001.py index 8b32b6714d..d375a92bfd 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial001.py @@ -26,7 +26,7 @@ def test_option_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--name" in result.output - assert "STR" in result.output + assert "" in result.output assert "--user-name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial002.py b/tests/test_tutorial/test_options/test_name/test_tutorial002.py index 6b4224a283..9e0dcb4892 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial002.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--name" in result.output - assert "STR" in result.output + assert "" in result.output assert "--user-name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial003.py b/tests/test_tutorial/test_options/test_name/test_tutorial003.py index 99e168d7da..e20ca92a63 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial003.py @@ -26,7 +26,7 @@ def test_option_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "-n" in result.output - assert "STR" in result.output + assert "" in result.output assert "--user-name" not in result.output assert "--name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial004.py b/tests/test_tutorial/test_options/test_name/test_tutorial004.py index 126e8f1e44..862890b9c7 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial004.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial004.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--user-name" in result.output - assert "STR" in result.output + assert "" in result.output assert "--name" not in result.output diff --git a/tests/test_tutorial/test_options/test_name/test_tutorial005.py b/tests/test_tutorial/test_options/test_name/test_tutorial005.py index 4eb44c8d2a..520073b98e 100644 --- a/tests/test_tutorial/test_options/test_name/test_tutorial005.py +++ b/tests/test_tutorial/test_options/test_name/test_tutorial005.py @@ -27,7 +27,7 @@ def test_option_help(mod: ModuleType): assert result.exit_code == 0 assert "-n" in result.output assert "--name" in result.output - assert "STR" in result.output + assert "" in result.output assert "-f" in result.output assert "--formal" in result.output diff --git a/tests/test_tutorial/test_options/test_password/test_tutorial001.py b/tests/test_tutorial/test_options/test_password/test_tutorial001.py index d39970e4d7..8374a497a3 100644 --- a/tests/test_tutorial/test_options/test_password/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_password/test_tutorial001.py @@ -44,7 +44,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 output_without_double_spaces = strip_double_spaces(result.output) - assert "--email STR [required]" in output_without_double_spaces + assert "--email [required]" in output_without_double_spaces def test_script(mod: ModuleType): diff --git a/tests/test_tutorial/test_options/test_password/test_tutorial002.py b/tests/test_tutorial/test_options/test_password/test_tutorial002.py index afd9e58302..4b63dbc20c 100644 --- a/tests/test_tutorial/test_options/test_password/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_password/test_tutorial002.py @@ -48,7 +48,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 output_without_double_spaces = strip_double_spaces(result.output) - assert "--password STR [required]" in output_without_double_spaces + assert "--password [required]" in output_without_double_spaces def test_script(mod: ModuleType): diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py index 53bdbf096a..c0d1e5770c 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial001.py @@ -39,7 +39,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py index 97f2630bea..42def22bd6 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial002.py @@ -39,7 +39,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py index dee2e3fab6..daefde09fa 100644 --- a/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py +++ b/tests/test_tutorial/test_options/test_prompt/test_tutorial003.py @@ -48,7 +48,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--project-name" in result.output - assert "STR" in result.output + assert "" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py b/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py index 94570b7bef..5003c80ae3 100644 --- a/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py +++ b/tests/test_tutorial/test_options/test_required/test_tutorial001_tutorial002.py @@ -41,7 +41,7 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[required]" in result.output @@ -50,7 +50,7 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--lastname" in result.output - assert "STR" in result.output + assert "" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py index 65ef1afcff..9832e47045 100644 --- a/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_index/test_tutorial001.py @@ -13,9 +13,9 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INT" in result.output + assert "" in result.output assert "--height-meters" in result.output - assert "FLOAT" in result.output + assert "" in result.output def test_params(): diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 7d55052d92..f41f434149 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -378,10 +378,10 @@ def cmd(val=default): @pytest.mark.parametrize( ("annotation", "expected_metavar"), [ - (str, "STR"), - (int, "INT"), - (float, "FLOAT"), - (tuple[str, int], ""), + (str, ""), + (int, ""), + (float, ""), + (tuple[str, int], ""), ], ) def test_param_type_help_metavar(annotation: type, expected_metavar: str) -> None: diff --git a/typer/param_types.py b/typer/param_types.py index 059f0a4633..a11ebe58f2 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -79,20 +79,25 @@ def _param_annotation(param: Parameter) -> Any | None: return None -def _annotation_metavar_label(annotation: Any) -> str: +def _annotation_metavar_label_bare(annotation: Any) -> str: + display_type = str(annotation) if annotation is None: - return "STR" + display_type = "str" origin = get_origin(annotation) if origin is list: args = get_args(annotation) if len(args) == 1: - return _annotation_metavar_label(args[0]) + display_type = _annotation_metavar_label_bare(args[0]) if origin is tuple: - labels = [_annotation_metavar_label(arg) for arg in get_args(annotation)] - return f"<{' '.join(labels)}>" + labels = [_annotation_metavar_label_bare(arg) for arg in get_args(annotation)] + display_type = ",".join(labels) if isinstance(annotation, type): - return annotation.__name__.upper() - return str(annotation).upper() + display_type = annotation.__name__ + return display_type + + +def _annotation_metavar_label(annotation: Any) -> str: + return f"<{_annotation_metavar_label_bare(annotation)}>" def param_type_metavar_label( @@ -106,10 +111,10 @@ def param_type_metavar_label( return f"{param_type.annotation.__name__.upper()} RANGE" if isinstance(param_type, TyperTuple): labels = [ - _annotation_metavar_label(element) + _annotation_metavar_label_bare(element) for element in param_type.element_annotations ] - return f"<{' '.join(labels)}>" + return f"<{','.join(labels)}>" if isinstance(param_type, TyperPath): if param_type.file_okay and not param_type.dir_okay: return "FILE" From 1e6ff44ca0c176b8f4f67b7729f878d4287d5277 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 17 Jun 2026 14:49:56 +0200 Subject: [PATCH 46/73] removing CommandSchema for now and make RuntimeParam required --- tests/test_core.py | 129 +++++++++++++++++----------------- tests/test_schema.py | 15 ++-- tests/test_type_conversion.py | 1 + typer/_click/decorators.py | 2 + typer/adapters.py | 37 ++++------ typer/core.py | 40 ++--------- typer/main.py | 4 +- typer/schema.py | 24 +------ 8 files changed, 94 insertions(+), 158 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index addeb17fa2..856fb195fe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,7 +5,7 @@ import typer._completion_shared import typer.completion from typer import _click -from typer.core import TyperArgument, TyperCommand, TyperGroup, TyperOption, _split_opt +from typer.core import TyperCommand, TyperGroup, _split_opt from typer.testing import CliRunner runner = CliRunner() @@ -42,75 +42,62 @@ def cmd(name: Annotated[str, typer.Option(metavar="CUSTOM")]) -> None: assert "--name CUSTOM" in result.output -def test_parameter_nargs_gt_1() -> None: +def test_tuple_argument_wrong_arity() -> None: app = typer.Typer() @app.command() def cmd(value: tuple[str, str]): pass # pragma: no cover - param = next(p for p in typer.main.get_command(app).params if p.name == "value") - ctx = _click.Context(TyperCommand(name="cmd")) - assert param.runtime_param is not None - assert param.process_value(ctx, ("one", "two")) == ("one", "two") + result = runner.invoke(app, ["only-one"]) + assert result.exit_code == 2 + assert "takes 2 values" in result.output - with pytest.raises( - _click.exceptions.BadParameter, match="2 values are required, but 1 given" - ): - param.process_value(ctx, ("one",)) +def test_count_option() -> None: + app = typer.Typer() + + @app.command() + def main(verbose: int = typer.Option(0, "--verbose", "-v", count=True)): + print(verbose) + + result = runner.invoke(app, ["-vvv"]) + assert result.exit_code == 0 + assert "3" in result.stdout -def test_parameter_constructor() -> None: - # no param_decl and expose_value is False: sets name to None - arg = TyperArgument(param_decls=[], expose_value=False) - assert arg.name is None - assert arg.opts == [] - assert arg.secondary_opts == [] - # no param_decl and expose_value is True: raises - with pytest.raises(TypeError, match="does not have a name."): - TyperArgument(param_decls=[], expose_value=True) +def test_duplicate_declaration_raises() -> None: + app = typer.Typer() - # len(param_decl) > 1: raises - with pytest.raises(TypeError, match="take exactly one parameter declaration"): - TyperArgument(param_decls=["first", "second"]) + @app.command() + def main(name: str = typer.Option(..., "name", "name")): + pass # pragma: no cover - # duplicated identifier in option declarations: raises with pytest.raises(TypeError, match="Name 'name' defined twice"): - TyperOption(param_decls=["name", "name"], required=False) + typer.main.get_command(app) + + +def test_invalid_boolean_flag_declaration_raises() -> None: + app = typer.Typer() + + @app.command() + def main(flag: bool = typer.Option(False, "--flag/--flag")): + pass # pragma: no cover - # same true/false flag in boolean option declaration: raises with pytest.raises(ValueError, match="cannot use the same flag for true/false"): - TyperOption(param_decls=["flag", "--flag/--flag"], required=False, is_flag=True) - - # inferred name is not a valid identifier: sets name to None - unnamed_option = TyperOption(param_decls=["--123"], required=False) - assert unnamed_option.name is None - - # no param_decl and prompt=True: raises - with pytest.raises(TypeError, match="'name' is required with 'prompt=True'."): - TyperOption(param_decls=[], expose_value=False, prompt=True, required=False) - - # count works - option = TyperOption( - param_decls=["verbose", "--verbose", "-v"], - type=None, - default=0, - required=False, - count=True, - ) - assert option.min == 0 + typer.main.get_command(app) def test_option_error_hint() -> None: - option = TyperOption( - param_decls=["name", "--name"], - required=False, - show_envvar=True, - envvar="APP_NAME", - ) - hint = option.get_error_hint(_click.Context(TyperCommand(name="cmd"))) - assert "(env var: 'APP_NAME')" in hint + app = typer.Typer() + + @app.command() + def main(age: int = typer.Option(..., envvar="APP_NAME", show_envvar=True)): + pass # pragma: no cover + + result = runner.invoke(app, ["--age", "not-int"]) + assert result.exit_code == 2 + assert "(env var: 'APP_NAME')" in result.output def test_group_init() -> None: @@ -186,31 +173,41 @@ def test_option_resolve_envvar( set_env: bool, expected: str | None, ) -> None: - option = TyperOption( - param_decls=["name", "--name"], - required=False, - envvar=envvar, - ) + context_settings = {"auto_envvar_prefix": auto_prefix} if auto_prefix else {} + app = typer.Typer(context_settings=context_settings) + + @app.command() + def main(name: str = typer.Option("fallback", envvar=envvar)): + print(name) + if set_env: monkeypatch.setenv("APP_NAME", "my-precious") - ctx = _click.Context(TyperCommand(name="cmd"), auto_envvar_prefix=auto_prefix) - assert option.resolve_envvar_value(ctx) == expected + result = runner.invoke(app, []) + assert result.exit_code == 0 + if expected is None: + assert "fallback" in result.stdout + else: + assert expected in result.stdout def test_option_resolve_envvar_list( monkeypatch: pytest.MonkeyPatch, ) -> None: - option = TyperOption( - param_decls=["name", "--name"], - required=False, - envvar=["APP_NAME_1", "APP_NAME_2"], - ) + app = typer.Typer() + + @app.command() + def main( + name: str = typer.Option("fallback", envvar=["APP_NAME_1", "APP_NAME_2"]), + ): + print(name) + monkeypatch.delenv("APP_NAME_1", raising=False) monkeypatch.delenv("APP_NAME_2", raising=False) - ctx = _click.Context(TyperCommand(name="cmd")) - assert option.resolve_envvar_value(ctx) is None + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert "fallback" in result.stdout def test_context_auto_envvar() -> None: diff --git a/tests/test_schema.py b/tests/test_schema.py index 713c08e9a4..e848456e2b 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,7 +6,7 @@ runner = CliRunner() -def test_runtime_coercion_on_invoke() -> None: +def test_coercion() -> None: app = typer.Typer() seen: dict[str, object] = {} @@ -21,18 +21,19 @@ def main(items: list[int], active: bool = False, val=42): assert seen == {"items": [1, 2], "active": True, "val": 7} -def test_runtime_coercion_invalid_value() -> None: +def test_coercion_invalid() -> None: app = typer.Typer() @app.command() def main(age: int): pass - result = runner.invoke(app, ["--age", "not-an-int"]) - assert result.exit_code != 0 + result = runner.invoke(app, ["not-an-int"]) + assert "Input should be a valid integer" in result.stderr + assert result.exit_code == 2 -def test_path_runtime_coercion_on_invoke(tmp_path: Path) -> None: +def test_coercion_path(tmp_path: Path) -> None: target = tmp_path / "config.txt" target.write_text("hello\n", encoding="utf-8") app = typer.Typer() @@ -43,11 +44,11 @@ def main(config: Path = typer.Option(..., exists=True)): seen.append(config) result = runner.invoke(app, ["--config", str(target)]) - assert result.exit_code == 0, result.output + assert result.exit_code == 0 assert seen == [target] -def test_tuple_file_runtime_coercion_on_invoke(tmp_path: Path) -> None: +def test_coercion_tuple_files(tmp_path: Path) -> None: first = tmp_path / "first.txt" second = tmp_path / "second.txt" first.write_text("first-content\n", encoding="utf-8") diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index f41f434149..6f62856e22 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -176,6 +176,7 @@ def custom_parser( result = runner.invoke(app, ["not-a-hex"]) assert result.exit_code == 2 assert "Invalid value" in result.output + assert "invalid literal for int()" in result.output def test_custom_parser_hex(): diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py index 28ad656a8c..9cb74f5e6f 100644 --- a/typer/_click/decorators.py +++ b/typer/_click/decorators.py @@ -40,6 +40,7 @@ def decorator(f: Command) -> Command: def help_option(param_decls: list[str]) -> Callable[[Command], Command]: """Help option which prints the help page and exits the program.""" + from ..schema import bool_flag_runtime_param def show_help(ctx: Context, param: Parameter, value: bool) -> None: """Callback that print the help page on ```` and exits.""" @@ -57,4 +58,5 @@ def show_help(ctx: Context, param: Parameter, value: bool) -> None: help="Show this message and exit.", callback=show_help, required=False, + runtime_param=bool_flag_runtime_param(name="help", default=False), ) diff --git a/typer/adapters.py b/typer/adapters.py index 861a166676..09e3ce7605 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -72,7 +72,13 @@ def build_leaf_adapter( ) -> TypeAdapter[Any]: """Build a Pydantic TypeAdapter for a leaf CLI annotation and constraints.""" if parameter_info.parser is not None: - return _build_parser_adapter(parameter_info.parser) + parser = parameter_info.parser + + # We need this because Pydantic would otherwise reject a callable class + def parse(value: Any) -> Any: + return parser(value) + + return TypeAdapter(Annotated[Any, BeforeValidator(parse)]) if lenient_issubclass(annotation, Enum): case_sensitive = parameter_info.case_sensitive @@ -166,12 +172,13 @@ def _decode_cli_bytes(value: Any) -> Any: # BOOL # def _parse_cli_bool(value: Any) -> Any: - if isinstance(value, str): - stripped = value.strip() - if stripped == "": - return False - return stripped - return value + if not isinstance(value, str): + return value + + stripped = value.strip() + if stripped == "": + return False + return stripped # NUMBER # @@ -190,22 +197,6 @@ def clamp_number(value: Any) -> Any: return clamp_number -# PARSER # -def _build_parser_adapter(parser: Callable[[Any], Any]) -> TypeAdapter[Any]: - def parse_with_parser(value: Any) -> Any: - try: - return parser(value) - except ValueError: - try: - value = str(value) - except UnicodeError: # pragma: no cover - assert isinstance(value, bytes) - value = value.decode("utf-8", "replace") - raise ValueError(value) from None - - return TypeAdapter(Annotated[Any, BeforeValidator(parse_with_parser)]) - - # CHOICE # def _build_choice_adapter( choices: Sequence[Any], diff --git a/typer/core.py b/typer/core.py index d117f8cda1..57fb8a5d83 100644 --- a/typer/core.py +++ b/typer/core.py @@ -18,13 +18,8 @@ from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem from ._typing import Literal -from .models import OptionInfo from .schema import ( - CommandSchema, - DeclaredParam, RuntimeParam, - bool_flag_runtime_param, - runtime_param_from_declared, ) from .utils import describe_number_range, parse_boolean_env_var @@ -105,7 +100,7 @@ def _value_is_missing(param: _click.Parameter, value: Any) -> bool: return True if (param.nargs != 1 or param.multiple) and value == (): - return True # pragma: no cover + return True return False @@ -113,13 +108,9 @@ def _value_is_missing(param: _click.Parameter, value: Any) -> bool: class TyperParameter(_click.core.Parameter): """Typer parameter with runtime coercion.""" - runtime_param: RuntimeParam | None + runtime_param: RuntimeParam def process_value(self, ctx: _click.Context, value: Any) -> Any: - if self.runtime_param is None: - raise TypeError( - f"{self.__class__.__name__} {self.name!r} requires runtime_param" - ) value = self.runtime_param.coerce(value, param=self, ctx=ctx) if self.required and self.value_is_missing(value): raise _click.exceptions.MissingParameter(ctx=ctx, param=self) @@ -303,6 +294,7 @@ def __init__( *, # Parameter param_decls: list[str], + runtime_param: RuntimeParam, type: Any | None = None, required: bool = False, default: Any | None = None, @@ -331,7 +323,6 @@ def __init__( max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, - runtime_param: RuntimeParam | None = None, ): self.help = help self.show_default = show_default @@ -480,6 +471,7 @@ def __init__( *, # Parameter param_decls: list[str], + runtime_param: RuntimeParam, type: types.ParamType | Any | None = None, required: bool = False, default: Any | None = None, @@ -516,7 +508,6 @@ def __init__( max: int | float | None = None, # Rich settings rich_help_panel: str | None = None, - runtime_param: RuntimeParam | None = None, ): if help: help = inspect.cleandoc(help) @@ -585,27 +576,6 @@ def __init__( _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) self.rich_help_panel = rich_help_panel - if self.runtime_param is None and self.is_bool_flag: - default_flag = self.default if isinstance(self.default, bool) else False - self.runtime_param = bool_flag_runtime_param( - name=self.name or "flag", - default=default_flag, - ) - elif self.runtime_param is None and self.count: - count_info = OptionInfo() - if self.min is not None: - count_info.min = self.min - if self.max is not None: - count_info.max = self.max - declared = DeclaredParam( - name=self.name or "count", - parameter_info=count_info, - default=self.default if self.default is not None else 0, - required=required, - annotation=int, - ) - self.runtime_param = runtime_param_from_declared(declared) - def get_error_hint(self, ctx: _click.Context) -> str: result = super().get_error_hint(ctx) if self.show_envvar and self.envvar is not None: @@ -956,7 +926,6 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: str | None = None, - schema: CommandSchema | None = None, ) -> None: super().__init__( name=name, @@ -974,7 +943,6 @@ def __init__( ) self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel - self.schema = schema or CommandSchema.from_params(params or []) def format_options( self, ctx: _click.Context, formatter: _click.HelpFormatter diff --git a/typer/main.py b/typer/main.py index 62c3347f29..1cc3d8ea3e 100644 --- a/typer/main.py +++ b/typer/main.py @@ -39,7 +39,7 @@ TyperInfo, ) from .param_types import cli_param_type, lenient_issubclass -from .schema import CommandSchema, declare_param, runtime_param_from_declared +from .schema import declare_param, runtime_param_from_declared from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1378,7 +1378,6 @@ def get_command_from_info( command_info.callback ) cls = command_info.cls or TyperCommand - schema = CommandSchema.from_params(params) command = cls( name=name, context_settings=command_info.context_settings, @@ -1400,7 +1399,6 @@ def get_command_from_info( rich_markup_mode=rich_markup_mode, # Rich settings rich_help_panel=command_info.rich_help_panel, - schema=schema, ) return command diff --git a/typer/schema.py b/typer/schema.py index 2111f5780e..e2d18565e0 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Callable from dataclasses import dataclass from typing import IO, TYPE_CHECKING, Any @@ -181,28 +181,6 @@ def _coerce_value( raise BadParameter(str(exc), ctx=ctx, param=param) from exc -@dataclass(frozen=True) -class CommandSchema: - """Schema for all parameters on a Typer command.""" - - params: tuple[RuntimeParam, ...] - - @classmethod - def from_params(cls, command_params: Sequence[Any]) -> CommandSchema: - runtime_params = [ - param.runtime_param - for param in command_params - if getattr(param, "runtime_param", None) is not None - ] - return cls(params=tuple(runtime_params)) - - def get_param(self, name: str) -> RuntimeParam | None: - for runtime_param in self.params: - if runtime_param.name == name: - return runtime_param - return None - - def declare_param(param: ParamMeta) -> DeclaredParam: """Declare metadata from a function parameter.""" default = None From b157ea99eb670857fb55f5a0d03714aaeb009347 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 18 Jun 2026 00:36:39 +0200 Subject: [PATCH 47/73] use TyperParameter more often and further unify metavar printing (still TODO: choice) --- tests/assets/completion_argument.py | 3 +- .../test_datetime/test_tutorial001.py | 4 +- .../test_number/test_tutorial001.py | 8 +- .../test_path/test_tutorial002.py | 3 +- tests/test_type_conversion.py | 40 +- typer/_click/core.py | 15 +- typer/_click/exceptions.py | 7 +- typer/_click/types.py | 9 +- typer/core.py | 9 +- typer/param_types.py | 566 ++++++++---------- typer/schema.py | 14 +- 11 files changed, 315 insertions(+), 363 deletions(-) diff --git a/tests/assets/completion_argument.py b/tests/assets/completion_argument.py index e2754c4357..ad064b9166 100644 --- a/tests/assets/completion_argument.py +++ b/tests/assets/completion_argument.py @@ -1,10 +1,11 @@ import typer from typer import _click +from typer.core import TyperParameter app = typer.Typer() -def shell_complete(ctx: _click.Context, param: _click.Parameter, incomplete: str): +def shell_complete(ctx: _click.Context, param: TyperParameter, incomplete: str): typer.echo(f"ctx: {ctx.info_name}", err=True) typer.echo(f"arg is: {param.name}", err=True) typer.echo(f"incomplete is: {incomplete}", err=True) diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index 80a01ba3b2..0af2ce014e 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -13,7 +13,7 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - assert "[%Y-%m-%d]" in result.output + assert "<%Y-%m-%d>" in result.output def test_main(): @@ -35,7 +35,7 @@ def test_main_datetime_object(): def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 - assert "Invalid value for 'BIRTH:[%Y-%m-%d]'" in result.output + assert "Invalid value for 'BIRTH:<%Y-%m-%d>'" in result.output assert "should be a valid datetime" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py index 1940d5d022..3f6239592d 100644 --- a/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_number/test_tutorial001.py @@ -28,9 +28,9 @@ def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INT RANGE" in result.output + assert "int range" in result.output assert "--score" in result.output - assert "FLOAT RANGE" in result.output + assert "float range" in result.output def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): @@ -38,9 +38,9 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--age" in result.output - assert "INT RANGE" in result.output + assert "int range" in result.output assert "--score" in result.output - assert "FLOAT RANGE" in result.output + assert "float range" in result.output def test_params(mod: ModuleType): diff --git a/tests/test_tutorial/test_parameter_types/test_path/test_tutorial002.py b/tests/test_tutorial/test_parameter_types/test_path/test_tutorial002.py index ff248d5822..9c9cde7501 100644 --- a/tests/test_tutorial/test_parameter_types/test_path/test_tutorial002.py +++ b/tests/test_tutorial/test_parameter_types/test_path/test_tutorial002.py @@ -30,7 +30,6 @@ def test_not_exists(tmpdir, mod: ModuleType): result = runner.invoke(mod.app, ["--config", f"{config_file}"]) assert result.exit_code != 0 assert "Invalid value for '--config'" in result.output - assert "File" in result.output assert "does not exist" in result.output @@ -47,7 +46,7 @@ def test_dir(mod: ModuleType): result = runner.invoke(mod.app, ["--config", "./"]) assert result.exit_code != 0 assert "Invalid value for '--config'" in result.output - assert "File './' is a directory." in result.output + assert "file './' is a directory." in result.output def test_script(mod: ModuleType): diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 6f62856e22..a783c5583a 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -1,8 +1,9 @@ import os import sys +from datetime import datetime from enum import Enum from pathlib import Path -from typing import Annotated, Any, get_args, get_origin +from typing import Annotated, Any, Literal, get_args, get_origin import pytest import typer @@ -377,20 +378,41 @@ def cmd(val=default): @pytest.mark.parametrize( - ("annotation", "expected_metavar"), + ("parameter", "expected_metavar"), [ - (str, ""), - (int, ""), - (float, ""), - (tuple[str, int], ""), + pytest.param(Annotated[str, typer.Option(...)], ""), + pytest.param(Annotated[int, typer.Option(...)], ""), + pytest.param(Annotated[float, typer.Option(...)], ""), + pytest.param( + Annotated[float, typer.Option(..., min=0.666, max=3.42)], "" + ), + pytest.param(Annotated[bytes, typer.Option(...)], ""), + pytest.param(Annotated[list[str], typer.Option(...)], ""), + pytest.param(Annotated[tuple[str, int], typer.Option(...)], ""), + pytest.param(Annotated[tuple[Path, str], typer.Option(...)], ""), + pytest.param(Annotated[Path, typer.Option(...)], ""), + pytest.param(Annotated[str, typer.Option(..., resolve_path=True)], ""), + pytest.param(Annotated[Path, typer.Option(..., dir_okay=False)], ""), + pytest.param(Annotated[Path, typer.Option(..., file_okay=False)], ""), + pytest.param(Annotated[SomeEnum, typer.Option(...)], "[one|two|three]"), + pytest.param(Annotated[SomeEnum, typer.Argument()], "{one|two|three}"), + pytest.param( + Annotated[SomeEnum, typer.Option(..., show_choices=False)], "[SomeEnum]" + ), + pytest.param(Annotated[Literal["x", "y"], typer.Option(...)], "[x|y]"), + pytest.param(Annotated[typer.FileText, typer.Option(...)], ""), + pytest.param(Annotated[datetime, typer.Option(...)], "<%Y-%m-%d>"), + pytest.param( + Annotated[datetime, typer.Option(..., formats=["%Y-%m-%d", "%d/%m/%Y"])], + "<%Y-%m-%d|%d/%m/%Y>", + ), ], ) -def test_param_type_help_metavar(annotation: type, expected_metavar: str) -> None: +def test_param_type_help_metavar(parameter: Any, expected_metavar: str) -> None: app = typer.Typer() - option = Annotated[annotation, typer.Option(...)] @app.command() - def main(value: option): + def main(value: parameter): pass # pragma: no cover result = runner.invoke(app, ["--help"]) diff --git a/typer/_click/core.py b/typer/_click/core.py index 1a9006ac8f..cebf3b9ccf 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -31,7 +31,7 @@ from .utils import echo, make_default_short_help if TYPE_CHECKING: - from ..core import TyperOption + from ..core import TyperOption, TyperParameter from .shell_completion import CompletionItem F = TypeVar("F", bound="Callable[..., Any]") @@ -59,7 +59,7 @@ def _complete_visible_commands( @contextmanager def augment_usage_errors( - ctx: "Context", param: Union["Parameter", None] = None + ctx: "Context", param: Union["TyperParameter", None] = None ) -> Iterator[None]: """Context manager that attaches extra information to exceptions.""" try: @@ -996,7 +996,9 @@ def handle_parse_result( the value has been explicitly set by the user (and as such, is not coming from a default). """ - with augment_usage_errors(ctx, param=self): + from ..core import TyperParameter + + with augment_usage_errors(ctx, param=cast(TyperParameter, self)): value, source = self.consume_value(ctx, opts) ctx.set_parameter_source(self.name, source) # type: ignore @@ -1043,4 +1045,9 @@ def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem" return cast("list[CompletionItem]", results) - return self.type.shell_complete(ctx, self, incomplete) + # All Parameter objects will in fact be TyperParameter objects + # This will be cleaned up in later iterations + from ..core import TyperParameter + + param = cast(TyperParameter, self) + return self.type.shell_complete(ctx, param, incomplete) diff --git a/typer/_click/exceptions.py b/typer/_click/exceptions.py index eca388d41d..acc5b9e58e 100644 --- a/typer/_click/exceptions.py +++ b/typer/_click/exceptions.py @@ -6,7 +6,8 @@ from .utils import echo, format_filename if TYPE_CHECKING: - from .core import Command, Context, Parameter + from ..core import TyperParameter + from .core import Command, Context def _join_param_hints(param_hint: Sequence[str] | str | None) -> str | None: @@ -90,7 +91,7 @@ def __init__( self, message: str, ctx: Union["Context", None] = None, - param: Union["Parameter", None] = None, + param: Union["TyperParameter", None] = None, param_hint: Sequence[str] | str | None = None, ) -> None: super().__init__(message, ctx) @@ -118,7 +119,7 @@ def __init__( self, message: str | None = None, ctx: Union["Context", None] = None, - param: Union["Parameter", None] = None, + param: Union["TyperParameter", None] = None, param_hint: Sequence[str] | str | None = None, param_type: str | None = None, ) -> None: diff --git a/typer/_click/types.py b/typer/_click/types.py index c11c1bd354..f3c2c38074 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -9,7 +9,8 @@ from .exceptions import BadParameter if TYPE_CHECKING: - from .core import Context, Parameter + from ..core import TyperParameter + from .core import Context from .shell_completion import CompletionItem @@ -31,7 +32,7 @@ def arity(self) -> int: envvar_list_splitter: ClassVar[str | None] = None def get_missing_message( - self, param: "Parameter", ctx: Union["Context", None] + self, param: "TyperParameter", ctx: Union["Context", None] ) -> str | None: """Optionally might return extra information about a missing parameter. @@ -51,14 +52,14 @@ def split_envvar_value(self, rv: str) -> Sequence[str]: def fail( self, message: str, - param: Union["Parameter", None] = None, + param: Union["TyperParameter", None] = None, ctx: Union["Context", None] = None, ) -> NoReturn: """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) def shell_complete( - self, ctx: "Context", param: "Parameter", incomplete: str + self, ctx: "Context", param: "TyperParameter", incomplete: str ) -> list["CompletionItem"]: """Return a list of `CompletionItem` objects for the incomplete value. Most types do not provide completions, but diff --git a/typer/core.py b/typer/core.py index 57fb8a5d83..a06df06740 100644 --- a/typer/core.py +++ b/typer/core.py @@ -14,10 +14,11 @@ ) from . import _click, param_types -from ._click import types from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem +from ._click.types import ParamType from ._typing import Literal +from .param_types import DEFAULT_PARAM_TYPE, TyperRanged from .schema import ( RuntimeParam, ) @@ -472,7 +473,7 @@ def __init__( # Parameter param_decls: list[str], runtime_param: RuntimeParam, - type: types.ParamType | Any | None = None, + type: ParamType | Any | None = None, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -550,7 +551,7 @@ def __init__( # TODO: revisit all of this flag stuff inferred_bool_flag = bool(is_flag and type is None and not count) if inferred_bool_flag: - self.type: types.ParamType = param_types.DEFAULT_PARAM_TYPE + self.type: ParamType = DEFAULT_PARAM_TYPE self.is_flag: bool = bool(is_flag) self.is_bool_flag: bool = inferred_bool_flag @@ -563,7 +564,7 @@ def __init__( # Counting self.count = count if count and type is None: - self.type = param_types._ranged_number_param_type(int) + self.type = TyperRanged(int) if self.min is None: self.min = 0 diff --git a/typer/param_types.py b/typer/param_types.py index a11ebe58f2..54a5edc43b 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -4,14 +4,15 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import IO, Any, ClassVar, Generic, TypeGuard, TypeVar, cast +from typing import IO, TYPE_CHECKING, Any, ClassVar, Generic, TypeGuard, TypeVar, cast from pydantic import TypeAdapter, ValidationError -from ._click import Context, Parameter, types +from ._click import Context from ._click._compat import open_stream from ._click.exceptions import BadParameter from ._click.shell_completion import CompletionItem +from ._click.types import ParamType from ._click.utils import LazyFile, format_filename, safecall from ._typing import get_args, get_origin, is_literal_type, literal_values from .models import ( @@ -23,7 +24,11 @@ ParameterInfo, ) +if TYPE_CHECKING: + from .core import TyperParameter + ParamTypeValue = TypeVar("ParamTypeValue") +DEFAULT_PARAM_TYPE = ParamType() def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: @@ -38,48 +43,68 @@ def _get_error_msg(exc: ValidationError) -> str: return str(exc) -DEFAULT_PARAM_TYPE = types.ParamType() - - -class _DatetimeDisplayType(types.ParamType): - def __init__(self, *, formats: tuple[str, ...]) -> None: - self.formats = formats - - -def datetime_value_metavar(formats: Sequence[str]) -> str: - return f"[{'|'.join(formats)}]" - - -def datetime_param_type(formats: Sequence[str] | None = None) -> _DatetimeDisplayType: - formats_tuple = tuple(formats) if formats is not None else ("%Y-%m-%d",) - return _DatetimeDisplayType(formats=formats_tuple) +def _format_option_metavar(param: "TyperParameter", value_metavar: str) -> str: + if param.nargs != 1: + value_metavar += "..." + return value_metavar -class FileDisplayType(types.ParamType): - envvar_list_splitter = os.path.pathsep +def _format_argument_metavar(param: "TyperParameter", value_metavar: str | None) -> str: + var = (param.name or "").upper() + if not param.required: + var = f"[{var}]" + if value_metavar: + var += f":{value_metavar}" + if param.nargs != 1: + var += "..." + return var - def shell_complete( - self, ctx: Context, param: Parameter, incomplete: str - ) -> list[CompletionItem]: - return [CompletionItem(incomplete, type="file")] +def resolve_metavar(param: "TyperParameter", ctx: Context) -> str: + if param.metavar is not None: + var = param.metavar + if getattr(param, "param_type_name", None) == "argument": + if not param.required and not var.startswith("["): + var = f"[{var}]" + return var -FILE = FileDisplayType() + value_metavar = resolve_value_metavar(param, ctx) + if getattr(param, "param_type_name", None) == "argument": + return _format_argument_metavar(param, value_metavar) + assert value_metavar is not None + return _format_option_metavar(param, value_metavar) -class _RangedNumberParamType(types.ParamType): - def __init__(self, annotation: type[Any]) -> None: - self.annotation = annotation +def resolve_value_metavar(param: "TyperParameter", ctx: Context) -> str | None: + param_type = param.type + if isinstance(param_type, TyperChoice): + return choice_value_metavar( + param, + ctx, + choices=param_type.choices, + case_sensitive=param_type.case_sensitive, + ) + if getattr(param, "param_type_name", None) == "argument" and not isinstance( + param_type, TyperDatetime + ): + return None + return param_type_metavar_label(param) -def _param_annotation(param: Parameter) -> Any | None: - runtime_param = getattr(param, "runtime_param", None) - if runtime_param is not None: - return runtime_param.annotation - return None +def resolve_rich_metavar(param: "TyperParameter", ctx: Context) -> str | None: + metavar_str = resolve_metavar(param, ctx) + if ( + getattr(param, "param_type_name", None) == "argument" + and param.name + and metavar_str == param.name.upper() + ): + metavar_str = param_type_metavar_label(param) + if metavar_str == "BOOL": + return None + return metavar_str -def _annotation_metavar_label_bare(annotation: Any) -> str: +def _annotation_metavar_label(annotation: Any) -> str: display_type = str(annotation) if annotation is None: display_type = "str" @@ -87,48 +112,141 @@ def _annotation_metavar_label_bare(annotation: Any) -> str: if origin is list: args = get_args(annotation) if len(args) == 1: - display_type = _annotation_metavar_label_bare(args[0]) + display_type = _annotation_metavar_label(args[0]) if origin is tuple: - labels = [_annotation_metavar_label_bare(arg) for arg in get_args(annotation)] + labels = [_annotation_metavar_label(arg) for arg in get_args(annotation)] display_type = ",".join(labels) if isinstance(annotation, type): display_type = annotation.__name__ return display_type -def _annotation_metavar_label(annotation: Any) -> str: - return f"<{_annotation_metavar_label_bare(annotation)}>" - - -def param_type_metavar_label( - param_type: types.ParamType, - *, - annotation: Any | None = None, -) -> str: - if isinstance(param_type, _DatetimeDisplayType): - return datetime_value_metavar(param_type.formats) - if isinstance(param_type, _RangedNumberParamType): - return f"{param_type.annotation.__name__.upper()} RANGE" - if isinstance(param_type, TyperTuple): +def param_type_metavar_label(param: "TyperParameter") -> str: + param_type = param.type + if isinstance(param_type, TyperDatetime): + label = "|".join(param_type.formats) + elif isinstance(param_type, TyperRanged): + label = f"{param_type.annotation.__name__} range" + elif isinstance(param_type, TyperTuple): labels = [ - _annotation_metavar_label_bare(element) + _annotation_metavar_label(element) for element in param_type.element_annotations ] - return f"<{','.join(labels)}>" - if isinstance(param_type, TyperPath): - if param_type.file_okay and not param_type.dir_okay: - return "FILE" - if param_type.dir_okay and not param_type.file_okay: - return "DIRECTORY" - return "PATH" - if annotation is not None: - return _annotation_metavar_label(annotation) - return "STR" + label = ",".join(labels) + elif isinstance(param_type, TyperPath): + label = path_metavar_label(param.runtime_param.parameter_info) + else: + label = _annotation_metavar_label(param.runtime_param.annotation) + return f"<{label}>" -CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) +def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: + """Infer a type from a default value. Returns (annotation, guessed).""" + if isinstance(default, tuple) and default: + if not isinstance(default[0], (tuple, list)): + return tuple(map(type, default)), True + if isinstance(default, (tuple, list)): + if not default: + return None, True + item = default[0] + if isinstance(item, (tuple, list)): + return tuple(map(type, item)), True + return type(item), True + return type(default), True + + +def resolve_param_type( + annotation: Any | None = None, + default: Any | None = None, + *, + parameter_info: ParameterInfo | None = None, +) -> ParamType: + """Resolve a display ParamType for metavar/help.""" + if annotation is None and default is not None: + annotation, _ = infer_type_from_default(default) + + if isinstance(annotation, tuple): + return TyperTuple(annotation) + if isinstance(annotation, ParamType): + return annotation + if parameter_info is not None and annotation is not None: + param_type = param_type_from_annotation(annotation, parameter_info) + if param_type is not None: + return param_type + + return DEFAULT_PARAM_TYPE + + +def cli_param_type( + *, + annotation: Any, + parameter_info: ParameterInfo, + default: Any, + is_list: bool, + is_tuple: bool, +) -> ParamType: + """Defer the "type" for metavar/help.""" + if is_tuple: + type_args = get_args(annotation) + return resolve_param_type(tuple(type_args), parameter_info=parameter_info) + if is_list: + (element_type,) = get_args(annotation) + return resolve_param_type(element_type, parameter_info=parameter_info) + return resolve_param_type( + annotation, default=default, parameter_info=parameter_info + ) + + +def get_param_type(*, annotation: Any, parameter_info: ParameterInfo) -> ParamType: + return resolve_param_type(annotation=annotation, parameter_info=parameter_info) + + +def param_type_from_annotation( + annotation: Any, + parameter_info: ParameterInfo, +) -> ParamType | None: + if annotation is int or annotation is float: + if parameter_info.min is not None or parameter_info.max is not None: + return TyperRanged(annotation) + return None + if annotation is datetime: + f = parameter_info.formats + formats_tuple = tuple(f) if f is not None else ("%Y-%m-%d",) + return TyperDatetime(formats=formats_tuple) + if _needs_typer_path(annotation, parameter_info): + return TyperPath() + if lenient_issubclass(annotation, Enum): + return TyperChoice(list(annotation), parameter_info.case_sensitive) + if is_literal_type(annotation): + return TyperChoice(literal_values(annotation), parameter_info.case_sensitive) + if lenient_issubclass(annotation, CLI_FILE_TYPES): + return TyperFile() + return None + + +# DATETIME # +class TyperDatetime(ParamType): + def __init__(self, *, formats: tuple[str, ...]) -> None: + self.formats = formats + + +# TUPLE # +class TyperTuple(ParamType): + """Metavar and nargs information for tuple parameters.""" + + is_composite = True + + def __init__(self, element_annotations: Sequence[Any]) -> None: + self.element_annotations: tuple[Any, ...] = tuple(element_annotations) + + @property + def arity(self) -> int: + return len(self.element_annotations) + + +# ENUM # def normalize_choice_value( choice: Any, case_sensitive: bool, @@ -173,20 +291,29 @@ def choice_coercion_annotation( return None -class TyperTuple(types.ParamType): - """Metavar and nargs information for tuple parameters.""" - - is_composite = True +def choice_value_metavar( + param: "TyperParameter", + ctx: Context, + *, + choices: Sequence[Any], + case_sensitive: bool, +) -> str: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + metavars = [_annotation_metavar_label(type(c)) for c in choices] + choices_str = "|".join([*dict.fromkeys(metavars)]) + else: + normalized_mapping = { + c: normalize_choice_value(c, case_sensitive, ctx) for c in choices + } + choices_str = "|".join(normalized_mapping.values()) - def __init__(self, element_annotations: Sequence[Any]) -> None: - self.element_annotations: tuple[Any, ...] = tuple(element_annotations) + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" - @property - def arity(self) -> int: - return len(self.element_annotations) + return f"[{choices_str}]" -class TyperChoice(types.ParamType, Generic[ParamTypeValue]): +class TyperChoice(ParamType, Generic[ParamTypeValue]): name = "choice" def __init__( @@ -203,14 +330,11 @@ def _normalized_mapping( the normalized values that are accepted via the command line. """ return { - choice: self.normalize_choice(choice=choice, ctx=ctx) + choice: normalize_choice_value(choice, self.case_sensitive, ctx) for choice in self.choices } - def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: - return normalize_choice_value(choice, self.case_sensitive, ctx) - - def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: + def get_missing_message(self, param: "TyperParameter", ctx: Context | None) -> str: """Message shown when no choice is passed.""" choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) return f"Choose from:\n\t{choices}" @@ -226,7 +350,7 @@ def _choice_as_str(self, choice: ParamTypeValue) -> str: return str(choice) def shell_complete( - self, ctx: Context, param: Parameter, incomplete: str + self, ctx: Context, param: "TyperParameter", incomplete: str ) -> list[CompletionItem]: """Complete choices that start with the incomplete value.""" @@ -241,31 +365,20 @@ def shell_complete( return [CompletionItem(c) for c in matched] -class TyperPath(types.ParamType): - envvar_list_splitter: ClassVar[str] = os.path.pathsep +# PATH # +def path_metavar_label(parameter_info: ParameterInfo) -> str: + if parameter_info.file_okay and not parameter_info.dir_okay: + return "file" + if parameter_info.dir_okay and not parameter_info.file_okay: + return "dir" + return "path" - def __init__( - self, - exists: bool = False, - file_okay: bool = True, - dir_okay: bool = True, - writable: bool = False, - readable: bool = True, - resolve_path: bool = False, - allow_dash: bool = False, - path_type: type[Any] | None = None, - ): - self.exists = exists - self.file_okay = file_okay - self.dir_okay = dir_okay - self.readable = readable - self.writable = writable - self.resolve_path = resolve_path - self.allow_dash = allow_dash - self.path_type = path_type + +class TyperPath(ParamType): + envvar_list_splitter: ClassVar[str] = os.path.pathsep def shell_complete( - self, ctx: Context, param: Parameter, incomplete: str + self, ctx: Context, param: "TyperParameter", incomplete: str ) -> list[CompletionItem]: """Return an empty list so that the autocompletion functionality will work properly from the commandline. @@ -273,14 +386,6 @@ def shell_complete( return [] -def _path_display_name(parameter_info: ParameterInfo) -> str: - if parameter_info.file_okay and not parameter_info.dir_okay: - return "file" - if parameter_info.dir_okay and not parameter_info.file_okay: - return "directory" - return "path" - - def resolve_path_type( annotation: Any, parameter_info: ParameterInfo, @@ -295,6 +400,15 @@ def path_uses_coercion(annotation: Any, parameter_info: ParameterInfo) -> bool: return _needs_typer_path(annotation, parameter_info) +def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: + return ( + annotation == Path + or parameter_info.allow_dash + or parameter_info.path_type is not None + or parameter_info.resolve_path + ) + + def _coerce_path_result( value: str | os.PathLike[str], path_type: type[Any] | None, @@ -311,7 +425,7 @@ def coerce_cli_path( parameter_info: ParameterInfo, *, path_type: type[Any] | None, - param: Parameter | None = None, + param: "TyperParameter | None" = None, ctx: Context | None = None, ) -> str | bytes | os.PathLike[str] | Path: if path_type is None or path_type is str or path_type is bytes: @@ -337,61 +451,51 @@ def coerce_cli_path( if parameter_info.resolve_path: rv = os.path.realpath(rv) - name = _path_display_name(parameter_info) + label = path_metavar_label(parameter_info) try: st = os.stat(rv) except OSError: if not parameter_info.exists: return _coerce_path_result(rv, path_type) raise BadParameter( - f"{name.title()} {format_filename(value)!r} does not exist.", + f"{label} {format_filename(value)!r} does not exist.", ctx=ctx, param=param, ) from None - name_title = name.title() loc = repr(format_filename(value)) if not parameter_info.file_okay and stat.S_ISREG(st.st_mode): - raise BadParameter(f"{name_title} {loc} is a file.", ctx=ctx, param=param) + raise BadParameter(f"{label} {loc} is a file.", ctx=ctx, param=param) if not parameter_info.dir_okay and stat.S_ISDIR(st.st_mode): - raise BadParameter( - f"{name_title} {loc} is a directory.", ctx=ctx, param=param - ) + raise BadParameter(f"{label} {loc} is a directory.", ctx=ctx, param=param) if parameter_info.readable and not os.access(rv, os.R_OK): - raise BadParameter( - f"{name_title} {loc} is not readable.", ctx=ctx, param=param - ) + raise BadParameter(f"{label} {loc} is not readable.", ctx=ctx, param=param) if parameter_info.writable and not os.access(rv, os.W_OK): - raise BadParameter( - f"{name_title} {loc} is not writable.", ctx=ctx, param=param - ) + raise BadParameter(f"{label} {loc} is not writable.", ctx=ctx, param=param) return _coerce_path_result(rv, path_type) -def typer_path_display_type( - annotation: Any, - parameter_info: ParameterInfo, -) -> TyperPath: - return TyperPath( - exists=parameter_info.exists, - file_okay=parameter_info.file_okay, - dir_okay=parameter_info.dir_okay, - writable=parameter_info.writable, - readable=parameter_info.readable, - resolve_path=parameter_info.resolve_path, - allow_dash=parameter_info.allow_dash, - path_type=resolve_path_type(annotation, parameter_info), - ) +# FILE # +CLI_FILE_TYPES = (FileTextWrite, FileText, FileBinaryRead, FileBinaryWrite) def is_file_annotation(annotation: Any) -> bool: return lenient_issubclass(annotation, CLI_FILE_TYPES) +class TyperFile(ParamType): + envvar_list_splitter = os.path.pathsep + + def shell_complete( + self, ctx: Context, param: "TyperParameter", incomplete: str + ) -> list[CompletionItem]: + return [CompletionItem(incomplete, type="file")] + + def file_coercion_annotation(annotation: Any) -> Any | None: """Return the file marker type when this parameter opens files.""" origin = get_origin(annotation) @@ -446,7 +550,7 @@ def _open_cli_file( parameter_info: ParameterInfo, *, mode: str, - param: Parameter | None = None, + param: "TyperParameter | None" = None, ctx: Context | None = None, ) -> IO[Any]: if _is_file_like(value): @@ -495,189 +599,7 @@ def _open_cli_file( raise BadParameter(message, ctx=ctx, param=param) from exc -def _file_param_type() -> FileDisplayType: - return FILE - - -def _ranged_number_param_type(annotation: type[Any]) -> _RangedNumberParamType: - return _RangedNumberParamType(annotation) - - -def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: - return ( - annotation == Path - or parameter_info.allow_dash - or parameter_info.path_type is not None - or parameter_info.resolve_path - ) - - -def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: - """Infer a type from a default value. Returns (annotation, guessed).""" - if isinstance(default, tuple) and default: - if not isinstance(default[0], (tuple, list)): - return tuple(map(type, default)), True - if isinstance(default, (tuple, list)): - if not default: - return None, True - item = default[0] - if isinstance(item, (tuple, list)): - return tuple(map(type, item)), True - return type(item), True - return type(default), True - - -def resolve_param_type( - annotation: Any | None = None, - default: Any | None = None, - *, - parameter_info: ParameterInfo | None = None, -) -> types.ParamType: - """Resolve a display ParamType for metavar/help.""" - if annotation is None and default is not None: - annotation, _ = infer_type_from_default(default) - - if isinstance(annotation, tuple): - return TyperTuple(annotation) - - if isinstance(annotation, types.ParamType): - return annotation - - if parameter_info is not None and annotation is not None: - param_type = param_type_from_annotation(annotation, parameter_info) - if param_type is not None: - return param_type - - return DEFAULT_PARAM_TYPE - - -def cli_param_type( - *, - annotation: Any, - parameter_info: ParameterInfo, - default: Any, - is_list: bool, - is_tuple: bool, -) -> types.ParamType: - """Defer the "type" for metavar/help.""" - if is_tuple: - type_args = get_args(annotation) - return resolve_param_type(tuple(type_args), parameter_info=parameter_info) - if is_list: - (element_type,) = get_args(annotation) - return resolve_param_type(element_type, parameter_info=parameter_info) - return resolve_param_type( - annotation, default=default, parameter_info=parameter_info - ) - - -def get_param_type( - *, annotation: Any, parameter_info: ParameterInfo -) -> types.ParamType: - return resolve_param_type(annotation=annotation, parameter_info=parameter_info) - - -def param_type_from_annotation( - annotation: Any, - parameter_info: ParameterInfo, -) -> types.ParamType | None: - if annotation is int or annotation is float: - if parameter_info.min is not None or parameter_info.max is not None: - return _ranged_number_param_type(annotation) - return None - if annotation is datetime: - return datetime_param_type(formats=parameter_info.formats) - if _needs_typer_path(annotation, parameter_info): - return typer_path_display_type(annotation, parameter_info) - if lenient_issubclass(annotation, Enum): - return TyperChoice(list(annotation), parameter_info.case_sensitive) - if is_literal_type(annotation): - return TyperChoice(literal_values(annotation), parameter_info.case_sensitive) - if lenient_issubclass(annotation, CLI_FILE_TYPES): - return _file_param_type() - return None - - -def choice_value_metavar( - param: Parameter, - ctx: Context, - *, - choices: Sequence[Any], - case_sensitive: bool, -) -> str: - if param.param_type_name == "option" and not param.show_choices: # type: ignore - metavars = [_annotation_metavar_label(type(c)) for c in choices] - choices_str = "|".join([*dict.fromkeys(metavars)]) - else: - normalized_mapping = { - c: normalize_choice_value(c, case_sensitive, ctx) for c in choices - } - choices_str = "|".join(normalized_mapping.values()) - - if param.required and param.param_type_name == "argument": - return f"{{{choices_str}}}" - - return f"[{choices_str}]" - - -def resolve_value_metavar(param: Parameter, ctx: Context) -> str | None: - param_type = param.type - if isinstance(param_type, TyperChoice): - return choice_value_metavar( - param, - ctx, - choices=param_type.choices, - case_sensitive=param_type.case_sensitive, - ) - if isinstance(param_type, _DatetimeDisplayType): - return datetime_value_metavar(param_type.formats) - if getattr(param, "param_type_name", None) == "argument": - return None - return param_type_metavar_label(param_type, annotation=_param_annotation(param)) - - -def _format_option_metavar(param: Parameter, value_metavar: str) -> str: - if param.nargs != 1: - value_metavar += "..." - return value_metavar - - -def _format_argument_metavar(param: Parameter, value_metavar: str | None) -> str: - var = (param.name or "").upper() - if not param.required: - var = f"[{var}]" - if value_metavar: - var += f":{value_metavar}" - if param.nargs != 1: - var += "..." - return var - - -def resolve_metavar(param: Parameter, ctx: Context) -> str: - if param.metavar is not None: - var = param.metavar - if getattr(param, "param_type_name", None) == "argument": - if not param.required and not var.startswith("["): - var = f"[{var}]" - return var - - value_metavar = resolve_value_metavar(param, ctx) - if getattr(param, "param_type_name", None) == "argument": - return _format_argument_metavar(param, value_metavar) - assert value_metavar is not None - return _format_option_metavar(param, value_metavar) - - -def resolve_rich_metavar(param: Parameter, ctx: Context) -> str | None: - metavar_str = resolve_metavar(param, ctx) - if ( - getattr(param, "param_type_name", None) == "argument" - and param.name - and metavar_str == param.name.upper() - ): - metavar_str = param_type_metavar_label( - param.type, annotation=_param_annotation(param) - ) - if metavar_str == "BOOL": - return None - return metavar_str +# RANGE # +class TyperRanged(ParamType): + def __init__(self, annotation: type[Any]) -> None: + self.annotation = annotation diff --git a/typer/schema.py b/typer/schema.py index e2d18565e0..08d3645bec 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass @@ -61,7 +59,7 @@ def coerce( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: is_multi_value = param.multiple or param.nargs == -1 @@ -78,7 +76,7 @@ def _coerce_value( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: pass @@ -94,7 +92,7 @@ def _coerce_value( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: try: @@ -115,7 +113,7 @@ def _coerce_value( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: mode = resolve_file_mode(self.parameter_info, self.file_annotation) @@ -144,7 +142,7 @@ def _coerce_value( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: return coerce_cli_path( @@ -167,7 +165,7 @@ def _coerce_value( self, value: Any, *, - param: TyperParameter, + param: "TyperParameter", ctx: Context, ) -> Any: try: From f3679c774eefde0ce3cdbfc0aeb65b87e4afa483 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 18 Jun 2026 00:51:14 +0200 Subject: [PATCH 48/73] fix metavar for list --- tests/test_type_conversion.py | 2 +- typer/param_types.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index a783c5583a..47cdaab773 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -387,7 +387,7 @@ def cmd(val=default): Annotated[float, typer.Option(..., min=0.666, max=3.42)], "" ), pytest.param(Annotated[bytes, typer.Option(...)], ""), - pytest.param(Annotated[list[str], typer.Option(...)], ""), + pytest.param(Annotated[list[str], typer.Option(...)], ""), pytest.param(Annotated[tuple[str, int], typer.Option(...)], ""), pytest.param(Annotated[tuple[Path, str], typer.Option(...)], ""), pytest.param(Annotated[Path, typer.Option(...)], ""), diff --git a/typer/param_types.py b/typer/param_types.py index 54a5edc43b..bbc6496ba5 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -106,24 +106,30 @@ def resolve_rich_metavar(param: "TyperParameter", ctx: Context) -> str | None: def _annotation_metavar_label(annotation: Any) -> str: display_type = str(annotation) + origin = get_origin(annotation) if annotation is None: display_type = "str" - origin = get_origin(annotation) - if origin is list: + elif origin is list: args = get_args(annotation) if len(args) == 1: - display_type = _annotation_metavar_label(args[0]) - if origin is tuple: + element_label = _annotation_metavar_label(args[0]) + display_type = f"list[{element_label}]" + else: + display_type = "list" + elif origin is tuple: labels = [_annotation_metavar_label(arg) for arg in get_args(annotation)] display_type = ",".join(labels) - if isinstance(annotation, type): + elif isinstance(annotation, type): display_type = annotation.__name__ return display_type def param_type_metavar_label(param: "TyperParameter") -> str: + annotation = param.runtime_param.annotation param_type = param.type - if isinstance(param_type, TyperDatetime): + if get_origin(annotation) is list: + label = _annotation_metavar_label(annotation) + elif isinstance(param_type, TyperDatetime): label = "|".join(param_type.formats) elif isinstance(param_type, TyperRanged): label = f"{param_type.annotation.__name__} range" From 3b3459a715d96eab68f37ebfc0f9bdd979bb9f48 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 18 Jun 2026 17:58:28 +0200 Subject: [PATCH 49/73] clean out param_types and move metavar functions to TyperParameter --- typer/core.py | 149 +++++++++++++++++++++++++++++++++++++++++-- typer/display.py | 22 +++++++ typer/param_types.py | 144 ++--------------------------------------- typer/rich_utils.py | 3 +- typer/schema.py | 28 ++++---- typer/utils.py | 13 ---- 6 files changed, 184 insertions(+), 175 deletions(-) create mode 100644 typer/display.py diff --git a/typer/core.py b/typer/core.py index a06df06740..4b6387952a 100644 --- a/typer/core.py +++ b/typer/core.py @@ -11,6 +11,8 @@ TextIO, Union, cast, + get_args, + get_origin, ) from . import _click, param_types @@ -18,11 +20,19 @@ from ._click.shell_completion import CompletionItem from ._click.types import ParamType from ._typing import Literal -from .param_types import DEFAULT_PARAM_TYPE, TyperRanged +from .display import describe_number_range +from .param_types import ( + DEFAULT_PARAM_TYPE, + TyperChoice, + TyperDatetime, + TyperPath, + TyperRanged, + TyperTuple, +) from .schema import ( RuntimeParam, ) -from .utils import describe_number_range, parse_boolean_env_var +from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] MARKUP_MODE_KEY = "TYPER_RICH_MARKUP_MODE" @@ -122,8 +132,63 @@ def process_value(self, ctx: _click.Context, value: Any) -> Any: def value_is_missing(self, value: Any) -> bool: return _value_is_missing(self, value) - def make_metavar(self, ctx: _click.Context) -> str: - return param_types.resolve_metavar(self, ctx=ctx) + def make_metavar(self, ctx: _click.Context) -> str | None: + return self.metavar + + def metavar_label(self) -> str: + annotation = self.runtime_param.annotation + param_type = self.type + if get_origin(annotation) is list: + label = self.metavar_type() + elif isinstance(param_type, TyperDatetime): + label = "|".join(param_type.formats) + elif isinstance(param_type, TyperRanged): + label = f"{param_type.annotation.__name__} range" + elif isinstance(param_type, TyperTuple): + labels = [ + self._metavar_type_by_annotation(a) + for a in param_type.element_annotations + ] + label = ",".join(labels) + elif isinstance(param_type, TyperPath): + label = param_types.path_metavar_label(self.runtime_param.parameter_info) + else: + label = self.metavar_type() + return f"<{label}>" + + def resolve_value_metavar(self, ctx: _click.Context) -> str | None: + return self.metavar_label() + + def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: + metavar_str = self.make_metavar(ctx) + if metavar_str == "BOOL": + return None + return metavar_str + + def metavar_type(self) -> str: + annotation = self.runtime_param.annotation + return self._metavar_type_by_annotation(annotation) + + def _metavar_type_by_annotation(self, annotation: type) -> str: + display_type = str(annotation) + origin = get_origin(annotation) + if annotation is None: + display_type = "str" + elif origin is list: + args = get_args(annotation) + if len(args) == 1: + element_label = self._metavar_type_by_annotation(args[0]) + display_type = f"list[{element_label}]" + else: + display_type = "list" + elif origin is tuple: + labels = [ + self._metavar_type_by_annotation(arg) for arg in get_args(annotation) + ] + display_type = ",".join(labels) + elif isinstance(annotation, type): + display_type = annotation.__name__ + return display_type def _get_default_string( @@ -461,6 +526,48 @@ def get_error_hint(self, ctx: _click.Context) -> str: def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) + def make_metavar(self, ctx: _click.Context) -> str: + if self.metavar is not None: + var = self.metavar + if not self.required and not var.startswith("["): + var = f"[{var}]" + return var + + var = (self.name or "").upper() + if not self.required: + var = f"[{var}]" + + value_metavar = self.resolve_value_metavar(ctx) + if value_metavar: + var += f":{value_metavar}" + + if self.nargs != 1: + var += "..." + return var + + def resolve_value_metavar(self, ctx: _click.Context) -> str | None: + param_type = self.type + if isinstance(param_type, TyperChoice): + normalized_mapping = { + c: param_types.normalize_choice_value(c, param_type.case_sensitive, ctx) + for c in param_type.choices + } + choices_str = "|".join(normalized_mapping.values()) + if self.required: + return f"{{{choices_str}}}" + return f"[{choices_str}]" + if not isinstance(param_type, TyperDatetime): + return None + return self.metavar_label() + + def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: + metavar_str = self.make_metavar(ctx) + if self.name and metavar_str == self.name.upper(): + metavar_str = self.metavar_label() + if metavar_str == "BOOL": + return None + return metavar_str + class TyperOption(TyperParameter): param_type_name = "option" @@ -865,6 +972,40 @@ def _write_opts(opts: Sequence[str]) -> str: return ("; " if any_prefix_is_slash else " / ").join(rv), help + def make_metavar(self, ctx: _click.Context) -> str | None: + if self.metavar is not None: + return self.metavar + + value_metavar = self.resolve_value_metavar(ctx) + if self.nargs != 1: + return str(value_metavar) + "..." + return value_metavar + + def resolve_value_metavar(self, ctx: _click.Context) -> str | None: + param_type = self.type + if isinstance(param_type, TyperChoice): + if not self.show_choices: + metavars = [ + self._metavar_type_by_annotation(type(c)) + for c in param_type.choices + ] + choices_str = "|".join([*dict.fromkeys(metavars)]) + else: + normalized_mapping = { + c: param_types.normalize_choice_value( + c, param_type.case_sensitive, ctx + ) + for c in param_type.choices + } + choices_str = "|".join(normalized_mapping.values()) + + return f"[{choices_str}]" + if self.param_type_name == "argument" and not isinstance( + param_type, TyperDatetime + ): + return None + return self.metavar_label() + def _typer_format_options( self: _click.core.Command, *, ctx: _click.Context, formatter: _click.HelpFormatter diff --git a/typer/display.py b/typer/display.py new file mode 100644 index 0000000000..b536a330c1 --- /dev/null +++ b/typer/display.py @@ -0,0 +1,22 @@ +from pydantic import ValidationError + + +def describe_number_range( + min: int | float | None, + max: int | float | None, +) -> str | None: + if min is None and max is None: + return None + if min is None: + return f"x<={max}" + if max is None: + return f"x>={min}" + return f"{min}<=x<={max}" + + +def get_error_msg(exc: ValidationError) -> str: + """Get a string representation of the (first) validation error.""" + errors = exc.errors() + if errors: + return errors[0]["msg"] + return str(exc) diff --git a/typer/param_types.py b/typer/param_types.py index bbc6496ba5..fc74114b08 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -15,6 +15,7 @@ from ._click.types import ParamType from ._click.utils import LazyFile, format_filename, safecall from ._typing import get_args, get_origin, is_literal_type, literal_values +from .display import get_error_msg from .models import ( AnyType, FileBinaryRead, @@ -35,117 +36,6 @@ def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) return isinstance(cls, type) and issubclass(cls, class_or_tuple) -def _get_error_msg(exc: ValidationError) -> str: - """Get a string representation of the (first) validation error.""" - errors = exc.errors() - if errors: - return errors[0]["msg"] - return str(exc) - - -def _format_option_metavar(param: "TyperParameter", value_metavar: str) -> str: - if param.nargs != 1: - value_metavar += "..." - return value_metavar - - -def _format_argument_metavar(param: "TyperParameter", value_metavar: str | None) -> str: - var = (param.name or "").upper() - if not param.required: - var = f"[{var}]" - if value_metavar: - var += f":{value_metavar}" - if param.nargs != 1: - var += "..." - return var - - -def resolve_metavar(param: "TyperParameter", ctx: Context) -> str: - if param.metavar is not None: - var = param.metavar - if getattr(param, "param_type_name", None) == "argument": - if not param.required and not var.startswith("["): - var = f"[{var}]" - return var - - value_metavar = resolve_value_metavar(param, ctx) - if getattr(param, "param_type_name", None) == "argument": - return _format_argument_metavar(param, value_metavar) - assert value_metavar is not None - return _format_option_metavar(param, value_metavar) - - -def resolve_value_metavar(param: "TyperParameter", ctx: Context) -> str | None: - param_type = param.type - if isinstance(param_type, TyperChoice): - return choice_value_metavar( - param, - ctx, - choices=param_type.choices, - case_sensitive=param_type.case_sensitive, - ) - if getattr(param, "param_type_name", None) == "argument" and not isinstance( - param_type, TyperDatetime - ): - return None - return param_type_metavar_label(param) - - -def resolve_rich_metavar(param: "TyperParameter", ctx: Context) -> str | None: - metavar_str = resolve_metavar(param, ctx) - if ( - getattr(param, "param_type_name", None) == "argument" - and param.name - and metavar_str == param.name.upper() - ): - metavar_str = param_type_metavar_label(param) - if metavar_str == "BOOL": - return None - return metavar_str - - -def _annotation_metavar_label(annotation: Any) -> str: - display_type = str(annotation) - origin = get_origin(annotation) - if annotation is None: - display_type = "str" - elif origin is list: - args = get_args(annotation) - if len(args) == 1: - element_label = _annotation_metavar_label(args[0]) - display_type = f"list[{element_label}]" - else: - display_type = "list" - elif origin is tuple: - labels = [_annotation_metavar_label(arg) for arg in get_args(annotation)] - display_type = ",".join(labels) - elif isinstance(annotation, type): - display_type = annotation.__name__ - return display_type - - -def param_type_metavar_label(param: "TyperParameter") -> str: - annotation = param.runtime_param.annotation - param_type = param.type - if get_origin(annotation) is list: - label = _annotation_metavar_label(annotation) - elif isinstance(param_type, TyperDatetime): - label = "|".join(param_type.formats) - elif isinstance(param_type, TyperRanged): - label = f"{param_type.annotation.__name__} range" - elif isinstance(param_type, TyperTuple): - labels = [ - _annotation_metavar_label(element) - for element in param_type.element_annotations - ] - label = ",".join(labels) - elif isinstance(param_type, TyperPath): - label = path_metavar_label(param.runtime_param.parameter_info) - else: - label = _annotation_metavar_label(param.runtime_param.annotation) - return f"<{label}>" - - def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: """Infer a type from a default value. Returns (annotation, guessed).""" if isinstance(default, tuple) and default: @@ -167,7 +57,7 @@ def resolve_param_type( *, parameter_info: ParameterInfo | None = None, ) -> ParamType: - """Resolve a display ParamType for metavar/help.""" + """Resolve a ParamType for this particular annotation.""" if annotation is None and default is not None: annotation, _ = infer_type_from_default(default) @@ -193,7 +83,7 @@ def cli_param_type( is_list: bool, is_tuple: bool, ) -> ParamType: - """Defer the "type" for metavar/help.""" + """Defer the param type""" if is_tuple: type_args = get_args(annotation) return resolve_param_type(tuple(type_args), parameter_info=parameter_info) @@ -205,10 +95,6 @@ def cli_param_type( ) -def get_param_type(*, annotation: Any, parameter_info: ParameterInfo) -> ParamType: - return resolve_param_type(annotation=annotation, parameter_info=parameter_info) - - def param_type_from_annotation( annotation: Any, parameter_info: ParameterInfo, @@ -297,28 +183,6 @@ def choice_coercion_annotation( return None -def choice_value_metavar( - param: "TyperParameter", - ctx: Context, - *, - choices: Sequence[Any], - case_sensitive: bool, -) -> str: - if param.param_type_name == "option" and not param.show_choices: # type: ignore - metavars = [_annotation_metavar_label(type(c)) for c in choices] - choices_str = "|".join([*dict.fromkeys(metavars)]) - else: - normalized_mapping = { - c: normalize_choice_value(c, case_sensitive, ctx) for c in choices - } - choices_str = "|".join(normalized_mapping.values()) - - if param.required and param.param_type_name == "argument": - return f"{{{choices_str}}}" - - return f"[{choices_str}]" - - class TyperChoice(ParamType, Generic[ParamTypeValue]): name = "choice" @@ -443,7 +307,7 @@ def coerce_cli_path( try: rv = TypeAdapter(path_type).validate_python(value) except ValidationError as exc: - raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc + raise BadParameter(get_error_msg(exc), ctx=ctx, param=param) from exc else: rv = value else: diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 0684259a04..63058087e0 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -31,7 +31,6 @@ TyperOption, get_number_range_help_str, ) -from .param_types import resolve_rich_metavar # Default styles STYLE_OPTION = "bold cyan" @@ -382,7 +381,7 @@ def _print_options_panel( # Column for a metavar, if we have one metavar = Text(style=STYLE_METAVAR, overflow="fold") - metavar_str = resolve_rich_metavar(param, ctx=ctx) + metavar_str = param.resolve_rich_metavar(ctx=ctx) if metavar_str is not None: metavar.append(metavar_str) diff --git a/typer/schema.py b/typer/schema.py index 08d3645bec..01dd016e73 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -10,6 +10,7 @@ from ._click.exceptions import BadParameter, UsageError from ._click.types import ParamType from ._typing import get_args, get_origin, is_union +from .display import get_error_msg from .models import ( ArgumentInfo, NoneType, @@ -22,7 +23,6 @@ if TYPE_CHECKING: from .core import TyperParameter from .param_types import ( - _get_error_msg, _open_cli_file, choice_coercion_annotation, coerce_cli_choice, @@ -98,7 +98,7 @@ def _coerce_value( try: return self.adapter.validate_python(value) except ValidationError as exc: - raise BadParameter(_get_error_msg(exc), ctx=ctx, param=param) from exc + raise BadParameter(get_error_msg(exc), ctx=ctx, param=param) from exc except ValueError as exc: raise BadParameter(str(exc), ctx=ctx, param=param) from exc @@ -256,14 +256,6 @@ def declare_param(param: ParamMeta) -> DeclaredParam: ) -def _runtime_param_fields(declared: DeclaredParam) -> dict[str, Any]: - return { - "name": declared.name, - "annotation": declared.annotation, - "parameter_info": declared.parameter_info, - } - - def bool_flag_runtime_param(*, name: str, default: bool = False) -> RuntimeParam: """Build runtime coercion for a standalone boolean flag option.""" declared = DeclaredParam( @@ -296,7 +288,7 @@ def coerce(value: Any) -> Any: try: return adapter.validate_python(value) except ValidationError as exc: - raise UsageError(_get_error_msg(exc)) from exc + raise UsageError(get_error_msg(exc)) from exc except ValueError as exc: raise UsageError(str(exc)) from exc @@ -304,24 +296,28 @@ def coerce(value: Any) -> Any: def runtime_param_from_declared(declared: DeclaredParam) -> RuntimeParam: - common = _runtime_param_fields(declared) + args = { + "name": declared.name, + "annotation": declared.annotation, + "parameter_info": declared.parameter_info, + } file_annotation = file_coercion_annotation(declared.annotation) if file_annotation is not None: - return FileRuntimeParam(**common, file_annotation=file_annotation) + return FileRuntimeParam(**args, file_annotation=file_annotation) if path_uses_coercion(declared.annotation, declared.parameter_info): return PathRuntimeParam( - **common, + **args, path_type=resolve_path_type(declared.annotation, declared.parameter_info), ) choice = choice_coercion_annotation(declared.annotation, declared.parameter_info) if choice is not None: choices, case_sensitive = choice return ChoiceRuntimeParam( - **common, + **args, choices=choices, case_sensitive=case_sensitive, ) return AdapterRuntimeParam( - **common, + **args, adapter=adapters.build_adapter(declared.annotation, declared.parameter_info), ) diff --git a/typer/utils.py b/typer/utils.py index 5142921114..addf9334d4 100644 --- a/typer/utils.py +++ b/typer/utils.py @@ -186,19 +186,6 @@ def get_params_from_function(func: Callable[..., Any]) -> dict[str, ParamMeta]: return params -def describe_number_range( - min: int | float | None, - max: int | float | None, -) -> str | None: - if min is None and max is None: - return None - if min is None: - return f"x<={max}" - if max is None: - return f"x>={min}" - return f"{min}<=x<={max}" - - def parse_boolean_env_var(env_var_value: str | None, default: bool) -> bool: if env_var_value is None: return default From 0264bca9fa9861d96ca2e1c0d359a41bd0bf11a5 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Thu, 18 Jun 2026 18:25:26 +0200 Subject: [PATCH 50/73] fix fixture and isolate temp dir --- .../test_app_dir/test_tutorial001.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_tutorial/test_app_dir/test_tutorial001.py b/tests/test_tutorial/test_app_dir/test_tutorial001.py index a0f5e001dc..00ed43a79c 100644 --- a/tests/test_tutorial/test_app_dir/test_tutorial001.py +++ b/tests/test_tutorial/test_app_dir/test_tutorial001.py @@ -11,20 +11,20 @@ runner = CliRunner() +@pytest.fixture(name="app_dir") +def isolated_app_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(typer, "get_app_dir", lambda app_name: str(tmp_path)) + return tmp_path + + @pytest.fixture(name="config_file") -def create_config_file(): - app_dir = Path(typer.get_app_dir("my-super-cli-app")) - app_dir.mkdir(parents=True, exist_ok=True) +def create_config_file(app_dir: Path) -> Path: config_path = app_dir / "config.json" config_path.touch(exist_ok=True) - - yield config_path - - config_path.unlink() - app_dir.rmdir() + return config_path -def test_cli_config_doesnt_exist(): +def test_cli_config_doesnt_exist(app_dir: Path): result = runner.invoke(mod.app) assert result.exit_code == 0 assert "Config file doesn't exist yet" in result.output From de4e031f7c003ed39e4c7f157d3f560030bf6c93 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 19 Jun 2026 11:16:49 +0200 Subject: [PATCH 51/73] clean up default inference --- typer/_click/core.py | 8 +-- typer/_click/decorators.py | 2 + typer/_click/termui.py | 5 +- typer/core.py | 20 +++---- typer/main.py | 14 ++--- typer/param_types.py | 114 ++++++++++++++++++++----------------- typer/schema.py | 35 +++--------- 7 files changed, 90 insertions(+), 108 deletions(-) diff --git a/typer/_click/core.py b/typer/_click/core.py index cebf3b9ccf..1b8dbe9008 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -16,7 +16,6 @@ overload, ) -from . import types from .exceptions import ( Abort, BadParameter, @@ -28,6 +27,7 @@ from .globals import pop_context, push_context from .parser import _OptionParser from .termui import style +from .types import ParamType from .utils import echo, make_default_short_help if TYPE_CHECKING: @@ -818,7 +818,7 @@ class Parameter(ABC): def __init__( self, param_decls: Sequence[str] | None = None, - type: types.ParamType | Any | None = None, + type: ParamType | None = None, required: bool = False, default: Any | Callable[[], Any] | None = None, callback: Callable[[Context, "Parameter", Any], Any] | None = None, @@ -839,9 +839,7 @@ def __init__( self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) - from ..param_types import resolve_param_type - - self.type = resolve_param_type(type, default) + self.type = type if type is not None else ParamType() # Default nargs to what the type tells us if we have that # information available. diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py index 9cb74f5e6f..ad493a205d 100644 --- a/typer/_click/decorators.py +++ b/typer/_click/decorators.py @@ -41,6 +41,7 @@ def decorator(f: Command) -> Command: def help_option(param_decls: list[str]) -> Callable[[Command], Command]: """Help option which prints the help page and exits the program.""" from ..schema import bool_flag_runtime_param + from .types import ParamType def show_help(ctx: Context, param: Parameter, value: bool) -> None: """Callback that print the help page on ```` and exits.""" @@ -58,5 +59,6 @@ def show_help(ctx: Context, param: Parameter, value: bool) -> None: help="Show this message and exit.", callback=show_help, required=False, + type=ParamType(), runtime_param=bool_flag_runtime_param(name="help", default=False), ) diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 8f0ab731a6..4414e70712 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -108,11 +108,12 @@ def prompt_func(text: str) -> str: raise Abort() from None if value_proc is None: - from ..param_types import resolve_param_type + from ..param_types import annotation_from_prompt, resolve_param_type from ..schema import prompt_value_proc + annotation = annotation_from_prompt(type, default) value_proc = prompt_value_proc(type, default) - type = resolve_param_type(type, default) + type = resolve_param_type(annotation) prompt = _build_prompt( text, prompt_suffix, show_default, default, show_choices, type diff --git a/typer/core.py b/typer/core.py index 4b6387952a..7fd30716f3 100644 --- a/typer/core.py +++ b/typer/core.py @@ -22,12 +22,12 @@ from ._typing import Literal from .display import describe_number_range from .param_types import ( - DEFAULT_PARAM_TYPE, TyperChoice, TyperDatetime, TyperPath, TyperRanged, TyperTuple, + lenient_issubclass, ) from .schema import ( RuntimeParam, @@ -138,7 +138,7 @@ def make_metavar(self, ctx: _click.Context) -> str | None: def metavar_label(self) -> str: annotation = self.runtime_param.annotation param_type = self.type - if get_origin(annotation) is list: + if lenient_issubclass(get_origin(annotation), list): label = self.metavar_type() elif isinstance(param_type, TyperDatetime): label = "|".join(param_type.formats) @@ -361,7 +361,7 @@ def __init__( # Parameter param_decls: list[str], runtime_param: RuntimeParam, - type: Any | None = None, + type: ParamType, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -580,7 +580,7 @@ def __init__( # Parameter param_decls: list[str], runtime_param: RuntimeParam, - type: ParamType | Any | None = None, + type: ParamType, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -656,12 +656,8 @@ def __init__( self.hidden = hidden # TODO: revisit all of this flag stuff - inferred_bool_flag = bool(is_flag and type is None and not count) - if inferred_bool_flag: - self.type: ParamType = DEFAULT_PARAM_TYPE - self.is_flag: bool = bool(is_flag) - self.is_bool_flag: bool = inferred_bool_flag + self.is_bool_flag: bool = bool(is_flag and not count) if self.is_flag: self._depr_flag_value = True @@ -670,10 +666,8 @@ def __init__( # Counting self.count = count - if count and type is None: - self.type = TyperRanged(int) - if self.min is None: - self.min = 0 + if count and self.min is None: + self.min = 0 self.allow_from_autoenv = allow_from_autoenv self.help = help diff --git a/typer/main.py b/typer/main.py index 1cc3d8ea3e..ef120c5dd0 100644 --- a/typer/main.py +++ b/typer/main.py @@ -15,7 +15,6 @@ from . import _click from ._click.globals import get_current_context -from ._click.types import ParamType from ._typing import get_args, get_origin from .completion import get_completion_inspect_parameters from .core import ( @@ -38,7 +37,7 @@ ParamMeta, TyperInfo, ) -from .param_types import cli_param_type, lenient_issubclass +from .param_types import TyperRanged, cli_param_type, lenient_issubclass from .schema import declare_param, runtime_param_from_declared from .utils import get_params_from_function @@ -1457,7 +1456,7 @@ def get_param( default_value = declared.default required = declared.required annotation_args = get_args(declared.annotation) - is_list = get_origin(declared.annotation) is list + is_list = lenient_issubclass(get_origin(declared.annotation), list) is_flag = None if isinstance(parameter_info, OptionInfo): if declared.annotation is bool: @@ -1469,17 +1468,16 @@ def get_param( and any("/" in decl for decl in parameter_info.param_decls) ): is_flag = True - parameter_type: ParamType | None = cli_param_type( + parameter_type = cli_param_type( annotation=declared.annotation, parameter_info=parameter_info, - default=default_value, is_list=is_list, - is_tuple=get_origin(declared.annotation) is tuple, + is_tuple=lenient_issubclass(get_origin(declared.annotation), tuple), ) if isinstance(parameter_info, OptionInfo): - if is_flag: - parameter_type = None + if parameter_info.count: + parameter_type = TyperRanged(int) default_option_name = get_command_name(param.name) if is_flag: default_option_declaration = ( diff --git a/typer/param_types.py b/typer/param_types.py index fc74114b08..5e86ab57df 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -4,7 +4,17 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, ClassVar, Generic, TypeGuard, TypeVar, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + ClassVar, + Generic, + TypeAlias, + TypeGuard, + TypeVar, + cast, +) from pydantic import TypeAdapter, ValidationError @@ -29,93 +39,91 @@ from .core import TyperParameter ParamTypeValue = TypeVar("ParamTypeValue") -DEFAULT_PARAM_TYPE = ParamType() + +ParameterAnnotation: TypeAlias = Any def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) -> bool: return isinstance(cls, type) and issubclass(cls, class_or_tuple) -def infer_type_from_default(default: Any) -> tuple[Any | None, bool]: - """Infer a type from a default value. Returns (annotation, guessed).""" - if isinstance(default, tuple) and default: +def _normalize_inferred_scalar_type(annotation: type) -> ParameterAnnotation: + if annotation in (int, float, bool): + return annotation + return str + + +def infer_annotation_from_default(default: Any | None) -> ParameterAnnotation: + """Infer a normalized annotation from a default value.""" + if default is None: + return str + if isinstance(default, tuple) and len(default) > 0: if not isinstance(default[0], (tuple, list)): - return tuple(map(type, default)), True + return tuple.__class_getitem__(tuple(map(type, default))) if isinstance(default, (tuple, list)): if not default: - return None, True + return str item = default[0] if isinstance(item, (tuple, list)): - return tuple(map(type, item)), True - return type(item), True - return type(default), True + return tuple.__class_getitem__(tuple(map(type, item))) + return _normalize_inferred_scalar_type(type(item)) + return _normalize_inferred_scalar_type(type(default)) + + +def annotation_from_prompt(t: Any | None, default: Any | None) -> ParameterAnnotation: + if t is not None and not isinstance(t, ParamType): + return t + return infer_annotation_from_default(default) def resolve_param_type( - annotation: Any | None = None, - default: Any | None = None, - *, + annotation: ParameterAnnotation, parameter_info: ParameterInfo | None = None, ) -> ParamType: """Resolve a ParamType for this particular annotation.""" - if annotation is None and default is not None: - annotation, _ = infer_type_from_default(default) + if isinstance(annotation, ParamType): + return annotation if isinstance(annotation, tuple): return TyperTuple(annotation) - if isinstance(annotation, ParamType): - return annotation - - if parameter_info is not None and annotation is not None: - param_type = param_type_from_annotation(annotation, parameter_info) - if param_type is not None: - return param_type + if parameter_info is not None: + if annotation is int or annotation is float: + if parameter_info.min is not None or parameter_info.max is not None: + return TyperRanged(annotation) + if annotation is datetime: + f = parameter_info.formats + formats_tuple = tuple(f) if f is not None else ("%Y-%m-%d",) + return TyperDatetime(formats=formats_tuple) + if _needs_typer_path(annotation, parameter_info): + return TyperPath() + if lenient_issubclass(annotation, Enum): + return TyperChoice(list(annotation), parameter_info.case_sensitive) + if is_literal_type(annotation): + return TyperChoice( + literal_values(annotation), parameter_info.case_sensitive + ) + if lenient_issubclass(annotation, CLI_FILE_TYPES): + return TyperFile() - return DEFAULT_PARAM_TYPE + return ParamType() def cli_param_type( *, - annotation: Any, + annotation: ParameterAnnotation, parameter_info: ParameterInfo, - default: Any, is_list: bool, is_tuple: bool, ) -> ParamType: """Defer the param type""" if is_tuple: type_args = get_args(annotation) - return resolve_param_type(tuple(type_args), parameter_info=parameter_info) + return resolve_param_type(tuple(type_args), parameter_info) if is_list: (element_type,) = get_args(annotation) - return resolve_param_type(element_type, parameter_info=parameter_info) - return resolve_param_type( - annotation, default=default, parameter_info=parameter_info - ) - - -def param_type_from_annotation( - annotation: Any, - parameter_info: ParameterInfo, -) -> ParamType | None: - if annotation is int or annotation is float: - if parameter_info.min is not None or parameter_info.max is not None: - return TyperRanged(annotation) - return None - if annotation is datetime: - f = parameter_info.formats - formats_tuple = tuple(f) if f is not None else ("%Y-%m-%d",) - return TyperDatetime(formats=formats_tuple) - if _needs_typer_path(annotation, parameter_info): - return TyperPath() - if lenient_issubclass(annotation, Enum): - return TyperChoice(list(annotation), parameter_info.case_sensitive) - if is_literal_type(annotation): - return TyperChoice(literal_values(annotation), parameter_info.case_sensitive) - if lenient_issubclass(annotation, CLI_FILE_TYPES): - return TyperFile() - return None + return resolve_param_type(element_type, parameter_info) + return resolve_param_type(annotation, parameter_info) # DATETIME # diff --git a/typer/schema.py b/typer/schema.py index 01dd016e73..45a8e974f8 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -8,7 +8,6 @@ from . import adapters from ._click import Context from ._click.exceptions import BadParameter, UsageError -from ._click.types import ParamType from ._typing import get_args, get_origin, is_union from .display import get_error_msg from .models import ( @@ -23,12 +22,14 @@ if TYPE_CHECKING: from .core import TyperParameter from .param_types import ( + ParameterAnnotation, _open_cli_file, + annotation_from_prompt, choice_coercion_annotation, coerce_cli_choice, coerce_cli_path, file_coercion_annotation, - infer_type_from_default, + infer_annotation_from_default, lenient_issubclass, path_uses_coercion, resolve_file_mode, @@ -44,7 +45,7 @@ class DeclaredParam: parameter_info: ParameterInfo default: Any required: bool - annotation: Any + annotation: ParameterAnnotation @dataclass(frozen=True) @@ -53,7 +54,7 @@ class RuntimeParam(ABC): name: str parameter_info: ParameterInfo - annotation: Any + annotation: ParameterAnnotation def coerce( self, @@ -196,7 +197,7 @@ def declare_param(param: ParamMeta) -> DeclaredParam: default = param.default parameter_info = OptionInfo() - pydantic_annotation: Any + pydantic_annotation: ParameterAnnotation if param.annotation is not param.empty: main_type = param.annotation @@ -231,21 +232,7 @@ def declare_param(param: ParamMeta) -> DeclaredParam: else: pydantic_annotation = main_type else: - if default is not None: - main_type, guessed = infer_type_from_default(default) - if main_type is None: - main_type = str - elif ( - guessed - and isinstance(main_type, tuple) - and all(isinstance(item, type) for item in main_type) - ): - main_type = tuple.__class_getitem__(main_type) - elif guessed and main_type not in (int, float, bool, str): - main_type = str - else: - main_type = str - pydantic_annotation = main_type + pydantic_annotation = infer_annotation_from_default(default) return DeclaredParam( name=param.name, @@ -273,13 +260,7 @@ def prompt_value_proc( default: Any | None = None, ) -> Callable[[Any], Any]: """Coerce interactive prompt input via the runtime adapter layer.""" - annotation = type - if isinstance(annotation, ParamType): - annotation = None - if annotation is None and default is not None: - annotation, _ = infer_type_from_default(default) - if annotation is None: - annotation = str + annotation = annotation_from_prompt(type, default) parameter_info = OptionInfo() adapter = adapters.build_adapter(annotation, parameter_info) From 0c8b6a8aec1fe697156c7d02596a0d9d87016458 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 19 Jun 2026 11:55:20 +0200 Subject: [PATCH 52/73] PassThroughRuntimeParam instead of falling back to str --- tests/test_schema.py | 63 +++++++++++++++++++++++++++++++++++++++++++ typer/adapters.py | 12 +++++++++ typer/core.py | 56 +++++++++++++++++++------------------- typer/param_types.py | 10 ++----- typer/schema.py | 64 +++++++++++++++++++++++++++++++++----------- 5 files changed, 154 insertions(+), 51 deletions(-) diff --git a/tests/test_schema.py b/tests/test_schema.py index e848456e2b..e43f94c320 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -64,3 +64,66 @@ def main(files: tuple[typer.FileText, typer.FileText]): result = runner.invoke(app, [str(first), str(second)]) assert result.exit_code == 0, result.output assert seen == ["first-content\n", "second-content\n"] + + +def test_passthrough_runtime_param_default() -> None: + class Widget: + def __init__(self, value: int) -> None: + self.value = value + + def __repr__(self) -> str: + return f"Widget({self.value})" + + app = typer.Typer() + seen: dict[str, Widget] = {} + + @app.command() + def main(val=Widget(42)): + seen["val"] = val + + param = next(p for p in typer.main.get_command(app).params if p.name == "val") + assert param.runtime_param is not None + assert param.runtime_param.annotation is Widget + + result = runner.invoke(app) + assert result.exit_code == 0 + assert isinstance(seen["val"], Widget) + assert seen["val"].value == 42 + + result = runner.invoke(app, ["--val", "666"]) + assert result.exit_code == 2 + # This doesn't work because there's no parser + assert "is not a valid Widget" in result.output + + +def test_widget_parsed_from_cli_with_parser() -> None: + class Widget: + def __init__(self, value: int) -> None: + self.value = value + + def __repr__(self) -> str: + return f"Widget({self.value})" + + def parse_widget(value: str) -> Widget: + return Widget(int(value)) + + app = typer.Typer() + seen: dict[str, Widget] = {} + + @app.command() + def main(val: Widget = typer.Option("42", parser=parse_widget)): + seen["val"] = val + + param = next(p for p in typer.main.get_command(app).params if p.name == "val") + assert param.runtime_param is not None + assert param.runtime_param.annotation is Widget + + result = runner.invoke(app) + assert result.exit_code == 0 + assert isinstance(seen["val"], Widget) + assert seen["val"].value == 42 + + result = runner.invoke(app, ["--val", "666"]) + assert result.exit_code == 0 + assert isinstance(seen["val"], Widget) + assert seen["val"].value == 666 diff --git a/typer/adapters.py b/typer/adapters.py index 09e3ce7605..2f6d60b8ce 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -5,6 +5,7 @@ from typing import Annotated, Any, get_args, get_origin from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter +from pydantic.errors import PydanticSchemaGenerationError from ._click import _compat from ._typing import is_literal_type, is_number_type, literal_values @@ -65,6 +66,17 @@ def parse_tuple(value: Any) -> tuple[Any, ...]: return build_leaf_adapter(annotation, parameter_info=parameter_info) +def try_build_adapter( + annotation: Any, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any] | None: + """Build a TypeAdapter when Pydantic can schema-generate the annotation.""" + try: + return build_adapter(annotation, parameter_info) + except PydanticSchemaGenerationError: + return None + + def build_leaf_adapter( annotation: Any, *, diff --git a/typer/core.py b/typer/core.py index 7fd30716f3..36f5d2448c 100644 --- a/typer/core.py +++ b/typer/core.py @@ -500,6 +500,25 @@ def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: help = f"{help} {extra_str}" if help else f"{extra_str}" return name, help + def make_metavar(self, ctx: _click.Context) -> str: + if self.metavar is not None: + var = self.metavar + if not self.required and not var.startswith("["): + var = f"[{var}]" + return var + + var = (self.name or "").upper() + if not self.required: + var = f"[{var}]" + + value_metavar = self.resolve_value_metavar(ctx) + if value_metavar: + var += f":{value_metavar}" + + if self.nargs != 1: + var += "..." + return var + def _parse_decls( self, decls: Sequence[str], expose_value: bool ) -> tuple[str | None, list[str], list[str]]: @@ -526,25 +545,6 @@ def get_error_hint(self, ctx: _click.Context) -> str: def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) - def make_metavar(self, ctx: _click.Context) -> str: - if self.metavar is not None: - var = self.metavar - if not self.required and not var.startswith("["): - var = f"[{var}]" - return var - - var = (self.name or "").upper() - if not self.required: - var = f"[{var}]" - - value_metavar = self.resolve_value_metavar(ctx) - if value_metavar: - var += f":{value_metavar}" - - if self.nargs != 1: - var += "..." - return var - def resolve_value_metavar(self, ctx: _click.Context) -> str | None: param_type = self.type if isinstance(param_type, TyperChoice): @@ -873,6 +873,15 @@ def _extract_default_help_str( ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) + def make_metavar(self, ctx: _click.Context) -> str | None: + if self.metavar is not None: + return self.metavar + + value_metavar = self.resolve_value_metavar(ctx) + if self.nargs != 1: + return str(value_metavar) + "..." + return value_metavar + def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: # Duplicate all of Click's logic only to modify a single line, to allow boolean # flags with only names for False values as it's currently supported by Typer @@ -966,15 +975,6 @@ def _write_opts(opts: Sequence[str]) -> str: return ("; " if any_prefix_is_slash else " / ").join(rv), help - def make_metavar(self, ctx: _click.Context) -> str | None: - if self.metavar is not None: - return self.metavar - - value_metavar = self.resolve_value_metavar(ctx) - if self.nargs != 1: - return str(value_metavar) + "..." - return value_metavar - def resolve_value_metavar(self, ctx: _click.Context) -> str | None: param_type = self.type if isinstance(param_type, TyperChoice): diff --git a/typer/param_types.py b/typer/param_types.py index 5e86ab57df..63281c155b 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -47,12 +47,6 @@ def lenient_issubclass(cls: Any, class_or_tuple: AnyType | tuple[AnyType, ...]) return isinstance(cls, type) and issubclass(cls, class_or_tuple) -def _normalize_inferred_scalar_type(annotation: type) -> ParameterAnnotation: - if annotation in (int, float, bool): - return annotation - return str - - def infer_annotation_from_default(default: Any | None) -> ParameterAnnotation: """Infer a normalized annotation from a default value.""" if default is None: @@ -66,8 +60,8 @@ def infer_annotation_from_default(default: Any | None) -> ParameterAnnotation: item = default[0] if isinstance(item, (tuple, list)): return tuple.__class_getitem__(tuple(map(type, item))) - return _normalize_inferred_scalar_type(type(item)) - return _normalize_inferred_scalar_type(type(default)) + return type(item) + return type(default) def annotation_from_prompt(t: Any | None, default: Any | None) -> ParameterAnnotation: diff --git a/typer/schema.py b/typer/schema.py index 45a8e974f8..7de04f93db 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -155,6 +155,29 @@ def _coerce_value( ) +@dataclass(frozen=True) +class PassThroughRuntimeParam(RuntimeParam): + """Coercion for annotations that cannot use a Pydantic TypeAdapter.""" + + def _coerce_value( + self, + value: Any, + *, + param: "TyperParameter", + ctx: Context, + ) -> Any: + annotation = self.annotation + if isinstance(annotation, type): + if isinstance(value, annotation): + return value + label = getattr(annotation, "__name__", repr(annotation)) + raise BadParameter( + f"Value {value!r} is not a valid {label}.", + ctx=ctx, + param=param, + ) + + @dataclass(frozen=True) class ChoiceRuntimeParam(RuntimeParam): """Coercion for enum and literal choice parameters.""" @@ -256,24 +279,35 @@ def bool_flag_runtime_param(*, name: str, default: bool = False) -> RuntimeParam def prompt_value_proc( - type: Any | None = None, + param_type: Any | None = None, default: Any | None = None, ) -> Callable[[Any], Any]: """Coerce interactive prompt input via the runtime adapter layer.""" - annotation = annotation_from_prompt(type, default) + annotation = annotation_from_prompt(param_type, default) parameter_info = OptionInfo() - adapter = adapters.build_adapter(annotation, parameter_info) + adapter = adapters.try_build_adapter(annotation, parameter_info) - def coerce(value: Any) -> Any: - try: - return adapter.validate_python(value) - except ValidationError as exc: - raise UsageError(get_error_msg(exc)) from exc - except ValueError as exc: - raise UsageError(str(exc)) from exc + if adapter is not None: + + def coerce(value: Any) -> Any: + try: + return adapter.validate_python(value) + except ValidationError as exc: + raise UsageError(get_error_msg(exc)) from exc + except ValueError as exc: + raise UsageError(str(exc)) from exc + + return coerce - return coerce + def coerce_pass_through(value: Any) -> Any: + if isinstance(annotation, type): + if isinstance(value, annotation): + return value + label = getattr(annotation, "__name__", repr(annotation)) + raise UsageError(f"Value {value!r} is not a valid {label}.") + + return coerce_pass_through def runtime_param_from_declared(declared: DeclaredParam) -> RuntimeParam: @@ -298,7 +332,7 @@ def runtime_param_from_declared(declared: DeclaredParam) -> RuntimeParam: choices=choices, case_sensitive=case_sensitive, ) - return AdapterRuntimeParam( - **args, - adapter=adapters.build_adapter(declared.annotation, declared.parameter_info), - ) + adapter = adapters.try_build_adapter(declared.annotation, declared.parameter_info) + if adapter is not None: + return AdapterRuntimeParam(**args, adapter=adapter) + return PassThroughRuntimeParam(**args) From 042d16c8830a6ea0ddea776cdbc406a23294cb07 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 19 Jun 2026 12:09:30 +0200 Subject: [PATCH 53/73] small refactors for diff readability --- typer/core.py | 55 +++++++++++++++++++-------------------------- typer/rich_utils.py | 9 ++------ 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/typer/core.py b/typer/core.py index 36f5d2448c..33b725760f 100644 --- a/typer/core.py +++ b/typer/core.py @@ -45,17 +45,6 @@ DEFAULT_MARKUP_MODE = None -def get_number_range_help_str(param: "TyperOption | TyperArgument") -> str | None: - if ( - isinstance(param, TyperOption) - and param.count - and param.min == 0 - and param.max is None - ): - return None - return describe_number_range(param.min, param.max) - - # Copy from _click.parser._split_opt def _split_opt(opt: str) -> tuple[str, str]: first = opt[:1] @@ -106,16 +95,6 @@ def compat_autocompletion( self._custom_shell_complete = compat_autocompletion -def _value_is_missing(param: _click.Parameter, value: Any) -> bool: - if value is None: - return True - - if (param.nargs != 1 or param.multiple) and value == (): - return True - - return False - - class TyperParameter(_click.core.Parameter): """Typer parameter with runtime coercion.""" @@ -130,7 +109,11 @@ def process_value(self, ctx: _click.Context, value: Any) -> Any: return value def value_is_missing(self, value: Any) -> bool: - return _value_is_missing(self, value) + if value is None: + return True + if (self.nargs != 1 or self.multiple) and value == (): + return True + return False def make_metavar(self, ctx: _click.Context) -> str | None: return self.metavar @@ -190,6 +173,9 @@ def _metavar_type_by_annotation(self, annotation: type) -> str: display_type = annotation.__name__ return display_type + def get_number_range_help_str(self) -> str | None: + return None + def _get_default_string( obj: Union["TyperArgument", "TyperOption"], @@ -360,8 +346,8 @@ def __init__( *, # Parameter param_decls: list[str], - runtime_param: RuntimeParam, type: ParamType, + runtime_param: RuntimeParam, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -480,7 +466,7 @@ def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: # Typer override end if default_string: extra.append(_("default: {default}").format(default=default_string)) - range_str = get_number_range_help_str(self) + range_str = self.get_number_range_help_str() if range_str: extra.append(range_str) if self.required: @@ -506,15 +492,12 @@ def make_metavar(self, ctx: _click.Context) -> str: if not self.required and not var.startswith("["): var = f"[{var}]" return var - var = (self.name or "").upper() if not self.required: var = f"[{var}]" - - value_metavar = self.resolve_value_metavar(ctx) - if value_metavar: - var += f":{value_metavar}" - + type_var = self.resolve_value_metavar(ctx) + if type_var: + var += f":{type_var}" if self.nargs != 1: var += "..." return var @@ -568,6 +551,9 @@ def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: return None return metavar_str + def get_number_range_help_str(self) -> str | None: + return describe_number_range(self.min, self.max) + class TyperOption(TyperParameter): param_type_name = "option" @@ -579,8 +565,8 @@ def __init__( *, # Parameter param_decls: list[str], - runtime_param: RuntimeParam, type: ParamType, + runtime_param: RuntimeParam, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -952,7 +938,7 @@ def _write_opts(opts: Sequence[str]) -> str: if default_string: extra.append(_("default: {default}").format(default=default_string)) - range_str = get_number_range_help_str(self) + range_str = self.get_number_range_help_str() if range_str: extra.append(range_str) @@ -1000,6 +986,11 @@ def resolve_value_metavar(self, ctx: _click.Context) -> str | None: return None return self.metavar_label() + def get_number_range_help_str(self) -> str | None: + if self.count and self.min == 0 and self.max is None: + return None + return describe_number_range(self.min, self.max) + def _typer_format_options( self: _click.core.Command, *, ctx: _click.Context, formatter: _click.HelpFormatter diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 63058087e0..d3614225f7 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -25,12 +25,7 @@ from typer.models import DeveloperExceptionConfig from . import _click -from .core import ( - TyperArgument, - TyperGroup, - TyperOption, - get_number_range_help_str, -) +from .core import TyperArgument, TyperGroup, TyperOption # Default styles STYLE_OPTION = "bold cyan" @@ -385,7 +380,7 @@ def _print_options_panel( if metavar_str is not None: metavar.append(metavar_str) - range_str = get_number_range_help_str(param) + range_str = param.get_number_range_help_str() if range_str: metavar.append(RANGE_STRING.format(range_str)) From b448af6d60f659e5e3ee2ea8446327dba4023c02 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 22 Jun 2026 11:34:38 +0200 Subject: [PATCH 54/73] formatting --- typer/adapters.py | 22 +++++++++---------- typer/core.py | 4 +--- typer/main.py | 4 +--- typer/schema.py | 54 ++++++++--------------------------------------- 4 files changed, 22 insertions(+), 62 deletions(-) diff --git a/typer/adapters.py b/typer/adapters.py index 2f6d60b8ce..b4f5404149 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -19,6 +19,17 @@ ) +def try_build_adapter( + annotation: Any, + parameter_info: ParameterInfo, +) -> TypeAdapter[Any] | None: + """Build a TypeAdapter when Pydantic can schema-generate the annotation.""" + try: + return build_adapter(annotation, parameter_info) + except PydanticSchemaGenerationError: + return None + + def build_adapter( annotation: Any, parameter_info: ParameterInfo, @@ -66,17 +77,6 @@ def parse_tuple(value: Any) -> tuple[Any, ...]: return build_leaf_adapter(annotation, parameter_info=parameter_info) -def try_build_adapter( - annotation: Any, - parameter_info: ParameterInfo, -) -> TypeAdapter[Any] | None: - """Build a TypeAdapter when Pydantic can schema-generate the annotation.""" - try: - return build_adapter(annotation, parameter_info) - except PydanticSchemaGenerationError: - return None - - def build_leaf_adapter( annotation: Any, *, diff --git a/typer/core.py b/typer/core.py index 33b725760f..140ef798e5 100644 --- a/typer/core.py +++ b/typer/core.py @@ -29,9 +29,7 @@ TyperTuple, lenient_issubclass, ) -from .schema import ( - RuntimeParam, -) +from .schema import RuntimeParam from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] diff --git a/typer/main.py b/typer/main.py index ef120c5dd0..406ef0d2a3 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1448,9 +1448,7 @@ def wrapper(**kwargs: Any) -> Any: return wrapper -def get_param( - param: ParamMeta, -) -> TyperArgument | TyperOption: +def get_param(param: ParamMeta) -> TyperArgument | TyperOption: declared = declare_param(param) parameter_info = declared.parameter_info default_value = declared.default diff --git a/typer/schema.py b/typer/schema.py index 7de04f93db..f0bbbc2116 100644 --- a/typer/schema.py +++ b/typer/schema.py @@ -18,9 +18,6 @@ ParamMeta, Required, ) - -if TYPE_CHECKING: - from .core import TyperParameter from .param_types import ( ParameterAnnotation, _open_cli_file, @@ -36,6 +33,9 @@ resolve_path_type, ) +if TYPE_CHECKING: + from .core import TyperParameter + @dataclass(frozen=True) class DeclaredParam: @@ -73,13 +73,7 @@ def coerce( return self._coerce_value(value, param=param, ctx=ctx) @abstractmethod - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: pass @@ -89,13 +83,7 @@ class AdapterRuntimeParam(RuntimeParam): adapter: TypeAdapter[Any] - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: try: return self.adapter.validate_python(value) except ValidationError as exc: @@ -110,13 +98,7 @@ class FileRuntimeParam(RuntimeParam): file_annotation: Any - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: mode = resolve_file_mode(self.parameter_info, self.file_annotation) def open_one(item: Any) -> IO[Any]: @@ -139,13 +121,7 @@ class PathRuntimeParam(RuntimeParam): path_type: type[Any] | None - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: return coerce_cli_path( value, self.parameter_info, @@ -159,13 +135,7 @@ def _coerce_value( class PassThroughRuntimeParam(RuntimeParam): """Coercion for annotations that cannot use a Pydantic TypeAdapter.""" - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: annotation = self.annotation if isinstance(annotation, type): if isinstance(value, annotation): @@ -185,13 +155,7 @@ class ChoiceRuntimeParam(RuntimeParam): choices: tuple[Any, ...] case_sensitive: bool - def _coerce_value( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: try: return coerce_cli_choice( value, From 0bf9b2facf1a42a0c050ad3755cc3dc88aa8ec72 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 22 Jun 2026 12:02:43 +0200 Subject: [PATCH 55/73] remove intermediate layer DeclaredParam --- tests/{test_schema.py => test_coercion.py} | 0 typer/_click/decorators.py | 4 +- typer/_click/termui.py | 2 +- typer/{schema.py => coercion.py} | 160 +++++---------------- typer/core.py | 2 +- typer/main.py | 41 ++++-- typer/param_types.py | 41 +++++- 7 files changed, 107 insertions(+), 143 deletions(-) rename tests/{test_schema.py => test_coercion.py} (100%) rename typer/{schema.py => coercion.py} (61%) diff --git a/tests/test_schema.py b/tests/test_coercion.py similarity index 100% rename from tests/test_schema.py rename to tests/test_coercion.py diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py index ad493a205d..b4c5d4e80a 100644 --- a/typer/_click/decorators.py +++ b/typer/_click/decorators.py @@ -40,7 +40,7 @@ def decorator(f: Command) -> Command: def help_option(param_decls: list[str]) -> Callable[[Command], Command]: """Help option which prints the help page and exits the program.""" - from ..schema import bool_flag_runtime_param + from ..coercion import bool_flag_runtime_param from .types import ParamType def show_help(ctx: Context, param: Parameter, value: bool) -> None: @@ -60,5 +60,5 @@ def show_help(ctx: Context, param: Parameter, value: bool) -> None: callback=show_help, required=False, type=ParamType(), - runtime_param=bool_flag_runtime_param(name="help", default=False), + runtime_param=bool_flag_runtime_param(), ) diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 4414e70712..3fbe38af5e 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -109,7 +109,7 @@ def prompt_func(text: str) -> str: if value_proc is None: from ..param_types import annotation_from_prompt, resolve_param_type - from ..schema import prompt_value_proc + from ..coercion import prompt_value_proc annotation = annotation_from_prompt(type, default) value_proc = prompt_value_proc(type, default) diff --git a/typer/schema.py b/typer/coercion.py similarity index 61% rename from typer/schema.py rename to typer/coercion.py index f0bbbc2116..a3af7f2a0b 100644 --- a/typer/schema.py +++ b/typer/coercion.py @@ -8,16 +8,8 @@ from . import adapters from ._click import Context from ._click.exceptions import BadParameter, UsageError -from ._typing import get_args, get_origin, is_union from .display import get_error_msg -from .models import ( - ArgumentInfo, - NoneType, - OptionInfo, - ParameterInfo, - ParamMeta, - Required, -) +from .models import OptionInfo, ParameterInfo from .param_types import ( ParameterAnnotation, _open_cli_file, @@ -26,8 +18,6 @@ coerce_cli_choice, coerce_cli_path, file_coercion_annotation, - infer_annotation_from_default, - lenient_issubclass, path_uses_coercion, resolve_file_mode, resolve_path_type, @@ -37,32 +27,14 @@ from .core import TyperParameter -@dataclass(frozen=True) -class DeclaredParam: - """Parameter metadata declared on a Typer command callback.""" - - name: str - parameter_info: ParameterInfo - default: Any - required: bool - annotation: ParameterAnnotation - - @dataclass(frozen=True) class RuntimeParam(ABC): """Runtime coercion contract for one command parameter.""" - name: str parameter_info: ParameterInfo annotation: ParameterAnnotation - def coerce( - self, - value: Any, - *, - param: "TyperParameter", - ctx: Context, - ) -> Any: + def coerce(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: is_multi_value = param.multiple or param.nargs == -1 if value is None: if is_multi_value: @@ -167,79 +139,43 @@ def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> An raise BadParameter(str(exc), ctx=ctx, param=param) from exc -def declare_param(param: ParamMeta) -> DeclaredParam: - """Declare metadata from a function parameter.""" - default = None - required = False - if isinstance(param.default, ParameterInfo): - parameter_info = param.default - if parameter_info.default == Required: - required = True - else: - default = parameter_info.default - elif param.default == Required or param.default is param.empty: - required = True - parameter_info = ArgumentInfo() - else: - default = param.default - parameter_info = OptionInfo() - - pydantic_annotation: ParameterAnnotation - - if param.annotation is not param.empty: - main_type = param.annotation - origin = get_origin(main_type) - - if origin is not None: - if is_union(origin): - types = [] - for type_ in get_args(main_type): - if type_ is NoneType: - continue - types.append(type_) - assert len(types) == 1, "Typer Currently doesn't support Union types" - main_type = types[0] - origin = get_origin(main_type) - - if lenient_issubclass(origin, list): - element_type = get_args(main_type)[0] - assert not get_origin(element_type), ( - "List types with complex sub-types are not currently supported" - ) - pydantic_annotation = main_type - elif lenient_issubclass(origin, tuple): - type_args = get_args(main_type) - for type_ in type_args: - assert not get_origin(type_), ( - "Tuple types with complex sub-types are not currently supported" - ) - pydantic_annotation = main_type - else: - pydantic_annotation = main_type - else: - pydantic_annotation = main_type - else: - pydantic_annotation = infer_annotation_from_default(default) - - return DeclaredParam( - name=param.name, - parameter_info=parameter_info, - default=default, - required=required, - annotation=pydantic_annotation, - ) +def build_runtime_param( + annotation: ParameterAnnotation, + parameter_info: ParameterInfo, +) -> RuntimeParam: + """Build runtime coercion for a callback parameter annotation.""" + args = { + "annotation": annotation, + "parameter_info": parameter_info, + } + file_annotation = file_coercion_annotation(annotation) + if file_annotation is not None: + return FileRuntimeParam(**args, file_annotation=file_annotation) + if path_uses_coercion(annotation, parameter_info): + return PathRuntimeParam( + **args, + path_type=resolve_path_type(annotation, parameter_info), + ) + choice = choice_coercion_annotation(annotation, parameter_info) + if choice is not None: + choices, case_sensitive = choice + return ChoiceRuntimeParam( + **args, + choices=choices, + case_sensitive=case_sensitive, + ) + adapter = adapters.try_build_adapter(annotation, parameter_info) + if adapter is not None: + return AdapterRuntimeParam(**args, adapter=adapter) + return PassThroughRuntimeParam(**args) -def bool_flag_runtime_param(*, name: str, default: bool = False) -> RuntimeParam: +def bool_flag_runtime_param() -> RuntimeParam: """Build runtime coercion for a standalone boolean flag option.""" - declared = DeclaredParam( - name=name, - parameter_info=OptionInfo(), - default=default, - required=False, + return build_runtime_param( annotation=bool, + parameter_info=OptionInfo(), ) - return runtime_param_from_declared(declared) def prompt_value_proc( @@ -272,31 +208,3 @@ def coerce_pass_through(value: Any) -> Any: raise UsageError(f"Value {value!r} is not a valid {label}.") return coerce_pass_through - - -def runtime_param_from_declared(declared: DeclaredParam) -> RuntimeParam: - args = { - "name": declared.name, - "annotation": declared.annotation, - "parameter_info": declared.parameter_info, - } - file_annotation = file_coercion_annotation(declared.annotation) - if file_annotation is not None: - return FileRuntimeParam(**args, file_annotation=file_annotation) - if path_uses_coercion(declared.annotation, declared.parameter_info): - return PathRuntimeParam( - **args, - path_type=resolve_path_type(declared.annotation, declared.parameter_info), - ) - choice = choice_coercion_annotation(declared.annotation, declared.parameter_info) - if choice is not None: - choices, case_sensitive = choice - return ChoiceRuntimeParam( - **args, - choices=choices, - case_sensitive=case_sensitive, - ) - adapter = adapters.try_build_adapter(declared.annotation, declared.parameter_info) - if adapter is not None: - return AdapterRuntimeParam(**args, adapter=adapter) - return PassThroughRuntimeParam(**args) diff --git a/typer/core.py b/typer/core.py index 140ef798e5..444359bdda 100644 --- a/typer/core.py +++ b/typer/core.py @@ -29,7 +29,7 @@ TyperTuple, lenient_issubclass, ) -from .schema import RuntimeParam +from .coercion import RuntimeParam from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] diff --git a/typer/main.py b/typer/main.py index 406ef0d2a3..6f3cf64e28 100644 --- a/typer/main.py +++ b/typer/main.py @@ -34,11 +34,14 @@ DefaultPlaceholder, DeveloperExceptionConfig, OptionInfo, + ParameterInfo, ParamMeta, + Required, TyperInfo, ) from .param_types import TyperRanged, cli_param_type, lenient_issubclass -from .schema import declare_param, runtime_param_from_declared +from .coercion import build_runtime_param +from .param_types import parse_param_annotation from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1449,15 +1452,27 @@ def wrapper(**kwargs: Any) -> Any: def get_param(param: ParamMeta) -> TyperArgument | TyperOption: - declared = declare_param(param) - parameter_info = declared.parameter_info - default_value = declared.default - required = declared.required - annotation_args = get_args(declared.annotation) - is_list = lenient_issubclass(get_origin(declared.annotation), list) + default_value = None + required = False + if isinstance(param.default, ParameterInfo): + parameter_info = param.default + if parameter_info.default == Required: + required = True + else: + default_value = parameter_info.default + elif param.default == Required or param.default is param.empty: + required = True + parameter_info = ArgumentInfo() + else: + default_value = param.default + parameter_info = OptionInfo() + + annotation = parse_param_annotation(param, default_value) + annotation_args = get_args(annotation) + is_list = lenient_issubclass(get_origin(annotation), list) is_flag = None if isinstance(parameter_info, OptionInfo): - if declared.annotation is bool: + if annotation is bool: is_flag = True elif ( is_list @@ -1467,10 +1482,14 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: ): is_flag = True parameter_type = cli_param_type( - annotation=declared.annotation, + annotation=annotation, parameter_info=parameter_info, is_list=is_list, - is_tuple=lenient_issubclass(get_origin(declared.annotation), tuple), + is_tuple=lenient_issubclass(get_origin(annotation), tuple), + ) + runtime_param = build_runtime_param( + annotation=annotation, + parameter_info=parameter_info, ) if isinstance(parameter_info, OptionInfo): @@ -1488,7 +1507,6 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: param_decls.extend(parameter_info.param_decls) else: param_decls.append(default_option_declaration) - runtime_param = runtime_param_from_declared(declared) return TyperOption( # Option param_decls=param_decls, @@ -1527,7 +1545,6 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: nargs = None if is_list: nargs = -1 - runtime_param = runtime_param_from_declared(declared) return TyperArgument( # Argument param_decls=param_decls, diff --git a/typer/param_types.py b/typer/param_types.py index 63281c155b..28920831ac 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -24,7 +24,7 @@ from ._click.shell_completion import CompletionItem from ._click.types import ParamType from ._click.utils import LazyFile, format_filename, safecall -from ._typing import get_args, get_origin, is_literal_type, literal_values +from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values from .display import get_error_msg from .models import ( AnyType, @@ -32,7 +32,9 @@ FileBinaryWrite, FileText, FileTextWrite, + NoneType, ParameterInfo, + ParamMeta, ) if TYPE_CHECKING: @@ -70,6 +72,43 @@ def annotation_from_prompt(t: Any | None, default: Any | None) -> ParameterAnnot return infer_annotation_from_default(default) +def parse_param_annotation( + param: ParamMeta, default: Any | None +) -> ParameterAnnotation: + """Parse the annotation for a callback parameter.""" + if param.annotation is not param.empty: + main_type = param.annotation + origin = get_origin(main_type) + + if origin is not None: + if is_union(origin): + types = [] + for type_ in get_args(main_type): + if type_ is NoneType: + continue + types.append(type_) + assert len(types) == 1, "Typer currently doesn't support Union types" + main_type = types[0] + origin = get_origin(main_type) + + if lenient_issubclass(origin, list): + element_type = get_args(main_type)[0] + assert not get_origin(element_type), ( + "List types with complex sub-types are not currently supported" + ) + return main_type + if lenient_issubclass(origin, tuple): + type_args = get_args(main_type) + for type_ in type_args: + assert not get_origin(type_), ( + "Tuple types with complex sub-types are not currently supported" + ) + return main_type + return main_type + return main_type + return infer_annotation_from_default(default) + + def resolve_param_type( annotation: ParameterAnnotation, parameter_info: ParameterInfo | None = None, From 9d778d4809bb8cfd611a7af5bfe7cacbe6130e68 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:03:25 +0000 Subject: [PATCH 56/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/_click/termui.py | 2 +- typer/core.py | 2 +- typer/main.py | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 3fbe38af5e..9fabbf103d 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -108,8 +108,8 @@ def prompt_func(text: str) -> str: raise Abort() from None if value_proc is None: - from ..param_types import annotation_from_prompt, resolve_param_type from ..coercion import prompt_value_proc + from ..param_types import annotation_from_prompt, resolve_param_type annotation = annotation_from_prompt(type, default) value_proc = prompt_value_proc(type, default) diff --git a/typer/core.py b/typer/core.py index 444359bdda..8c3049053d 100644 --- a/typer/core.py +++ b/typer/core.py @@ -20,6 +20,7 @@ from ._click.shell_completion import CompletionItem from ._click.types import ParamType from ._typing import Literal +from .coercion import RuntimeParam from .display import describe_number_range from .param_types import ( TyperChoice, @@ -29,7 +30,6 @@ TyperTuple, lenient_issubclass, ) -from .coercion import RuntimeParam from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] diff --git a/typer/main.py b/typer/main.py index 6f3cf64e28..b19d8fa072 100644 --- a/typer/main.py +++ b/typer/main.py @@ -16,6 +16,7 @@ from . import _click from ._click.globals import get_current_context from ._typing import get_args, get_origin +from .coercion import build_runtime_param from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -39,9 +40,12 @@ Required, TyperInfo, ) -from .param_types import TyperRanged, cli_param_type, lenient_issubclass -from .coercion import build_runtime_param -from .param_types import parse_param_annotation +from .param_types import ( + TyperRanged, + cli_param_type, + lenient_issubclass, + parse_param_annotation, +) from .utils import get_params_from_function _original_except_hook = sys.excepthook From 66f57a8f662f9117c489582e4702d931e1d676e8 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Mon, 22 Jun 2026 16:52:55 +0200 Subject: [PATCH 57/73] TypeDescriptor to solidify type coercion --- tests/test_type_conversion.py | 3 +- typer/_click/core.py | 2 +- typer/_click/decorators.py | 3 +- typer/_click/exceptions.py | 7 +- typer/_click/termui.py | 24 ++-- typer/_click/types.py | 8 -- typer/adapters.py | 49 ++++++-- typer/coercion.py | 215 +++++++++++++++++++++++----------- typer/core.py | 113 ++++++++++++------ typer/main.py | 23 ++-- typer/param_types.py | 187 ++++++----------------------- 11 files changed, 325 insertions(+), 309 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 47cdaab773..0908d20917 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -8,7 +8,6 @@ import pytest import typer from typer import _click, param_types -from typer.param_types import TyperPath from typer.testing import CliRunner from tests.utils import needs_linux, needs_windows @@ -291,7 +290,7 @@ def warp(loc: str = typer.Option(..., resolve_path=True)): print(loc) param = next(p for p in typer.main.get_command(app).params if p.name == "loc") - assert isinstance(param.type, TyperPath) + assert param.type_descriptor.is_path @pytest.mark.parametrize( diff --git a/typer/_click/core.py b/typer/_click/core.py index 1b8dbe9008..b0e1d1f892 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -1048,4 +1048,4 @@ def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem" from ..core import TyperParameter param = cast(TyperParameter, self) - return self.type.shell_complete(ctx, param, incomplete) + return param.shell_complete(ctx, incomplete) diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py index b4c5d4e80a..6fb4944569 100644 --- a/typer/_click/decorators.py +++ b/typer/_click/decorators.py @@ -40,7 +40,7 @@ def decorator(f: Command) -> Command: def help_option(param_decls: list[str]) -> Callable[[Command], Command]: """Help option which prints the help page and exits the program.""" - from ..coercion import bool_flag_runtime_param + from ..coercion import bool_flag_runtime_param, bool_flag_type_descriptor from .types import ParamType def show_help(ctx: Context, param: Parameter, value: bool) -> None: @@ -61,4 +61,5 @@ def show_help(ctx: Context, param: Parameter, value: bool) -> None: required=False, type=ParamType(), runtime_param=bool_flag_runtime_param(), + type_descriptor=bool_flag_type_descriptor(), ) diff --git a/typer/_click/exceptions.py b/typer/_click/exceptions.py index acc5b9e58e..d6744f91da 100644 --- a/typer/_click/exceptions.py +++ b/typer/_click/exceptions.py @@ -143,9 +143,10 @@ def format_message(self) -> str: msg = self.message if self.param is not None: - msg_extra = self.param.type.get_missing_message( - param=self.param, ctx=self.ctx - ) + from ..core import TyperParameter + + assert isinstance(self.param, TyperParameter) + msg_extra = self.param.get_missing_message(ctx=self.ctx) if msg_extra: if msg: msg += f". {msg_extra}" diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 3fbe38af5e..e6c3655685 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -51,14 +51,18 @@ def _build_prompt( show_default: bool = False, default: Any | None = None, show_choices: bool = True, - type: ParamType | None = None, + annotation: Any | None = None, ) -> str: # prevent circular imports - from ..param_types import TyperChoice + from ..models import OptionInfo + from ..param_types import choice_as_str, choice_coercion_annotation prompt = text - if type is not None and show_choices and isinstance(type, TyperChoice): - prompt += f" ({', '.join(map(str, type.choices))})" + if show_choices and annotation is not None: + choice = choice_coercion_annotation(annotation, OptionInfo()) + if choice is not None: + choices, _ = choice + prompt += f" ({', '.join(map(choice_as_str, choices))})" if default is not None and show_default: prompt = f"{prompt} [{_format_default(default)}]" return f"{prompt}{suffix}" @@ -107,23 +111,25 @@ def prompt_func(text: str) -> str: echo(None, err=err) raise Abort() from None + from ..param_types import annotation_from_prompt + + annotation = annotation_from_prompt(type, default) if value_proc is None: - from ..param_types import annotation_from_prompt, resolve_param_type from ..coercion import prompt_value_proc - annotation = annotation_from_prompt(type, default) value_proc = prompt_value_proc(type, default) - type = resolve_param_type(annotation) prompt = _build_prompt( - text, prompt_suffix, show_default, default, show_choices, type + text, prompt_suffix, show_default, default, show_choices, annotation ) if confirmation_prompt: if confirmation_prompt is True: confirmation_prompt = "Repeat for confirmation" - confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + confirmation_prompt = _build_prompt( + confirmation_prompt, prompt_suffix, annotation=annotation + ) while True: while True: diff --git a/typer/_click/types.py b/typer/_click/types.py index f3c2c38074..0038a0ff51 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -31,14 +31,6 @@ def arity(self) -> int: # Windows). envvar_list_splitter: ClassVar[str | None] = None - def get_missing_message( - self, param: "TyperParameter", ctx: Union["Context", None] - ) -> str | None: - """Optionally might return extra information about a missing - parameter. - """ - pass # pragma: no cover - def split_envvar_value(self, rv: str) -> Sequence[str]: """Given a value from an environment variable this splits it up into small chunks depending on the defined envvar list splitter. diff --git a/typer/adapters.py b/typer/adapters.py index b4f5404149..d79fb448d2 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -2,9 +2,9 @@ from collections.abc import Callable, Sequence from datetime import datetime from enum import Enum -from typing import Annotated, Any, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, Any, get_args, get_origin -from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter +from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter, ValidationInfo from pydantic.errors import PydanticSchemaGenerationError from ._click import _compat @@ -18,6 +18,29 @@ resolve_path_type, ) +if TYPE_CHECKING: + from ._click import Context + from .core import TyperParameter + +_CTX_KEY = "ctx" +_PARAM_KEY = "param" + + +def validation_context( + ctx: "Context", + param: "TyperParameter", +) -> dict[str, Any]: + return {_CTX_KEY: ctx, _PARAM_KEY: param} + + +def validation_ctx_param( + info: ValidationInfo, +) -> tuple["Context | None", "TyperParameter | None"]: + context = info.context + if not context: + return None, None + return context.get(_CTX_KEY), context.get(_PARAM_KEY) + def try_build_adapter( annotation: Any, @@ -46,11 +69,12 @@ def build_adapter( list_type = args[0] adapter = build_adapter(list_type, parameter_info) - def parse_list(value: Any) -> list[Any]: + def parse_list(value: Any, info: ValidationInfo) -> list[Any]: if not isinstance(value, (list, tuple)): value = [value] + context = info.context return [ - None if item is None else adapter.validate_python(item) + None if item is None else adapter.validate_python(item, context=context) for item in value ] @@ -60,15 +84,16 @@ def parse_list(value: Any) -> list[Any]: types = get_args(annotation) adapters = [build_adapter(t, parameter_info) for t in types] - def parse_tuple(value: Any) -> tuple[Any, ...]: + def parse_tuple(value: Any, info: ValidationInfo) -> tuple[Any, ...]: if not isinstance(value, (list, tuple)): raise ValueError("value is not a valid tuple") if len(value) != len(adapters): raise ValueError( f"{len(adapters)} values are required, but {len(value)} given." ) + context = info.context return tuple( - None if item is None else adapter.validate_python(item) + None if item is None else adapter.validate_python(item, context=context) for adapter, item in zip(adapters, value, strict=False) ) @@ -215,12 +240,13 @@ def _build_choice_adapter( *, case_sensitive: bool, ) -> TypeAdapter[Any]: - def parse_choice(value: Any) -> Any: + def parse_choice(value: Any, info: ValidationInfo) -> Any: + ctx, _ = validation_ctx_param(info) return coerce_cli_choice( value, choices=choices, case_sensitive=case_sensitive, - ctx=None, + ctx=ctx, ) return TypeAdapter(Annotated[Any, BeforeValidator(parse_choice)]) @@ -233,13 +259,14 @@ def build_path_adapter( ) -> TypeAdapter[Any]: path_type = resolve_path_type(annotation, parameter_info) - def parse_path(value: Any) -> Any: + def parse_path(value: Any, info: ValidationInfo) -> Any: + ctx, param = validation_ctx_param(info) return coerce_cli_path( value, parameter_info, path_type=path_type, - param=None, - ctx=None, + param=param, + ctx=ctx, ) return TypeAdapter(Annotated[Any, BeforeValidator(parse_path)]) diff --git a/typer/coercion.py b/typer/coercion.py index a3af7f2a0b..5fa760d3c7 100644 --- a/typer/coercion.py +++ b/typer/coercion.py @@ -1,6 +1,8 @@ +import os from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import IO, TYPE_CHECKING, Any from pydantic import TypeAdapter, ValidationError @@ -8,25 +10,145 @@ from . import adapters from ._click import Context from ._click.exceptions import BadParameter, UsageError +from ._click.types import ParamType +from ._typing import get_args, get_origin, is_number_type +from .adapters import validation_context from .display import get_error_msg from .models import OptionInfo, ParameterInfo from .param_types import ( ParameterAnnotation, + _needs_typer_path, _open_cli_file, annotation_from_prompt, choice_coercion_annotation, - coerce_cli_choice, - coerce_cli_path, file_coercion_annotation, - path_uses_coercion, + is_file_annotation, + lenient_issubclass, + path_metavar_label, resolve_file_mode, - resolve_path_type, ) if TYPE_CHECKING: from .core import TyperParameter +@dataclass(frozen=True) +class TypeDescriptor: + """Resolved CLI type: metadata, coercion adapter, and deduced flags.""" + + annotation: ParameterAnnotation + parameter_info: ParameterInfo + param_type: ParamType + adapter: TypeAdapter[Any] | None + file_annotation: Any | None + + @property + def is_list(self) -> bool: + return lenient_issubclass(get_origin(self.annotation), list) + + @property + def is_tuple(self) -> bool: + return lenient_issubclass(get_origin(self.annotation), tuple) + + @property + def is_datetime(self) -> bool: + return self.annotation is datetime + + @property + def is_ranged(self) -> bool: + if self.is_list or self.is_tuple: + return False + return is_number_type(self.annotation) and ( + self.parameter_info.min is not None or self.parameter_info.max is not None + ) + + @property + def is_path(self) -> bool: + return _needs_typer_path(self.annotation, self.parameter_info) + + @property + def is_choice(self) -> bool: + return self.choices is not None + + @property + def is_file(self) -> bool: + return self.file_annotation is not None + + @property + def datetime_formats(self) -> tuple[str, ...]: + formats = self.parameter_info.formats + if formats is not None: + return tuple(formats) + return ("%Y-%m-%d",) + + @property + def path_label(self) -> str: + return path_metavar_label(self.parameter_info) + + @property + def choices(self) -> tuple[Any, ...] | None: + if self.is_list: + args = get_args(self.annotation) + if len(args) == 1: + choice = choice_coercion_annotation(args[0], self.parameter_info) + if choice is not None: + return choice[0] + choice = choice_coercion_annotation(self.annotation, self.parameter_info) + if choice is not None: + return choice[0] + return None + + @property + def case_sensitive(self) -> bool: + return self.parameter_info.case_sensitive + + @property + def ranged_type_name(self) -> str: + if isinstance(self.annotation, type): + return self.annotation.__name__ + return "number" + + @property + def tuple_arity(self) -> int | None: + if not self.is_tuple: + return None + return len(get_args(self.annotation)) + + @property + def envvar_list_splitter(self) -> str | None: + if self.is_file: + return os.path.pathsep + if self.is_path: + return os.path.pathsep + if self.is_list: + args = get_args(self.annotation) + if len(args) == 1 and ( + is_file_annotation(args[0]) + or _needs_typer_path(args[0], self.parameter_info) + ): + return os.path.pathsep + return None + + +def resolve_type_descriptor( + annotation: ParameterAnnotation, + parameter_info: ParameterInfo, +) -> TypeDescriptor: + """Resolve ParamType and Pydantic adapter for one parameter annotation.""" + param_type = ParamType() + file_annotation = file_coercion_annotation(annotation) + adapter = None + if file_annotation is None: + adapter = adapters.try_build_adapter(annotation, parameter_info) + return TypeDescriptor( + annotation=annotation, + parameter_info=parameter_info, + param_type=param_type, + adapter=adapter, + file_annotation=file_annotation, + ) + + @dataclass(frozen=True) class RuntimeParam(ABC): """Runtime coercion contract for one command parameter.""" @@ -57,7 +179,10 @@ class AdapterRuntimeParam(RuntimeParam): def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: try: - return self.adapter.validate_python(value) + return self.adapter.validate_python( + value, + context=validation_context(ctx, param), + ) except ValidationError as exc: raise BadParameter(get_error_msg(exc), ctx=ctx, param=param) from exc except ValueError as exc: @@ -87,22 +212,6 @@ def open_one(item: Any) -> IO[Any]: return open_one(value) -@dataclass(frozen=True) -class PathRuntimeParam(RuntimeParam): - """Coercion for path parameters.""" - - path_type: type[Any] | None - - def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: - return coerce_cli_path( - value, - self.parameter_info, - path_type=self.path_type, - param=param, - ctx=ctx, - ) - - @dataclass(frozen=True) class PassThroughRuntimeParam(RuntimeParam): """Coercion for annotations that cannot use a Pydantic TypeAdapter.""" @@ -120,64 +229,32 @@ def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> An ) -@dataclass(frozen=True) -class ChoiceRuntimeParam(RuntimeParam): - """Coercion for enum and literal choice parameters.""" - - choices: tuple[Any, ...] - case_sensitive: bool - - def _coerce_value(self, value: Any, param: "TyperParameter", ctx: Context) -> Any: - try: - return coerce_cli_choice( - value, - choices=self.choices, - case_sensitive=self.case_sensitive, - ctx=ctx, - ) - except ValueError as exc: - raise BadParameter(str(exc), ctx=ctx, param=param) from exc - - -def build_runtime_param( - annotation: ParameterAnnotation, - parameter_info: ParameterInfo, -) -> RuntimeParam: - """Build runtime coercion for a callback parameter annotation.""" +def build_runtime_param(descriptor: TypeDescriptor) -> RuntimeParam: + """Build runtime coercion from a resolved type descriptor.""" args = { - "annotation": annotation, - "parameter_info": parameter_info, + "annotation": descriptor.annotation, + "parameter_info": descriptor.parameter_info, } - file_annotation = file_coercion_annotation(annotation) - if file_annotation is not None: - return FileRuntimeParam(**args, file_annotation=file_annotation) - if path_uses_coercion(annotation, parameter_info): - return PathRuntimeParam( - **args, - path_type=resolve_path_type(annotation, parameter_info), - ) - choice = choice_coercion_annotation(annotation, parameter_info) - if choice is not None: - choices, case_sensitive = choice - return ChoiceRuntimeParam( - **args, - choices=choices, - case_sensitive=case_sensitive, - ) - adapter = adapters.try_build_adapter(annotation, parameter_info) - if adapter is not None: - return AdapterRuntimeParam(**args, adapter=adapter) + if descriptor.file_annotation is not None: + return FileRuntimeParam(**args, file_annotation=descriptor.file_annotation) + if descriptor.adapter is not None: + return AdapterRuntimeParam(**args, adapter=descriptor.adapter) return PassThroughRuntimeParam(**args) -def bool_flag_runtime_param() -> RuntimeParam: - """Build runtime coercion for a standalone boolean flag option.""" - return build_runtime_param( +def bool_flag_type_descriptor() -> TypeDescriptor: + """Resolved type for a standalone boolean flag option.""" + return resolve_type_descriptor( annotation=bool, parameter_info=OptionInfo(), ) +def bool_flag_runtime_param() -> RuntimeParam: + """Build runtime coercion for a standalone boolean flag option.""" + return build_runtime_param(bool_flag_type_descriptor()) + + def prompt_value_proc( param_type: Any | None = None, default: Any | None = None, diff --git a/typer/core.py b/typer/core.py index 444359bdda..385f85e4b9 100644 --- a/typer/core.py +++ b/typer/core.py @@ -20,16 +20,9 @@ from ._click.shell_completion import CompletionItem from ._click.types import ParamType from ._typing import Literal +from .coercion import RuntimeParam, TypeDescriptor from .display import describe_number_range -from .param_types import ( - TyperChoice, - TyperDatetime, - TyperPath, - TyperRanged, - TyperTuple, - lenient_issubclass, -) -from .coercion import RuntimeParam +from .param_types import choice_missing_message, choice_shell_complete from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] @@ -97,6 +90,7 @@ class TyperParameter(_click.core.Parameter): """Typer parameter with runtime coercion.""" runtime_param: RuntimeParam + type_descriptor: TypeDescriptor def process_value(self, ctx: _click.Context, value: Any) -> Any: value = self.runtime_param.coerce(value, param=self, ctx=ctx) @@ -113,26 +107,67 @@ def value_is_missing(self, value: Any) -> bool: return True return False + def get_missing_message(self, ctx: _click.Context | None) -> str | None: + desc = self.type_descriptor + if desc.is_choice and desc.choices is not None: + return choice_missing_message( + desc.choices, + case_sensitive=desc.case_sensitive, + ctx=ctx, + ) + return "" + + def value_from_envvar(self, ctx: _click.Context) -> str | Sequence[str] | None: + rv: Any | None = self.resolve_envvar_value(ctx) + if rv is not None and (self.nargs != 1 or self.multiple): + splitter = self.type_descriptor.envvar_list_splitter + if splitter is not None: + rv = (rv or "").split(splitter) + else: + rv = self.type.split_envvar_value(rv) + return rv + + def shell_complete( + self, ctx: _click.Context, incomplete: str + ) -> list[CompletionItem]: + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + if results and isinstance(results[0], str): + results = [CompletionItem(c) for c in results] + return cast(list[CompletionItem], results) + + desc = self.type_descriptor + if desc.is_choice and desc.choices is not None: + return choice_shell_complete( + desc.choices, + case_sensitive=desc.case_sensitive, + incomplete=incomplete, + ) + if desc.is_file: + return [CompletionItem(incomplete, type="file")] + if desc.is_path: + return [] + return self.type.shell_complete(ctx, self, incomplete) + def make_metavar(self, ctx: _click.Context) -> str | None: return self.metavar def metavar_label(self) -> str: - annotation = self.runtime_param.annotation - param_type = self.type - if lenient_issubclass(get_origin(annotation), list): + desc = self.type_descriptor + if desc.is_list: label = self.metavar_type() - elif isinstance(param_type, TyperDatetime): - label = "|".join(param_type.formats) - elif isinstance(param_type, TyperRanged): - label = f"{param_type.annotation.__name__} range" - elif isinstance(param_type, TyperTuple): + elif desc.is_tuple: labels = [ - self._metavar_type_by_annotation(a) - for a in param_type.element_annotations + self._metavar_type_by_annotation(arg) + for arg in get_args(desc.annotation) ] label = ",".join(labels) - elif isinstance(param_type, TyperPath): - label = param_types.path_metavar_label(self.runtime_param.parameter_info) + elif desc.is_datetime: + label = "|".join(desc.datetime_formats) + elif desc.is_ranged: + label = f"{desc.ranged_type_name} range" + elif desc.is_path: + label = desc.path_label else: label = self.metavar_type() return f"<{label}>" @@ -346,6 +381,7 @@ def __init__( param_decls: list[str], type: ParamType, runtime_param: RuntimeParam, + type_descriptor: TypeDescriptor, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -383,6 +419,7 @@ def __init__( self.max = max self.rich_help_panel = rich_help_panel self.runtime_param = runtime_param + self.type_descriptor = type_descriptor super().__init__( param_decls=param_decls, @@ -527,19 +564,19 @@ def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) def resolve_value_metavar(self, ctx: _click.Context) -> str | None: - param_type = self.type - if isinstance(param_type, TyperChoice): + desc = self.type_descriptor + if desc.is_choice: normalized_mapping = { - c: param_types.normalize_choice_value(c, param_type.case_sensitive, ctx) - for c in param_type.choices + c: param_types.normalize_choice_value(c, desc.case_sensitive, ctx) + for c in desc.choices or () } choices_str = "|".join(normalized_mapping.values()) if self.required: return f"{{{choices_str}}}" return f"[{choices_str}]" - if not isinstance(param_type, TyperDatetime): - return None - return self.metavar_label() + if desc.is_datetime: + return self.metavar_label() + return None def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: metavar_str = self.make_metavar(ctx) @@ -565,6 +602,7 @@ def __init__( param_decls: list[str], type: ParamType, runtime_param: RuntimeParam, + type_descriptor: TypeDescriptor, required: bool = False, default: Any | None = None, callback: Callable[..., Any] | None = None, @@ -607,6 +645,7 @@ def __init__( self.min = min self.max = max self.runtime_param = runtime_param + self.type_descriptor = type_descriptor super().__init__( param_decls, @@ -786,7 +825,7 @@ def prompt_for_value(self, ctx: _click.Context) -> Any: # Use ``None`` to inform the prompt() function to reiterate until a valid # value is provided by the user if we have no default. default=default, - type=self.type, + type=self.type_descriptor.annotation, hide_input=self.hide_input, show_choices=self.show_choices, confirmation_prompt=self.confirmation_prompt, @@ -960,27 +999,23 @@ def _write_opts(opts: Sequence[str]) -> str: return ("; " if any_prefix_is_slash else " / ").join(rv), help def resolve_value_metavar(self, ctx: _click.Context) -> str | None: - param_type = self.type - if isinstance(param_type, TyperChoice): + desc = self.type_descriptor + if desc.is_choice: if not self.show_choices: metavars = [ self._metavar_type_by_annotation(type(c)) - for c in param_type.choices + for c in desc.choices or () ] choices_str = "|".join([*dict.fromkeys(metavars)]) else: normalized_mapping = { - c: param_types.normalize_choice_value( - c, param_type.case_sensitive, ctx - ) - for c in param_type.choices + c: param_types.normalize_choice_value(c, desc.case_sensitive, ctx) + for c in desc.choices or () } choices_str = "|".join(normalized_mapping.values()) return f"[{choices_str}]" - if self.param_type_name == "argument" and not isinstance( - param_type, TyperDatetime - ): + if self.param_type_name == "argument" and not desc.is_datetime: return None return self.metavar_label() diff --git a/typer/main.py b/typer/main.py index 6f3cf64e28..6d8b8752d6 100644 --- a/typer/main.py +++ b/typer/main.py @@ -16,6 +16,7 @@ from . import _click from ._click.globals import get_current_context from ._typing import get_args, get_origin +from .coercion import build_runtime_param, resolve_type_descriptor from .completion import get_completion_inspect_parameters from .core import ( DEFAULT_MARKUP_MODE, @@ -39,9 +40,7 @@ Required, TyperInfo, ) -from .param_types import TyperRanged, cli_param_type, lenient_issubclass -from .coercion import build_runtime_param -from .param_types import parse_param_annotation +from .param_types import lenient_issubclass, parse_param_annotation from .utils import get_params_from_function _original_except_hook = sys.excepthook @@ -1481,20 +1480,15 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: and any("/" in decl for decl in parameter_info.param_decls) ): is_flag = True - parameter_type = cli_param_type( - annotation=annotation, - parameter_info=parameter_info, - is_list=is_list, - is_tuple=lenient_issubclass(get_origin(annotation), tuple), - ) - runtime_param = build_runtime_param( + descriptor = resolve_type_descriptor( annotation=annotation, parameter_info=parameter_info, ) + parameter_type = descriptor.param_type + runtime_param = build_runtime_param(descriptor) + tuple_nargs = descriptor.tuple_arity if isinstance(parameter_info, OptionInfo): - if parameter_info.count: - parameter_type = TyperRanged(int) default_option_name = get_command_name(param.name) if is_flag: default_option_declaration = ( @@ -1536,15 +1530,19 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: autocompletion=get_param_completion(parameter_info.autocompletion), min=parameter_info.min, max=parameter_info.max, + nargs=tuple_nargs, # Rich settings rich_help_panel=parameter_info.rich_help_panel, runtime_param=runtime_param, + type_descriptor=descriptor, ) elif isinstance(parameter_info, ArgumentInfo): param_decls = [param.name] nargs = None if is_list: nargs = -1 + elif tuple_nargs is not None: + nargs = tuple_nargs return TyperArgument( # Argument param_decls=param_decls, @@ -1571,6 +1569,7 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: # Rich settings rich_help_panel=parameter_info.rich_help_panel, runtime_param=runtime_param, + type_descriptor=descriptor, ) raise AssertionError("A Parameter should be returned") # pragma: no cover diff --git a/typer/param_types.py b/typer/param_types.py index 28920831ac..2b599c385f 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -1,18 +1,14 @@ import os import stat -from collections.abc import Iterable, Mapping, Sequence -from datetime import datetime +from collections.abc import Sequence from enum import Enum from pathlib import Path from typing import ( IO, TYPE_CHECKING, Any, - ClassVar, - Generic, TypeAlias, TypeGuard, - TypeVar, cast, ) @@ -40,8 +36,6 @@ if TYPE_CHECKING: from .core import TyperParameter -ParamTypeValue = TypeVar("ParamTypeValue") - ParameterAnnotation: TypeAlias = Any @@ -109,76 +103,6 @@ def parse_param_annotation( return infer_annotation_from_default(default) -def resolve_param_type( - annotation: ParameterAnnotation, - parameter_info: ParameterInfo | None = None, -) -> ParamType: - """Resolve a ParamType for this particular annotation.""" - if isinstance(annotation, ParamType): - return annotation - - if isinstance(annotation, tuple): - return TyperTuple(annotation) - - if parameter_info is not None: - if annotation is int or annotation is float: - if parameter_info.min is not None or parameter_info.max is not None: - return TyperRanged(annotation) - if annotation is datetime: - f = parameter_info.formats - formats_tuple = tuple(f) if f is not None else ("%Y-%m-%d",) - return TyperDatetime(formats=formats_tuple) - if _needs_typer_path(annotation, parameter_info): - return TyperPath() - if lenient_issubclass(annotation, Enum): - return TyperChoice(list(annotation), parameter_info.case_sensitive) - if is_literal_type(annotation): - return TyperChoice( - literal_values(annotation), parameter_info.case_sensitive - ) - if lenient_issubclass(annotation, CLI_FILE_TYPES): - return TyperFile() - - return ParamType() - - -def cli_param_type( - *, - annotation: ParameterAnnotation, - parameter_info: ParameterInfo, - is_list: bool, - is_tuple: bool, -) -> ParamType: - """Defer the param type""" - if is_tuple: - type_args = get_args(annotation) - return resolve_param_type(tuple(type_args), parameter_info) - if is_list: - (element_type,) = get_args(annotation) - return resolve_param_type(element_type, parameter_info) - return resolve_param_type(annotation, parameter_info) - - -# DATETIME # -class TyperDatetime(ParamType): - def __init__(self, *, formats: tuple[str, ...]) -> None: - self.formats = formats - - -# TUPLE # -class TyperTuple(ParamType): - """Metavar and nargs information for tuple parameters.""" - - is_composite = True - - def __init__(self, element_annotations: Sequence[Any]) -> None: - self.element_annotations: tuple[Any, ...] = tuple(element_annotations) - - @property - def arity(self) -> int: - return len(self.element_annotations) - - # ENUM # def normalize_choice_value( choice: Any, @@ -198,7 +122,7 @@ def coerce_cli_choice( *, choices: Sequence[Any], case_sensitive: bool, - ctx: Context | None, + ctx: Context | None = None, ) -> Any: if any(isinstance(choice, Enum) and value is choice for choice in choices): return value @@ -224,56 +148,38 @@ def choice_coercion_annotation( return None -class TyperChoice(ParamType, Generic[ParamTypeValue]): - name = "choice" - - def __init__( - self, choices: Iterable[ParamTypeValue], case_sensitive: bool = True - ) -> None: - self.choices: Sequence[ParamTypeValue] = tuple(choices) - self.case_sensitive = case_sensitive - - def _normalized_mapping( - self, ctx: Context | None = None - ) -> Mapping[ParamTypeValue, str]: - """ - Returns mapping where keys are the original choices and the values are - the normalized values that are accepted via the command line. - """ - return { - choice: normalize_choice_value(choice, self.case_sensitive, ctx) - for choice in self.choices - } - - def get_missing_message(self, param: "TyperParameter", ctx: Context | None) -> str: - """Message shown when no choice is passed.""" - choices = ",\n\t".join(self._normalized_mapping(ctx=ctx).values()) - return f"Choose from:\n\t{choices}" - - def get_invalid_choice_message(self, value: Any, ctx: Context | None) -> str: - """Get the error message when the given choice is invalid.""" - choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) - return f"{value!r} is not one of {choices_str}." - - def _choice_as_str(self, choice: ParamTypeValue) -> str: - if isinstance(choice, Enum): - return str(choice.value) - return str(choice) - - def shell_complete( - self, ctx: Context, param: "TyperParameter", incomplete: str - ) -> list[CompletionItem]: - """Complete choices that start with the incomplete value.""" - - str_choices = map(self._choice_as_str, self.choices) - - if self.case_sensitive: - matched = (c for c in str_choices if c.startswith(incomplete)) - else: - incomplete = incomplete.lower() - matched = (c for c in str_choices if c.lower().startswith(incomplete)) +def choice_as_str(choice: Any) -> str: + if isinstance(choice, Enum): + return str(choice.value) + return str(choice) - return [CompletionItem(c) for c in matched] + +def choice_shell_complete( + choices: Sequence[Any], + *, + case_sensitive: bool, + incomplete: str, +) -> list[CompletionItem]: + str_choices = map(choice_as_str, choices) + if case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + return [CompletionItem(c) for c in matched] + + +def choice_missing_message( + choices: Sequence[Any], + *, + case_sensitive: bool, + ctx: Context | None, +) -> str: + normalized = [ + normalize_choice_value(choice, case_sensitive, ctx) for choice in choices + ] + choices_str = ",\n\t".join(normalized) + return f"Choose from:\n\t{choices_str}" # PATH # @@ -285,18 +191,6 @@ def path_metavar_label(parameter_info: ParameterInfo) -> str: return "path" -class TyperPath(ParamType): - envvar_list_splitter: ClassVar[str] = os.path.pathsep - - def shell_complete( - self, ctx: Context, param: "TyperParameter", incomplete: str - ) -> list[CompletionItem]: - """Return an empty list so that the autocompletion functionality - will work properly from the commandline. - """ - return [] - - def resolve_path_type( annotation: Any, parameter_info: ParameterInfo, @@ -398,15 +292,6 @@ def is_file_annotation(annotation: Any) -> bool: return lenient_issubclass(annotation, CLI_FILE_TYPES) -class TyperFile(ParamType): - envvar_list_splitter = os.path.pathsep - - def shell_complete( - self, ctx: Context, param: "TyperParameter", incomplete: str - ) -> list[CompletionItem]: - return [CompletionItem(incomplete, type="file")] - - def file_coercion_annotation(annotation: Any) -> Any | None: """Return the file marker type when this parameter opens files.""" origin = get_origin(annotation) @@ -508,9 +393,3 @@ def _open_cli_file( except OSError as exc: # pragma: no cover message = f"'{format_filename(value)}': {exc.strerror}" raise BadParameter(message, ctx=ctx, param=param) from exc - - -# RANGE # -class TyperRanged(ParamType): - def __init__(self, annotation: type[Any]) -> None: - self.annotation = annotation From 65dd995d5eb49b7f9993a69345278a2c9c73f246 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:55:52 +0000 Subject: [PATCH 58/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/_click/termui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/_click/termui.py b/typer/_click/termui.py index 3bcf97d69e..fd47f2c28f 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -116,7 +116,7 @@ def prompt_func(text: str) -> str: annotation = annotation_from_prompt(type, default) if value_proc is None: from ..coercion import prompt_value_proc - from ..param_types import annotation_from_prompt, resolve_param_type + from ..param_types import annotation_from_prompt value_proc = prompt_value_proc(type, default) From 68c3a7f400c23688d025f73609d169d3c6b09c38 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 23 Jun 2026 11:29:56 +0200 Subject: [PATCH 59/73] remove unnecessary helper functions --- typer/adapters.py | 74 ++++++++++++++++++++------------------------ typer/core.py | 31 ++++++++++++------- typer/param_types.py | 61 ++++++------------------------------ 3 files changed, 62 insertions(+), 104 deletions(-) diff --git a/typer/adapters.py b/typer/adapters.py index d79fb448d2..8e4b4b87f1 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -1,7 +1,8 @@ import sys -from collections.abc import Callable, Sequence +from collections.abc import Sequence from datetime import datetime from enum import Enum +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, get_args, get_origin from pydantic import AfterValidator, BeforeValidator, Field, TypeAdapter, ValidationInfo @@ -15,7 +16,6 @@ coerce_cli_choice, coerce_cli_path, lenient_issubclass, - resolve_path_type, ) if TYPE_CHECKING: @@ -136,25 +136,12 @@ def parse(value: Any) -> Any: return _build_datetime_adapter(parameter_info.formats) if is_number_type(annotation): - clamp = parameter_info.clamp - min = parameter_info.min - max = parameter_info.max - if clamp: - # Use AfterValidator so it runs after coercion - return TypeAdapter( - Annotated[ - annotation, - AfterValidator(_make_number_clamp_validator(annotation, min, max)), - ] - ) - else: - field_kwargs: dict[str, Any] = {} - if min is not None: - field_kwargs["ge"] = min - if max is not None: - field_kwargs["le"] = max - if field_kwargs: - return TypeAdapter(Annotated[annotation, Field(**field_kwargs)]) + return _build_number_adapter( + annotation, + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + ) if annotation is bool: return TypeAdapter(Annotated[bool, BeforeValidator(_parse_cli_bool)]) @@ -187,11 +174,6 @@ def parse_datetime(value: Any) -> datetime: # STRING / BYTES # def _parse_cli_str(value: Any) -> str: """Coerce a CLI value to str""" - return str(_decode_cli_bytes(value)) - - -def _decode_cli_bytes(value: Any) -> Any: - """Decode bytes from argv/env; leave other values unchanged.""" if isinstance(value, bytes): enc = _compat._get_argv_encoding() try: @@ -204,7 +186,7 @@ def _decode_cli_bytes(value: Any) -> Any: except UnicodeError: return value.decode("utf-8", "replace") return value.decode("utf-8", "replace") - return value + return str(value) # BOOL # @@ -219,19 +201,29 @@ def _parse_cli_bool(value: Any) -> Any: # NUMBER # -def _make_number_clamp_validator( - number_class: type[Any], - min: float | None, - max: float | None, -) -> Callable[[Any], Any]: - def clamp_number(value: Any) -> Any: - if min is not None and value < min: - return number_class(min) - if max is not None and value > max: - return number_class(max) - return value +def _build_number_adapter( + number_class: type[Any], *, min: float | None, max: float | None, clamp: bool | None +): + if clamp: + + def clamp_number(value: Any) -> Any: + if min is not None and value < min: + return number_class(min) + if max is not None and value > max: + return number_class(max) + return value - return clamp_number + # Use AfterValidator so it runs after coercion + return TypeAdapter(Annotated[number_class, AfterValidator(clamp_number)]) + else: + field_kwargs: dict[str, Any] = {} + if min is not None: + field_kwargs["ge"] = min + if max is not None: + field_kwargs["le"] = max + if field_kwargs: + return TypeAdapter(Annotated[number_class, Field(**field_kwargs)]) + return TypeAdapter(number_class) # CHOICE # @@ -257,7 +249,9 @@ def build_path_adapter( annotation: Any, parameter_info: ParameterInfo, ) -> TypeAdapter[Any]: - path_type = resolve_path_type(annotation, parameter_info) + path_type = parameter_info.path_type + if path_type is None and lenient_issubclass(annotation, Path): + path_type = annotation def parse_path(value: Any, info: ValidationInfo) -> Any: ctx, param = validation_ctx_param(info) diff --git a/typer/core.py b/typer/core.py index 385f85e4b9..785f87280d 100644 --- a/typer/core.py +++ b/typer/core.py @@ -22,7 +22,7 @@ from ._typing import Literal from .coercion import RuntimeParam, TypeDescriptor from .display import describe_number_range -from .param_types import choice_missing_message, choice_shell_complete +from .param_types import choice_as_str, normalize_choice_value from .utils import parse_boolean_env_var MarkupMode = Literal["markdown", "rich", None] @@ -110,11 +110,12 @@ def value_is_missing(self, value: Any) -> bool: def get_missing_message(self, ctx: _click.Context | None) -> str | None: desc = self.type_descriptor if desc.is_choice and desc.choices is not None: - return choice_missing_message( - desc.choices, - case_sensitive=desc.case_sensitive, - ctx=ctx, - ) + normalized = [ + normalize_choice_value(choice, desc.case_sensitive, ctx) + for choice in desc.choices + ] + choices_str = ",\n\t".join(normalized) + return f"Choose from:\n\t{choices_str}" return "" def value_from_envvar(self, ctx: _click.Context) -> str | Sequence[str] | None: @@ -130,23 +131,29 @@ def value_from_envvar(self, ctx: _click.Context) -> str | Sequence[str] | None: def shell_complete( self, ctx: _click.Context, incomplete: str ) -> list[CompletionItem]: + # custom if self._custom_shell_complete is not None: results = self._custom_shell_complete(ctx, self, incomplete) if results and isinstance(results[0], str): results = [CompletionItem(c) for c in results] return cast(list[CompletionItem], results) - + # choice desc = self.type_descriptor if desc.is_choice and desc.choices is not None: - return choice_shell_complete( - desc.choices, - case_sensitive=desc.case_sensitive, - incomplete=incomplete, - ) + str_choices = map(choice_as_str, desc.choices) + if desc.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + return [CompletionItem(c) for c in matched] + # file if desc.is_file: return [CompletionItem(incomplete, type="file")] + # path if desc.is_path: return [] + # fall-back return self.type.shell_complete(ctx, self, incomplete) def make_metavar(self, ctx: _click.Context) -> str | None: diff --git a/typer/param_types.py b/typer/param_types.py index 2b599c385f..23d861e08f 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -169,19 +169,6 @@ def choice_shell_complete( return [CompletionItem(c) for c in matched] -def choice_missing_message( - choices: Sequence[Any], - *, - case_sensitive: bool, - ctx: Context | None, -) -> str: - normalized = [ - normalize_choice_value(choice, case_sensitive, ctx) for choice in choices - ] - choices_str = ",\n\t".join(normalized) - return f"Choose from:\n\t{choices_str}" - - # PATH # def path_metavar_label(parameter_info: ParameterInfo) -> str: if parameter_info.file_okay and not parameter_info.dir_okay: @@ -191,20 +178,6 @@ def path_metavar_label(parameter_info: ParameterInfo) -> str: return "path" -def resolve_path_type( - annotation: Any, - parameter_info: ParameterInfo, -) -> type[Any] | None: - path_type = parameter_info.path_type - if path_type is None and lenient_issubclass(annotation, Path): - path_type = annotation - return path_type - - -def path_uses_coercion(annotation: Any, parameter_info: ParameterInfo) -> bool: - return _needs_typer_path(annotation, parameter_info) - - def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: return ( annotation == Path @@ -322,25 +295,6 @@ def resolve_file_mode(parameter_info: ParameterInfo, annotation: Any) -> str: return "r" -def _is_file_like(value: Any) -> TypeGuard[IO[Any]]: - return hasattr(value, "read") or hasattr(value, "write") - - -def _resolve_file_lazy_flag( - value: str | os.PathLike[str], - *, - mode: str, - lazy: bool | None, -) -> bool: - if lazy is not None: - return lazy - if os.fspath(value) == "-": - return False - if "w" in mode: - return True - return False - - def _open_cli_file( value: str | os.PathLike[str] | IO[Any], parameter_info: ParameterInfo, @@ -349,17 +303,20 @@ def _open_cli_file( param: "TyperParameter | None" = None, ctx: Context | None = None, ) -> IO[Any]: - if _is_file_like(value): + if hasattr(value, "read") or hasattr(value, "write"): return value value = cast("str | os.PathLike[str]", value) try: - lazy = _resolve_file_lazy_flag( - value, - mode=mode, - lazy=parameter_info.lazy, - ) + lazy = parameter_info.lazy + if lazy is None: + if os.fspath(value) == "-": + lazy = False + elif "w" in mode: + lazy = True + else: + lazy = False if lazy: lf = LazyFile( From 9c08b7c6a6ba6c06a60440e6fffd2a257500dd71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:30:42 +0000 Subject: [PATCH 60/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/param_types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/typer/param_types.py b/typer/param_types.py index 23d861e08f..36b3bb1256 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -8,7 +8,6 @@ TYPE_CHECKING, Any, TypeAlias, - TypeGuard, cast, ) From 0a0365a91fa28fa974a2f5585da9f3dd14162ae4 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 23 Jun 2026 11:36:08 +0200 Subject: [PATCH 61/73] fix types --- typer/adapters.py | 2 +- typer/param_types.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/typer/adapters.py b/typer/adapters.py index 8e4b4b87f1..87e28de091 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -203,7 +203,7 @@ def _parse_cli_bool(value: Any) -> Any: # NUMBER # def _build_number_adapter( number_class: type[Any], *, min: float | None, max: float | None, clamp: bool | None -): +) -> TypeAdapter[Any]: if clamp: def clamp_number(value: Any) -> Any: diff --git a/typer/param_types.py b/typer/param_types.py index 23d861e08f..d5b77ba3fe 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -8,7 +8,6 @@ TYPE_CHECKING, Any, TypeAlias, - TypeGuard, cast, ) @@ -304,10 +303,9 @@ def _open_cli_file( ctx: Context | None = None, ) -> IO[Any]: if hasattr(value, "read") or hasattr(value, "write"): + assert isinstance(value, IO) return value - value = cast("str | os.PathLike[str]", value) - try: lazy = parameter_info.lazy if lazy is None: From 1f058cca1c295b9dbf85b006a879993ad7e880ac Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 23 Jun 2026 12:08:22 +0200 Subject: [PATCH 62/73] fix types (again) --- typer/adapters.py | 4 ++-- typer/param_types.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/typer/adapters.py b/typer/adapters.py index 87e28de091..d9a89120c6 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -214,7 +214,7 @@ def clamp_number(value: Any) -> Any: return value # Use AfterValidator so it runs after coercion - return TypeAdapter(Annotated[number_class, AfterValidator(clamp_number)]) + return TypeAdapter(Annotated[number_class, AfterValidator(clamp_number)]) # ty: ignore[invalid-type-form] else: field_kwargs: dict[str, Any] = {} if min is not None: @@ -222,7 +222,7 @@ def clamp_number(value: Any) -> Any: if max is not None: field_kwargs["le"] = max if field_kwargs: - return TypeAdapter(Annotated[number_class, Field(**field_kwargs)]) + return TypeAdapter(Annotated[number_class, Field(**field_kwargs)]) # ty: ignore[invalid-type-form] return TypeAdapter(number_class) diff --git a/typer/param_types.py b/typer/param_types.py index d5b77ba3fe..3ed9fd75de 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -302,8 +302,7 @@ def _open_cli_file( param: "TyperParameter | None" = None, ctx: Context | None = None, ) -> IO[Any]: - if hasattr(value, "read") or hasattr(value, "write"): - assert isinstance(value, IO) + if not isinstance(value, (str, os.PathLike)): return value try: From 07c724ae785ab2ea24f27bd57ce05b0072e5eb98 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 23 Jun 2026 18:03:00 +0200 Subject: [PATCH 63/73] keep fixing types --- typer/param_types.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/typer/param_types.py b/typer/param_types.py index 3ed9fd75de..bea089b50a 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -302,13 +302,18 @@ def _open_cli_file( param: "TyperParameter | None" = None, ctx: Context | None = None, ) -> IO[Any]: - if not isinstance(value, (str, os.PathLike)): - return value + if hasattr(value, "read") or hasattr(value, "write"): + return cast("IO[Any]", value) + + if isinstance(value, str): + path: str | os.PathLike[str] = value + else: + path = value try: lazy = parameter_info.lazy if lazy is None: - if os.fspath(value) == "-": + if os.fspath(path) == "-": lazy = False elif "w" in mode: lazy = True @@ -317,7 +322,7 @@ def _open_cli_file( if lazy: lf = LazyFile( - value, + path, mode, parameter_info.encoding, parameter_info.errors, @@ -330,7 +335,7 @@ def _open_cli_file( return cast("IO[Any]", lf) f, should_close = open_stream( - value, + path, mode, parameter_info.encoding, parameter_info.errors, @@ -345,5 +350,5 @@ def _open_cli_file( return f except OSError as exc: # pragma: no cover - message = f"'{format_filename(value)}': {exc.strerror}" + message = f"'{format_filename(path)}': {exc.strerror}" raise BadParameter(message, ctx=ctx, param=param) from exc From 291e4e9ece6ef8d311038e53f8ab92769f82828b Mon Sep 17 00:00:00 2001 From: svlandeg Date: Tue, 23 Jun 2026 19:11:00 +0200 Subject: [PATCH 64/73] remove _click/types and ParamType entirely --- typer/_click/core.py | 24 +++++++++------ typer/_click/decorators.py | 2 -- typer/_click/termui.py | 3 +- typer/_click/types.py | 61 -------------------------------------- typer/coercion.py | 6 +--- typer/core.py | 14 ++------- typer/main.py | 3 -- typer/param_types.py | 19 +----------- 8 files changed, 21 insertions(+), 111 deletions(-) delete mode 100644 typer/_click/types.py diff --git a/typer/_click/core.py b/typer/_click/core.py index b0e1d1f892..335bba4651 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -8,6 +8,7 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, Literal, NoReturn, TypeVar, @@ -27,7 +28,6 @@ from .globals import pop_context, push_context from .parser import _OptionParser from .termui import style -from .types import ParamType from .utils import echo, make_default_short_help if TYPE_CHECKING: @@ -818,7 +818,6 @@ class Parameter(ABC): def __init__( self, param_decls: Sequence[str] | None = None, - type: ParamType | None = None, required: bool = False, default: Any | Callable[[], Any] | None = None, callback: Callable[[Context, "Parameter", Any], Any] | None = None, @@ -839,15 +838,11 @@ def __init__( self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) - self.type = type if type is not None else ParamType() # Default nargs to what the type tells us if we have that # information available. if nargs is None: - if self.type.is_composite: - nargs = self.type.arity - else: - nargs = 1 + nargs = 1 self.required = required self.callback = callback @@ -969,6 +964,18 @@ def resolve_envvar_value(self, ctx: Context) -> str | None: return None + envvar_list_splitter: ClassVar[str | None] = None + + def split_envvar_value(self, rv: str) -> Sequence[str]: + """Given a value from an environment variable this splits it up + into small chunks depending on the defined envvar list splitter. + + If the splitter is set to `None`, which means that whitespace splits, + then leading and trailing whitespace is ignored. Otherwise, leading + and trailing splitters usually lead to empty items being included. + """ + return (rv or "").split(self.envvar_list_splitter) + def value_from_envvar(self, ctx: Context) -> str | Sequence[str] | None: """Process the value from the environment variable. @@ -978,7 +985,7 @@ def value_from_envvar(self, ctx: Context) -> str | Sequence[str] | None: rv: Any | None = self.resolve_envvar_value(ctx) if rv is not None and (self.nargs != 1 or self.multiple): - rv = self.type.split_envvar_value(rv) + rv = self.split_envvar_value(rv) return rv @@ -1031,7 +1038,6 @@ def get_error_hint(self, ctx: Context) -> str: def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem"]: """Return a list of completions for the incomplete value. If a ``shell_complete`` function was given during init, it is used. - Otherwise, the `type` `ParamType.shell_complete` function is used. """ if self._custom_shell_complete is not None: results = self._custom_shell_complete(ctx, self, incomplete) diff --git a/typer/_click/decorators.py b/typer/_click/decorators.py index 6fb4944569..af3fbded3d 100644 --- a/typer/_click/decorators.py +++ b/typer/_click/decorators.py @@ -41,7 +41,6 @@ def decorator(f: Command) -> Command: def help_option(param_decls: list[str]) -> Callable[[Command], Command]: """Help option which prints the help page and exits the program.""" from ..coercion import bool_flag_runtime_param, bool_flag_type_descriptor - from .types import ParamType def show_help(ctx: Context, param: Parameter, value: bool) -> None: """Callback that print the help page on ```` and exits.""" @@ -59,7 +58,6 @@ def show_help(ctx: Context, param: Parameter, value: bool) -> None: help="Show this message and exit.", callback=show_help, required=False, - type=ParamType(), runtime_param=bool_flag_runtime_param(), type_descriptor=bool_flag_type_descriptor(), ) diff --git a/typer/_click/termui.py b/typer/_click/termui.py index fd47f2c28f..1e858976ec 100644 --- a/typer/_click/termui.py +++ b/typer/_click/termui.py @@ -5,7 +5,6 @@ from .exceptions import Abort, UsageError from .globals import resolve_color_default -from .types import ParamType from .utils import LazyFile, echo if TYPE_CHECKING: @@ -80,7 +79,7 @@ def prompt( default: Any | None = None, hide_input: bool = False, confirmation_prompt: bool | str = False, - type: ParamType | Any | None = None, + type: Any | None = None, value_proc: Callable[[str], Any] | None = None, prompt_suffix: str = ": ", show_default: bool = True, diff --git a/typer/_click/types.py b/typer/_click/types.py deleted file mode 100644 index 0038a0ff51..0000000000 --- a/typer/_click/types.py +++ /dev/null @@ -1,61 +0,0 @@ -from collections.abc import Sequence -from typing import ( - TYPE_CHECKING, - ClassVar, - NoReturn, - Union, -) - -from .exceptions import BadParameter - -if TYPE_CHECKING: - from ..core import TyperParameter - from .core import Context - from .shell_completion import CompletionItem - - -class ParamType: - """Display and plumbing metadata for a CLI parameter type.""" - - is_composite: ClassVar[bool] = False - - @property - def arity(self) -> int: - return 1 - - # if a list of this type is expected and the value is pulled from a - # string environment variable, this is what splits it up. `None` - # means any whitespace. For all parameters the general rule is that - # whitespace splits them up. The exception are paths and files which - # are split by ``os.path.pathsep`` by default (":" on Unix and ";" on - # Windows). - envvar_list_splitter: ClassVar[str | None] = None - - def split_envvar_value(self, rv: str) -> Sequence[str]: - """Given a value from an environment variable this splits it up - into small chunks depending on the defined envvar list splitter. - - If the splitter is set to `None`, which means that whitespace splits, - then leading and trailing whitespace is ignored. Otherwise, leading - and trailing splitters usually lead to empty items being included. - """ - return (rv or "").split(self.envvar_list_splitter) - - def fail( - self, - message: str, - param: Union["TyperParameter", None] = None, - ctx: Union["Context", None] = None, - ) -> NoReturn: - """Helper method to fail with an invalid value message.""" - raise BadParameter(message, ctx=ctx, param=param) - - def shell_complete( - self, ctx: "Context", param: "TyperParameter", incomplete: str - ) -> list["CompletionItem"]: - """Return a list of `CompletionItem` objects for the - incomplete value. Most types do not provide completions, but - some do, and this allows custom types to provide custom - completions as well. - """ - return [] diff --git a/typer/coercion.py b/typer/coercion.py index 5fa760d3c7..ed47688fff 100644 --- a/typer/coercion.py +++ b/typer/coercion.py @@ -10,7 +10,6 @@ from . import adapters from ._click import Context from ._click.exceptions import BadParameter, UsageError -from ._click.types import ParamType from ._typing import get_args, get_origin, is_number_type from .adapters import validation_context from .display import get_error_msg @@ -38,7 +37,6 @@ class TypeDescriptor: annotation: ParameterAnnotation parameter_info: ParameterInfo - param_type: ParamType adapter: TypeAdapter[Any] | None file_annotation: Any | None @@ -134,8 +132,7 @@ def resolve_type_descriptor( annotation: ParameterAnnotation, parameter_info: ParameterInfo, ) -> TypeDescriptor: - """Resolve ParamType and Pydantic adapter for one parameter annotation.""" - param_type = ParamType() + """Resolve Pydantic adapter for one parameter annotation.""" file_annotation = file_coercion_annotation(annotation) adapter = None if file_annotation is None: @@ -143,7 +140,6 @@ def resolve_type_descriptor( return TypeDescriptor( annotation=annotation, parameter_info=parameter_info, - param_type=param_type, adapter=adapter, file_annotation=file_annotation, ) diff --git a/typer/core.py b/typer/core.py index 785f87280d..c7ff7acba5 100644 --- a/typer/core.py +++ b/typer/core.py @@ -18,7 +18,6 @@ from . import _click, param_types from ._click.parser import _OptionParser from ._click.shell_completion import CompletionItem -from ._click.types import ParamType from ._typing import Literal from .coercion import RuntimeParam, TypeDescriptor from .display import describe_number_range @@ -125,7 +124,7 @@ def value_from_envvar(self, ctx: _click.Context) -> str | Sequence[str] | None: if splitter is not None: rv = (rv or "").split(splitter) else: - rv = self.type.split_envvar_value(rv) + rv = self.split_envvar_value(rv) return rv def shell_complete( @@ -150,11 +149,8 @@ def shell_complete( # file if desc.is_file: return [CompletionItem(incomplete, type="file")] - # path - if desc.is_path: - return [] - # fall-back - return self.type.shell_complete(ctx, self, incomplete) + # fall-back, specifically also required for path's + return [] def make_metavar(self, ctx: _click.Context) -> str | None: return self.metavar @@ -386,7 +382,6 @@ def __init__( *, # Parameter param_decls: list[str], - type: ParamType, runtime_param: RuntimeParam, type_descriptor: TypeDescriptor, required: bool = False, @@ -430,7 +425,6 @@ def __init__( super().__init__( param_decls=param_decls, - type=type, required=required, default=default, callback=callback, @@ -607,7 +601,6 @@ def __init__( *, # Parameter param_decls: list[str], - type: ParamType, runtime_param: RuntimeParam, type_descriptor: TypeDescriptor, required: bool = False, @@ -656,7 +649,6 @@ def __init__( super().__init__( param_decls, - type=type, multiple=multiple, required=required, default=default, diff --git a/typer/main.py b/typer/main.py index 6d8b8752d6..0f9d8e4efb 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1484,7 +1484,6 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: annotation=annotation, parameter_info=parameter_info, ) - parameter_type = descriptor.param_type runtime_param = build_runtime_param(descriptor) tuple_nargs = descriptor.tuple_arity @@ -1513,7 +1512,6 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: multiple=is_list, count=parameter_info.count, allow_from_autoenv=parameter_info.allow_from_autoenv, - type=parameter_type, help=parameter_info.help, hidden=parameter_info.hidden, show_choices=parameter_info.show_choices, @@ -1546,7 +1544,6 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: return TyperArgument( # Argument param_decls=param_decls, - type=parameter_type, required=required, nargs=nargs, # TyperArgument diff --git a/typer/param_types.py b/typer/param_types.py index bea089b50a..165a5911cb 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -16,8 +16,6 @@ from ._click import Context from ._click._compat import open_stream from ._click.exceptions import BadParameter -from ._click.shell_completion import CompletionItem -from ._click.types import ParamType from ._click.utils import LazyFile, format_filename, safecall from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values from .display import get_error_msg @@ -60,7 +58,7 @@ def infer_annotation_from_default(default: Any | None) -> ParameterAnnotation: def annotation_from_prompt(t: Any | None, default: Any | None) -> ParameterAnnotation: - if t is not None and not isinstance(t, ParamType): + if t is not None: return t return infer_annotation_from_default(default) @@ -153,21 +151,6 @@ def choice_as_str(choice: Any) -> str: return str(choice) -def choice_shell_complete( - choices: Sequence[Any], - *, - case_sensitive: bool, - incomplete: str, -) -> list[CompletionItem]: - str_choices = map(choice_as_str, choices) - if case_sensitive: - matched = (c for c in str_choices if c.startswith(incomplete)) - else: - incomplete = incomplete.lower() - matched = (c for c in str_choices if c.lower().startswith(incomplete)) - return [CompletionItem(c) for c in matched] - - # PATH # def path_metavar_label(parameter_info: ParameterInfo) -> str: if parameter_info.file_okay and not parameter_info.dir_okay: From 9b8417321ef14047daa97fbd32c640a1e80c79ba Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 24 Jun 2026 10:21:12 +0200 Subject: [PATCH 65/73] better test for str resolved as path --- tests/test_type_conversion.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 0908d20917..029047f484 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -282,15 +282,22 @@ def show(path: Any = typer.Option(..., path_type=path_type)): assert "my_awesome_file" in result.output -def test_str_with_path_options() -> None: +def test_str_path_resolves(tmp_path: Path) -> None: app = typer.Typer() + seen: list[str] = [] @app.command() - def warp(loc: str = typer.Option(..., resolve_path=True)): - print(loc) + def main(loc: str = typer.Option(..., resolve_path=True)): + seen.append(loc) - param = next(p for p in typer.main.get_command(app).params if p.name == "loc") - assert param.type_descriptor.is_path + rel = tmp_path / "some_dir" / "some_file.txt" + rel.parent.mkdir(parents=True, exist_ok=True) + rel.resolve().write_text("x", encoding="utf-8") + result = runner.invoke(app, ["--loc", str(rel)]) + + assert result.exit_code == 0 + assert isinstance(seen[0], str) + assert seen[0] == os.path.realpath(str(rel)) @pytest.mark.parametrize( From 6198cd6334e737acb2de118fae3e17a560d63a0e Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 24 Jun 2026 10:40:17 +0200 Subject: [PATCH 66/73] even better test for str resolve --- tests/test_type_conversion.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 029047f484..f0b140423b 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -290,14 +290,16 @@ def test_str_path_resolves(tmp_path: Path) -> None: def main(loc: str = typer.Option(..., resolve_path=True)): seen.append(loc) - rel = tmp_path / "some_dir" / "some_file.txt" - rel.parent.mkdir(parents=True, exist_ok=True) - rel.resolve().write_text("x", encoding="utf-8") - result = runner.invoke(app, ["--loc", str(rel)]) + file = tmp_path / "file.txt" + file.write_text("x", encoding="utf-8") + non_canonical = str(tmp_path / "first_dir" / "second_dir" / ".." / "file.txt") + + result = runner.invoke(app, ["--loc", non_canonical]) assert result.exit_code == 0 - assert isinstance(seen[0], str) - assert seen[0] == os.path.realpath(str(rel)) + assert ".." not in seen[0] + assert "second_dir" not in seen[0] + assert str(tmp_path / "first_dir" / "file.txt") in seen[0] @pytest.mark.parametrize( From 6230efc498d5c39ddd19add76d9ff7bb55d4447c Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 24 Jun 2026 13:53:57 +0200 Subject: [PATCH 67/73] avoid Path coercion for str-typed parameters --- tests/test_type_conversion.py | 24 ++---------------------- typer/adapters.py | 9 ++------- typer/coercion.py | 9 +++------ typer/param_types.py | 9 --------- 4 files changed, 7 insertions(+), 44 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index f0b140423b..8916cec568 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -274,7 +274,7 @@ def test_path_coerced(path_type) -> None: app = typer.Typer() @app.command() - def show(path: Any = typer.Option(..., path_type=path_type)): + def show(path: Path = typer.Option(..., path_type=path_type)): print(path) result = runner.invoke(app, ["--path", "dir/my_awesome_file.txt"]) @@ -282,26 +282,6 @@ def show(path: Any = typer.Option(..., path_type=path_type)): assert "my_awesome_file" in result.output -def test_str_path_resolves(tmp_path: Path) -> None: - app = typer.Typer() - seen: list[str] = [] - - @app.command() - def main(loc: str = typer.Option(..., resolve_path=True)): - seen.append(loc) - - file = tmp_path / "file.txt" - file.write_text("x", encoding="utf-8") - non_canonical = str(tmp_path / "first_dir" / "second_dir" / ".." / "file.txt") - - result = runner.invoke(app, ["--loc", non_canonical]) - - assert result.exit_code == 0 - assert ".." not in seen[0] - assert "second_dir" not in seen[0] - assert str(tmp_path / "first_dir" / "file.txt") in seen[0] - - @pytest.mark.parametrize( ("create_file", "option_kwargs", "deny_mode", "expected_error"), [ @@ -398,8 +378,8 @@ def cmd(val=default): pytest.param(Annotated[list[str], typer.Option(...)], ""), pytest.param(Annotated[tuple[str, int], typer.Option(...)], ""), pytest.param(Annotated[tuple[Path, str], typer.Option(...)], ""), + pytest.param(Annotated[str, typer.Option(..., resolve_path=True)], ""), pytest.param(Annotated[Path, typer.Option(...)], ""), - pytest.param(Annotated[str, typer.Option(..., resolve_path=True)], ""), pytest.param(Annotated[Path, typer.Option(..., dir_okay=False)], ""), pytest.param(Annotated[Path, typer.Option(..., file_okay=False)], ""), pytest.param(Annotated[SomeEnum, typer.Option(...)], "[one|two|three]"), diff --git a/typer/adapters.py b/typer/adapters.py index d9a89120c6..14b63c8b48 100644 --- a/typer/adapters.py +++ b/typer/adapters.py @@ -11,12 +11,7 @@ from ._click import _compat from ._typing import is_literal_type, is_number_type, literal_values from .models import ParameterInfo -from .param_types import ( - _needs_typer_path, - coerce_cli_choice, - coerce_cli_path, - lenient_issubclass, -) +from .param_types import coerce_cli_choice, coerce_cli_path, lenient_issubclass if TYPE_CHECKING: from ._click import Context @@ -129,7 +124,7 @@ def parse(value: Any) -> Any: literal_values(annotation), case_sensitive=case_sensitive, ) - if _needs_typer_path(annotation, parameter_info): + if annotation is Path: return build_path_adapter(annotation, parameter_info) if annotation is datetime: diff --git a/typer/coercion.py b/typer/coercion.py index ed47688fff..621abee08f 100644 --- a/typer/coercion.py +++ b/typer/coercion.py @@ -3,6 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import IO, TYPE_CHECKING, Any from pydantic import TypeAdapter, ValidationError @@ -16,7 +17,6 @@ from .models import OptionInfo, ParameterInfo from .param_types import ( ParameterAnnotation, - _needs_typer_path, _open_cli_file, annotation_from_prompt, choice_coercion_annotation, @@ -62,7 +62,7 @@ def is_ranged(self) -> bool: @property def is_path(self) -> bool: - return _needs_typer_path(self.annotation, self.parameter_info) + return self.annotation is Path @property def is_choice(self) -> bool: @@ -120,10 +120,7 @@ def envvar_list_splitter(self) -> str | None: return os.path.pathsep if self.is_list: args = get_args(self.annotation) - if len(args) == 1 and ( - is_file_annotation(args[0]) - or _needs_typer_path(args[0], self.parameter_info) - ): + if len(args) == 1 and (is_file_annotation(args[0]) or args[0] is Path): return os.path.pathsep return None diff --git a/typer/param_types.py b/typer/param_types.py index 165a5911cb..5c7a42dc01 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -160,15 +160,6 @@ def path_metavar_label(parameter_info: ParameterInfo) -> str: return "path" -def _needs_typer_path(annotation: Any, parameter_info: ParameterInfo) -> bool: - return ( - annotation == Path - or parameter_info.allow_dash - or parameter_info.path_type is not None - or parameter_info.resolve_path - ) - - def _coerce_path_result( value: str | os.PathLike[str], path_type: type[Any] | None, From 9c3ddd8ae8d741a91ff007b8aaef3979c4962e06 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 24 Jun 2026 15:42:26 +0200 Subject: [PATCH 68/73] extend metavar test with defaults (will fail for now due to related issue with metavar) --- tests/test_type_conversion.py | 18 ++++++++++++++---- typer/core.py | 6 +----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 8916cec568..8511e5943e 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -369,6 +369,7 @@ def cmd(val=default): ("parameter", "expected_metavar"), [ pytest.param(Annotated[str, typer.Option(...)], ""), + pytest.param(Annotated[str, typer.Argument(...)], ""), pytest.param(Annotated[int, typer.Option(...)], ""), pytest.param(Annotated[float, typer.Option(...)], ""), pytest.param( @@ -376,8 +377,8 @@ def cmd(val=default): ), pytest.param(Annotated[bytes, typer.Option(...)], ""), pytest.param(Annotated[list[str], typer.Option(...)], ""), - pytest.param(Annotated[tuple[str, int], typer.Option(...)], ""), - pytest.param(Annotated[tuple[Path, str], typer.Option(...)], ""), + pytest.param(Annotated[tuple[str, int], typer.Option(...)], "..."), + pytest.param(Annotated[tuple[Path, str], typer.Option(...)], "..."), pytest.param(Annotated[str, typer.Option(..., resolve_path=True)], ""), pytest.param(Annotated[Path, typer.Option(...)], ""), pytest.param(Annotated[Path, typer.Option(..., dir_okay=False)], ""), @@ -400,10 +401,19 @@ def test_param_type_help_metavar(parameter: Any, expected_metavar: str) -> None: app = typer.Typer() @app.command() - def main(value: parameter): + # TODO: type-specific default + def with_default(value: parameter = "my_default"): pass # pragma: no cover - result = runner.invoke(app, ["--help"]) + @app.command() + def without_default(value: parameter): + pass # pragma: no cover + + result = runner.invoke(app, ["with-default", "--help"]) + assert result.exit_code == 0 + assert expected_metavar in result.output + + result = runner.invoke(app, ["without-default", "--help"]) assert result.exit_code == 0 assert expected_metavar in result.output diff --git a/typer/core.py b/typer/core.py index c7ff7acba5..a19d1e6ac8 100644 --- a/typer/core.py +++ b/typer/core.py @@ -572,9 +572,7 @@ def resolve_value_metavar(self, ctx: _click.Context) -> str | None: for c in desc.choices or () } choices_str = "|".join(normalized_mapping.values()) - if self.required: - return f"{{{choices_str}}}" - return f"[{choices_str}]" + return f"{{{choices_str}}}" if desc.is_datetime: return self.metavar_label() return None @@ -1014,8 +1012,6 @@ def resolve_value_metavar(self, ctx: _click.Context) -> str | None: choices_str = "|".join(normalized_mapping.values()) return f"[{choices_str}]" - if self.param_type_name == "argument" and not desc.is_datetime: - return None return self.metavar_label() def get_number_range_help_str(self) -> str | None: From 53a6cf09d4f99137107914e29f53e8851ffd28da Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 26 Jun 2026 09:50:55 +0200 Subject: [PATCH 69/73] fix metavar type display --- tests/test_rich_utils.py | 18 +++++++++--------- typer/rich_utils.py | 19 ++++++++++++++----- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index beb914b961..87af6510eb 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -248,18 +248,18 @@ def main( out_nospace = result.stdout.replace(" ", "") # arguments - assert "arg1INTEGER" in out_nospace - assert "arg3INTEGER" in out_nospace - assert "[arg4]INTEGER" in out_nospace - assert "[meta7]INTEGER" in out_nospace - assert "ARG8INTEGER" in out_nospace - assert "arg9INTEGER" in out_nospace + assert "arg1" in out_nospace + assert "arg3" in out_nospace + assert "[arg4]" in out_nospace + assert "[meta7]" in out_nospace + assert "ARG8" in out_nospace + assert "arg9" in out_nospace assert "arg7" not in result.stdout.lower() assert "arg8" not in result.stdout assert "ARG9" not in result.stdout # options - assert "arg2INTEGER" in out_nospace - assert "arg5INTEGER" in out_nospace - assert "arg6INTEGER" in out_nospace + assert "--arg2" in out_nospace + assert "--arg5" in out_nospace + assert "--arg6" in out_nospace diff --git a/typer/rich_utils.py b/typer/rich_utils.py index b95029dc89..fec1a75bd8 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -368,13 +368,13 @@ def _print_options_panel( metavar_name = None metavar_type = None metavar_str = param.make_metavar(ctx=ctx) - if isinstance(param, TyperArgument): + if isinstance(param, TyperArgument) and metavar_str is not None: # TODO: revise this legacy behaviour of keeping argument names lowercased for Rich formatting if param.metavar is None and param.name: metavar_name = metavar_str.replace(param.name.upper(), param.name) else: metavar_name = metavar_str - if isinstance(param, TyperOption): + if isinstance(param, TyperOption) and metavar_str is not None: metavar_type = metavar_str for opt_str in param.opts: @@ -394,9 +394,18 @@ def _print_options_panel( # Column for recording the type types_data = Text(style=STYLE_TYPES, overflow="fold") - types_data_str = param.resolve_rich_metavar(ctx=ctx) - if types_data_str is not None: - types_data.append(metavar_str) + if isinstance(param, TyperOption) and metavar_type and metavar_type != "BOOL": + types_data.append(metavar_type) + else: + types_data_str = param.resolve_rich_metavar(ctx=ctx) + if isinstance(param, TyperArgument) and types_data_str is not None: + if types_data_str == metavar_name or ( + metavar_name is not None + and types_data_str.upper() == metavar_name.upper() + ): + types_data_str = param.metavar_label() + if types_data_str is not None: + types_data.append(types_data_str) range_str = param.get_number_range_help_str() if range_str: From 166b22d55cacc7bac4a182c76269b504e9fdf1e2 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 26 Jun 2026 12:31:15 +0200 Subject: [PATCH 70/73] solidify metavar printing (part 1: brackets) --- docs/tutorial/parameter-types/enum.md | 12 +- .../test_datetime/test_tutorial001.py | 2 +- .../test_enum/test_tutorial001.py | 2 +- .../test_enum/test_tutorial003.py | 3 +- .../test_enum/test_tutorial004.py | 2 +- tests/test_type_conversion.py | 18 +- tests/test_types.py | 4 +- typer/_click/core.py | 2 +- typer/_click/exceptions.py | 4 +- typer/coercion.py | 6 +- typer/core.py | 183 +++++++++--------- typer/param_types.py | 4 +- typer/rich_utils.py | 39 ++-- 13 files changed, 132 insertions(+), 149 deletions(-) diff --git a/docs/tutorial/parameter-types/enum.md b/docs/tutorial/parameter-types/enum.md index 185ad420c3..fd442fbf21 100644 --- a/docs/tutorial/parameter-types/enum.md +++ b/docs/tutorial/parameter-types/enum.md @@ -19,11 +19,11 @@ Check it: ```console $ python main.py --help -// Notice the predefined values [simple|conv|lstm] +// Notice the predefined values Usage: main.py [OPTIONS] Options: - --network [simple|conv|lstm] [default: simple] + --network [default: simple] --help Show this message and exit. // Try it @@ -91,8 +91,8 @@ $ python main.py --help Usage: main.py [OPTIONS] Options: - --groceries [Eggs|Bacon|Cheese] [default: Eggs, Cheese] - --help Show this message and exit. + --groceries [default: Eggs, Cheese] + --help Show this message and exit. // Try it with the default values $ python main.py @@ -123,11 +123,11 @@ You can also use `Literal` to represent a set of possible predefined choices, wi ```console $ python main.py --help -// Notice the predefined values [simple|conv|lstm] +// Notice the predefined values Usage: main.py [OPTIONS] Options: - --network [simple|conv|lstm] [default: simple] + --network [default: simple] --help Show this message and exit. // Try it diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index 0af2ce014e..84f630f882 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -35,7 +35,7 @@ def test_main_datetime_object(): def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 - assert "Invalid value for 'BIRTH:<%Y-%m-%d>'" in result.output + assert "Invalid value for 'BIRTH'" in result.output assert "should be a valid datetime" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py index 6ec636d7ec..a27d32f1bb 100644 --- a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial001.py @@ -13,7 +13,7 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "--network" in result.output - assert "[simple|conv|lstm]" in result.output + assert "" in result.output assert "default: simple" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial003.py b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial003.py index d6c0e532c9..7214a522df 100644 --- a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial003.py +++ b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial003.py @@ -23,10 +23,11 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): + mod.app.rich_markup_mode = None result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 assert "--groceries" in result.output - assert "[Eggs|Bacon|Cheese]" in result.output + assert "" in result.output assert "default: Eggs, Cheese" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial004.py b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial004.py index 84f0eb3b16..0791fab333 100644 --- a/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial004.py +++ b/tests/test_tutorial/test_parameter_types/test_enum/test_tutorial004.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "--network [simple|conv|lstm]" in result.output.replace(" ", "") + assert "--network " in result.output.replace(" ", "") def test_main(mod): diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 8511e5943e..663ce5d300 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -164,8 +164,9 @@ def custom_parser( assert result.exit_code == 0 -def test_custom_parse_value_error(): +def test_custom_parse_value_error(monkeypatch): app = typer.Typer() + monkeypatch.setenv("COLUMNS", "200") @app.command() def custom_parser( @@ -371,6 +372,7 @@ def cmd(val=default): pytest.param(Annotated[str, typer.Option(...)], ""), pytest.param(Annotated[str, typer.Argument(...)], ""), pytest.param(Annotated[int, typer.Option(...)], ""), + pytest.param(Annotated[int, typer.Argument(...)], ""), pytest.param(Annotated[float, typer.Option(...)], ""), pytest.param( Annotated[float, typer.Option(..., min=0.666, max=3.42)], "" @@ -383,12 +385,18 @@ def cmd(val=default): pytest.param(Annotated[Path, typer.Option(...)], ""), pytest.param(Annotated[Path, typer.Option(..., dir_okay=False)], ""), pytest.param(Annotated[Path, typer.Option(..., file_okay=False)], ""), - pytest.param(Annotated[SomeEnum, typer.Option(...)], "[one|two|three]"), - pytest.param(Annotated[SomeEnum, typer.Argument()], "{one|two|three}"), + pytest.param(Annotated[SomeEnum, typer.Option(...)], ""), + pytest.param(Annotated[SomeEnum, typer.Argument()], ""), + pytest.param( + Annotated[SomeEnum, typer.Option(..., show_choices=False)], "" + ), + pytest.param( + Annotated[list[SomeEnum], typer.Option(...)], "" + ), pytest.param( - Annotated[SomeEnum, typer.Option(..., show_choices=False)], "[SomeEnum]" + Annotated[list[SomeEnum], typer.Option(..., show_choices=False)], "" ), - pytest.param(Annotated[Literal["x", "y"], typer.Option(...)], "[x|y]"), + pytest.param(Annotated[Literal["x", "y"], typer.Option(...)], ""), pytest.param(Annotated[typer.FileText, typer.Option(...)], ""), pytest.param(Annotated[datetime, typer.Option(...)], "<%Y-%m-%d>"), pytest.param( diff --git a/tests/test_types.py b/tests/test_types.py index 8beb9de87b..d60907a79f 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -84,11 +84,11 @@ def test_enum_choice() -> None: def test_enum_choice_help() -> None: result = runner.invoke(app, ["hello-argument", "--help"]) assert result.exit_code == 0 - assert "{rick|morty}" in result.output + assert "" in result.output result = runner.invoke(app, ["hello-option", "--help"]) assert result.exit_code == 0 - assert "[rick|morty]" in result.output + assert "" in result.output result = runner.invoke(app, ["hello-no-choices", "--help"]) assert result.exit_code == 0 diff --git a/typer/_click/core.py b/typer/_click/core.py index 335bba4651..9ed01b2851 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -1028,7 +1028,7 @@ def get_help_record(self, ctx: Context) -> tuple[str, str] | None: def get_usage_pieces(self, ctx: Context) -> list[str]: return [] - def get_error_hint(self, ctx: Context) -> str: + def get_error_hint(self) -> str: """Get a stringified version of the param for use in error messages to indicate which param caused the error. """ diff --git a/typer/_click/exceptions.py b/typer/_click/exceptions.py index d6744f91da..37fc57b3ca 100644 --- a/typer/_click/exceptions.py +++ b/typer/_click/exceptions.py @@ -102,7 +102,7 @@ def format_message(self) -> str: if self.param_hint is not None: param_hint = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) # type: ignore + param_hint = self.param.get_error_hint() else: return f"Invalid value: {self.message}" @@ -130,7 +130,7 @@ def format_message(self) -> str: if self.param_hint is not None: param_hint: Sequence[str] | str | None = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) # type: ignore + param_hint = self.param.get_error_hint() else: param_hint = None diff --git a/typer/coercion.py b/typer/coercion.py index 621abee08f..043370c105 100644 --- a/typer/coercion.py +++ b/typer/coercion.py @@ -23,7 +23,7 @@ file_coercion_annotation, is_file_annotation, lenient_issubclass, - path_metavar_label, + path_type_name, resolve_file_mode, ) @@ -80,8 +80,8 @@ def datetime_formats(self) -> tuple[str, ...]: return ("%Y-%m-%d",) @property - def path_label(self) -> str: - return path_metavar_label(self.parameter_info) + def path_type(self) -> str: + return path_type_name(self.parameter_info) @property def choices(self) -> tuple[Any, ...] | None: diff --git a/typer/core.py b/typer/core.py index a19d1e6ac8..f7e124e5d6 100644 --- a/typer/core.py +++ b/typer/core.py @@ -90,6 +90,7 @@ class TyperParameter(_click.core.Parameter): runtime_param: RuntimeParam type_descriptor: TypeDescriptor + show_choices: bool def process_value(self, ctx: _click.Context, value: Any) -> Any: value = self.runtime_param.coerce(value, param=self, ctx=ctx) @@ -152,43 +153,51 @@ def shell_complete( # fall-back, specifically also required for path's return [] - def make_metavar(self, ctx: _click.Context) -> str | None: + def display_name_type(self, ctx: _click.Context) -> str | None: return self.metavar - def metavar_label(self) -> str: + def display_type(self, ctx: _click.Context) -> str: + """Formatted type string for help, e.g. ````""" desc = self.type_descriptor - if desc.is_list: - label = self.metavar_type() + if desc.is_choice: + if not self.show_choices: + type_names = [self._bare_type(type(c)) for c in desc.choices or ()] + label = "|".join([*dict.fromkeys(type_names)]) + else: + normalized_mapping = { + c: param_types.normalize_choice_value(c, desc.case_sensitive, ctx) + for c in desc.choices or () + } + label = "|".join(normalized_mapping.values()) + if desc.is_list: + label = f"list[{label}]" + elif desc.is_list: + label = self.bare_type() elif desc.is_tuple: - labels = [ - self._metavar_type_by_annotation(arg) - for arg in get_args(desc.annotation) - ] + labels = [self._bare_type(arg) for arg in get_args(desc.annotation)] label = ",".join(labels) elif desc.is_datetime: label = "|".join(desc.datetime_formats) elif desc.is_ranged: label = f"{desc.ranged_type_name} range" elif desc.is_path: - label = desc.path_label + label = desc.path_type else: - label = self.metavar_type() + label = self.bare_type() return f"<{label}>" - def resolve_value_metavar(self, ctx: _click.Context) -> str | None: - return self.metavar_label() - - def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: - metavar_str = self.make_metavar(ctx) - if metavar_str == "BOOL": + def display_type_rich(self, ctx: _click.Context) -> str | None: + """Type string for the Rich help types column.""" + display = self.display_type(ctx) + if display == "BOOL": return None - return metavar_str + return display - def metavar_type(self) -> str: + def bare_type(self) -> str: annotation = self.runtime_param.annotation - return self._metavar_type_by_annotation(annotation) + return self._bare_type(annotation) - def _metavar_type_by_annotation(self, annotation: type) -> str: + def _bare_type(self, annotation: type) -> str: display_type = str(annotation) origin = get_origin(annotation) if annotation is None: @@ -196,14 +205,12 @@ def _metavar_type_by_annotation(self, annotation: type) -> str: elif origin is list: args = get_args(annotation) if len(args) == 1: - element_label = self._metavar_type_by_annotation(args[0]) + element_label = self._bare_type(args[0]) display_type = f"list[{element_label}]" else: display_type = "list" elif origin is tuple: - labels = [ - self._metavar_type_by_annotation(arg) for arg in get_args(annotation) - ] + labels = [self._bare_type(arg) for arg in get_args(annotation)] display_type = ",".join(labels) elif isinstance(annotation, type): display_type = annotation.__name__ @@ -458,17 +465,45 @@ def _get_default_string( default_value=default_value, ) + def display_name(self) -> str: + """Argument display name for usage/help (no type suffix).""" + if self.metavar is not None: + var = self.metavar + if not self.required and not var.startswith("["): + var = f"[{var}]" + return var + var = (self.name or "").upper() + if not self.required: + var = f"[{var}]" + return var + + def rich_display_name(self) -> str: + """Argument display name for the Rich help name column.""" + if self.metavar is not None: + return self.display_name() + label = self.display_name() + if self.name: + label = label.replace(self.name.upper(), self.name) + if self.nargs != 1: + label += "..." + return label + + def usage_display_name(self) -> str: + """Argument name for the usage line and plain-text help records.""" + name = self.display_name() + if self.metavar is None and self.nargs != 1: + name += "..." + return name + def _extract_default_help_str( self, *, ctx: _click.Context ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: - # Modified version of _click.core.Option.get_help_record() - # to support Arguments if self.hidden: return None - name = self.make_metavar(ctx=ctx) + name = self.usage_display_name() help = self.help or "" extra = [] if self.show_envvar: @@ -522,19 +557,12 @@ def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: help = f"{help} {extra_str}" if help else f"{extra_str}" return name, help - def make_metavar(self, ctx: _click.Context) -> str: - if self.metavar is not None: - var = self.metavar - if not self.required and not var.startswith("["): - var = f"[{var}]" - return var - var = (self.name or "").upper() - if not self.required: - var = f"[{var}]" - type_var = self.resolve_value_metavar(ctx) + def display_name_type(self, ctx: _click.Context) -> str: + var = self.display_name() + type_var = self.display_type(ctx) if type_var: var += f":{type_var}" - if self.nargs != 1: + if self.metavar is None and self.nargs != 1: var += "..." return var @@ -556,34 +584,19 @@ def _parse_decls( return name, [arg], [] def get_usage_pieces(self, ctx: _click.Context) -> list[str]: - return [self.make_metavar(ctx)] + return [self.usage_display_name()] - def get_error_hint(self, ctx: _click.Context) -> str: - return f"'{self.make_metavar(ctx)}'" + def get_error_hint(self) -> str: + return f"'{self.display_name()}'" def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) - def resolve_value_metavar(self, ctx: _click.Context) -> str | None: - desc = self.type_descriptor - if desc.is_choice: - normalized_mapping = { - c: param_types.normalize_choice_value(c, desc.case_sensitive, ctx) - for c in desc.choices or () - } - choices_str = "|".join(normalized_mapping.values()) - return f"{{{choices_str}}}" - if desc.is_datetime: - return self.metavar_label() - return None - - def resolve_rich_metavar(self, ctx: _click.Context) -> str | None: - metavar_str = self.make_metavar(ctx) - if self.name and metavar_str == self.name.upper(): - metavar_str = self.metavar_label() - if metavar_str == "BOOL": - return None - return metavar_str + def display_type_rich(self, ctx: _click.Context) -> str | None: + suffix = self.display_type(ctx) + if suffix is not None: + return suffix + return self.display_type(ctx) def get_number_range_help_str(self) -> str | None: return describe_number_range(self.min, self.max) @@ -698,8 +711,8 @@ def __init__( _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) self.rich_help_panel = rich_help_panel - def get_error_hint(self, ctx: _click.Context) -> str: - result = super().get_error_hint(ctx) + def get_error_hint(self) -> str: + result = super().get_error_hint() if self.show_envvar and self.envvar is not None: result += f" (env var: '{self.envvar}')" return result @@ -893,19 +906,19 @@ def _extract_default_help_str( ) -> Any | Callable[[], Any] | None: return _extract_default_help_str(self, ctx=ctx) - def make_metavar(self, ctx: _click.Context) -> str | None: + def value_label(self, ctx: _click.Context) -> str | None: if self.metavar is not None: return self.metavar - value_metavar = self.resolve_value_metavar(ctx) - if self.nargs != 1: - return str(value_metavar) + "..." - return value_metavar + value_display = self.display_type(ctx) + if self.nargs != 1 and value_display is not None: + return str(value_display) + "..." + return value_display + + def display_name_type(self, ctx: _click.Context) -> str | None: + return self.value_label(ctx) def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: - # Duplicate all of Click's logic only to modify a single line, to allow boolean - # flags with only names for False values as it's currently supported by Typer - # Ref: https://typer.tiangolo.com/tutorial/parameter-types/bool/#only-names-for-false if self.hidden: return None @@ -920,7 +933,7 @@ def _write_opts(opts: Sequence[str]) -> str: any_prefix_is_slash = True if not self.is_flag and not self.count: - rv += f" {self.make_metavar(ctx=ctx)}" + rv += f" {self.display_name_type(ctx=ctx)}" return rv @@ -951,24 +964,18 @@ def _write_opts(opts: Sequence[str]) -> str: ) extra.append(_("env var: {var}").format(var=var_str)) - # Typer override: - # Extracted to _extract_default() to allow re-using it in rich_utils default_value = self._extract_default_help_str(ctx=ctx) - # Typer override end show_default_is_str = isinstance(self.show_default, str) if show_default_is_str or ( default_value is not None and (self.show_default or ctx.show_default) ): - # Typer override: - # Extracted to _get_default_string() to allow re-using it in rich_utils default_string = self._get_default_string( ctx=ctx, show_default_is_str=show_default_is_str, default_value=default_value, ) - # Typer override end if default_string: extra.append(_("default: {default}").format(default=default_string)) @@ -995,24 +1002,8 @@ def _write_opts(opts: Sequence[str]) -> str: return ("; " if any_prefix_is_slash else " / ").join(rv), help - def resolve_value_metavar(self, ctx: _click.Context) -> str | None: - desc = self.type_descriptor - if desc.is_choice: - if not self.show_choices: - metavars = [ - self._metavar_type_by_annotation(type(c)) - for c in desc.choices or () - ] - choices_str = "|".join([*dict.fromkeys(metavars)]) - else: - normalized_mapping = { - c: param_types.normalize_choice_value(c, desc.case_sensitive, ctx) - for c in desc.choices or () - } - choices_str = "|".join(normalized_mapping.values()) - - return f"[{choices_str}]" - return self.metavar_label() + def display_type_rich(self, ctx: _click.Context) -> str | None: + return self.value_label(ctx) def get_number_range_help_str(self) -> str | None: if self.count and self.min == 0 and self.max is None: diff --git a/typer/param_types.py b/typer/param_types.py index 5c7a42dc01..b7e7f51eb1 100644 --- a/typer/param_types.py +++ b/typer/param_types.py @@ -152,7 +152,7 @@ def choice_as_str(choice: Any) -> str: # PATH # -def path_metavar_label(parameter_info: ParameterInfo) -> str: +def path_type_name(parameter_info: ParameterInfo) -> str: if parameter_info.file_okay and not parameter_info.dir_okay: return "file" if parameter_info.dir_okay and not parameter_info.file_okay: @@ -202,7 +202,7 @@ def coerce_cli_path( if parameter_info.resolve_path: rv = os.path.realpath(rv) - label = path_metavar_label(parameter_info) + label = path_type_name(parameter_info) try: st = os.stat(rv) except OSError: diff --git a/typer/rich_utils.py b/typer/rich_utils.py index fec1a75bd8..70b67c8bcf 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -364,48 +364,31 @@ def _print_options_panel( secondary_opt_long_strs = [] secondary_opt_short_strs = [] - # check whether argument has a metavar name or type set - metavar_name = None - metavar_type = None - metavar_str = param.make_metavar(ctx=ctx) - if isinstance(param, TyperArgument) and metavar_str is not None: - # TODO: revise this legacy behaviour of keeping argument names lowercased for Rich formatting - if param.metavar is None and param.name: - metavar_name = metavar_str.replace(param.name.upper(), param.name) - else: - metavar_name = metavar_str - if isinstance(param, TyperOption) and metavar_str is not None: - metavar_type = metavar_str + # Argument name label and option type display use separate APIs. + display_name: str | None = None + if isinstance(param, TyperArgument): + display_name = param.rich_display_name() for opt_str in param.opts: if "--" in opt_str: opt_long_strs.append(opt_str) - elif metavar_name: - opt_short_strs.append(metavar_name) + elif display_name: + opt_short_strs.append(display_name) else: opt_short_strs.append(opt_str) for opt_str in param.secondary_opts: if "--" in opt_str: secondary_opt_long_strs.append(opt_str) - elif metavar_name: # pragma: no cover - secondary_opt_short_strs.append(metavar_name) + elif display_name: # pragma: no cover + secondary_opt_short_strs.append(display_name) else: secondary_opt_short_strs.append(opt_str) # Column for recording the type types_data = Text(style=STYLE_TYPES, overflow="fold") - if isinstance(param, TyperOption) and metavar_type and metavar_type != "BOOL": - types_data.append(metavar_type) - else: - types_data_str = param.resolve_rich_metavar(ctx=ctx) - if isinstance(param, TyperArgument) and types_data_str is not None: - if types_data_str == metavar_name or ( - metavar_name is not None - and types_data_str.upper() == metavar_name.upper() - ): - types_data_str = param.metavar_label() - if types_data_str is not None: - types_data.append(types_data_str) + display_type_str = param.display_type_rich(ctx=ctx) + if display_type_str is not None: + types_data.append(display_type_str) range_str = param.get_number_range_help_str() if range_str: From f1cb56e89c9e1f50eb84ccd333ed84bfb9418088 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:31:59 +0000 Subject: [PATCH 71/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_type_conversion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_type_conversion.py b/tests/test_type_conversion.py index 663ce5d300..b1f990dbeb 100644 --- a/tests/test_type_conversion.py +++ b/tests/test_type_conversion.py @@ -394,7 +394,8 @@ def cmd(val=default): Annotated[list[SomeEnum], typer.Option(...)], "" ), pytest.param( - Annotated[list[SomeEnum], typer.Option(..., show_choices=False)], "" + Annotated[list[SomeEnum], typer.Option(..., show_choices=False)], + "", ), pytest.param(Annotated[Literal["x", "y"], typer.Option(...)], ""), pytest.param(Annotated[typer.FileText, typer.Option(...)], ""), From e7e84324da5377266df8a5b491fc0cce6b9ebe91 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 26 Jun 2026 19:01:43 +0200 Subject: [PATCH 72/73] part 2 of metavar printing: use {} to denote arg/opt names otherwise not between brackets --- .../number/tutorial001_an_py310.py | 4 +- .../number/tutorial001_py310.py | 4 +- .../number/tutorial002_an_py310.py | 4 +- .../number/tutorial002_py310.py | 4 +- tests/assets/cli/multiapp-docs-title.md | 4 +- tests/assets/cli/multiapp-docs.md | 4 +- tests/assets/cli/richformattedapp-docs.md | 6 +- tests/test_cli/test_help.py | 2 +- tests/test_core.py | 79 ++++++++++++++----- tests/test_others.py | 2 +- tests/test_prog_name.py | 2 +- tests/test_rich_markup_mode.py | 2 +- tests/test_rich_utils.py | 14 ++-- .../test_default/test_tutorial001.py | 2 +- .../test_default/test_tutorial002.py | 2 +- .../test_envvar/test_tutorial001.py | 4 +- .../test_envvar/test_tutorial002.py | 2 +- .../test_envvar/test_tutorial003.py | 2 +- .../test_help/test_tutorial001.py | 8 +- .../test_help/test_tutorial002.py | 4 +- .../test_help/test_tutorial003.py | 4 +- .../test_help/test_tutorial004.py | 4 +- .../test_help/test_tutorial005.py | 2 +- .../test_help/test_tutorial008.py | 4 +- .../test_optional/test_tutorial000.py | 2 +- .../test_optional/test_tutorial001.py | 4 +- .../test_optional/test_tutorial002.py | 2 +- .../test_optional/test_tutorial003.py | 4 +- .../test_arguments/test_tutorial001.py | 4 +- .../test_help/test_tutorial001.py | 4 +- .../test_help/test_tutorial007.py | 2 +- .../test_first_steps/test_tutorial002.py | 2 +- .../test_first_steps/test_tutorial003.py | 2 +- .../test_first_steps/test_tutorial004.py | 4 +- .../test_first_steps/test_tutorial005.py | 2 +- .../test_tutorial002.py | 2 +- .../test_datetime/test_tutorial001.py | 2 +- .../test_uuid/test_tutorial001.py | 2 +- .../test_typer_app/test_tutorial001.py | 2 +- typer/_click/core.py | 15 ---- typer/core.py | 63 +++++++-------- typer/main.py | 20 ++++- 42 files changed, 174 insertions(+), 133 deletions(-) diff --git a/docs_src/parameter_types/number/tutorial001_an_py310.py b/docs_src/parameter_types/number/tutorial001_an_py310.py index 1784b13f06..995f64b67f 100644 --- a/docs_src/parameter_types/number/tutorial001_an_py310.py +++ b/docs_src/parameter_types/number/tutorial001_an_py310.py @@ -7,11 +7,11 @@ @app.command() def main( - id: Annotated[int, typer.Argument(min=0, max=1000)], + ID: Annotated[int, typer.Argument(min=0, max=1000)], age: Annotated[int, typer.Option(min=18)] = 20, score: Annotated[float, typer.Option(max=100)] = 0, ): - print(f"ID is {id}") + print(f"ID is {ID}") print(f"--age is {age}") print(f"--score is {score}") diff --git a/docs_src/parameter_types/number/tutorial001_py310.py b/docs_src/parameter_types/number/tutorial001_py310.py index fc4fe0d30e..37e9e39ff8 100644 --- a/docs_src/parameter_types/number/tutorial001_py310.py +++ b/docs_src/parameter_types/number/tutorial001_py310.py @@ -5,11 +5,11 @@ @app.command() def main( - id: int = typer.Argument(..., min=0, max=1000), + ID: int = typer.Argument(..., min=0, max=1000), age: int = typer.Option(20, min=18), score: float = typer.Option(0, max=100), ): - print(f"ID is {id}") + print(f"ID is {ID}") print(f"--age is {age}") print(f"--score is {score}") diff --git a/docs_src/parameter_types/number/tutorial002_an_py310.py b/docs_src/parameter_types/number/tutorial002_an_py310.py index 5d3835817c..9df5e4838e 100644 --- a/docs_src/parameter_types/number/tutorial002_an_py310.py +++ b/docs_src/parameter_types/number/tutorial002_an_py310.py @@ -7,11 +7,11 @@ @app.command() def main( - id: Annotated[int, typer.Argument(min=0, max=1000)], + ID: Annotated[int, typer.Argument(min=0, max=1000)], rank: Annotated[int, typer.Option(max=10, clamp=True)] = 0, score: Annotated[float, typer.Option(min=0, max=100, clamp=True)] = 0, ): - print(f"ID is {id}") + print(f"ID is {ID}") print(f"--rank is {rank}") print(f"--score is {score}") diff --git a/docs_src/parameter_types/number/tutorial002_py310.py b/docs_src/parameter_types/number/tutorial002_py310.py index c0daadfbd5..f4a624619f 100644 --- a/docs_src/parameter_types/number/tutorial002_py310.py +++ b/docs_src/parameter_types/number/tutorial002_py310.py @@ -5,11 +5,11 @@ @app.command() def main( - id: int = typer.Argument(..., min=0, max=1000), + ID: int = typer.Argument(..., min=0, max=1000), rank: int = typer.Option(0, max=10, clamp=True), score: float = typer.Option(0, min=0, max=100, clamp=True), ): - print(f"ID is {id}") + print(f"ID is {ID}") print(f"--rank is {rank}") print(f"--score is {score}") diff --git a/tests/assets/cli/multiapp-docs-title.md b/tests/assets/cli/multiapp-docs-title.md index 1bcd798ca2..368275838f 100644 --- a/tests/assets/cli/multiapp-docs-title.md +++ b/tests/assets/cli/multiapp-docs-title.md @@ -76,12 +76,12 @@ Say Hi **Usage**: ```console -$ multiapp sub hi [OPTIONS] [USER] +$ multiapp sub hi [OPTIONS] [user] ``` **Arguments**: -* `[USER]`: The name of the user to greet [default: World] +* `[user]`: The name of the user to greet [default: World] **Options**: diff --git a/tests/assets/cli/multiapp-docs.md b/tests/assets/cli/multiapp-docs.md index 08d708332c..e1dad5f804 100644 --- a/tests/assets/cli/multiapp-docs.md +++ b/tests/assets/cli/multiapp-docs.md @@ -76,12 +76,12 @@ Say Hi **Usage**: ```console -$ multiapp sub hi [OPTIONS] [USER] +$ multiapp sub hi [OPTIONS] [user] ``` **Arguments**: -* `[USER]`: The name of the user to greet [default: World] +* `[user]`: The name of the user to greet [default: World] **Options**: diff --git a/tests/assets/cli/richformattedapp-docs.md b/tests/assets/cli/richformattedapp-docs.md index 678a2daf6f..a6d1ab1dc3 100644 --- a/tests/assets/cli/richformattedapp-docs.md +++ b/tests/assets/cli/richformattedapp-docs.md @@ -5,13 +5,13 @@ Say cool name of the user [required] -* `[USER_2]`: The world [default: The World] +* `user_1`: The cool name of the user [required] +* `[user_2]`: The world [default: The World] **Options**: diff --git a/tests/test_cli/test_help.py b/tests/test_cli/test_help.py index e829c5801b..3238c0658b 100644 --- a/tests/test_cli/test_help.py +++ b/tests/test_cli/test_help.py @@ -121,7 +121,7 @@ def cmd(value: str) -> None: output_lines = result.output.splitlines() usage_idx = output_lines.index("Usage: very-long-program-name-that-forces-wrap ") args_line = output_lines[usage_idx + 1] - assert args_line.lstrip() == "[OPTIONS] VALUE" + assert args_line.lstrip() == "[OPTIONS] {value}" assert args_line.startswith(" ") diff --git a/tests/test_core.py b/tests/test_core.py index 856fb195fe..8f47f77af0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -11,25 +11,6 @@ runner = CliRunner() -def test_human_readable_name() -> None: - app = typer.Typer() - - @app.command() - def main( - my_arg_1: Annotated[str, typer.Argument()], - my_arg_2: Annotated[str, typer.Argument(metavar="META_ARG")], - my_opt: Annotated[str, typer.Option()], - ): - pass # pragma: no cover - - command = typer.main.get_command(app) - params = {param.name: param for param in command.params} - - assert params["my_arg_1"].human_readable_name == "MY_ARG_1" - assert params["my_arg_2"].human_readable_name == "META_ARG" - assert params["my_opt"].human_readable_name == "my_opt" - - def test_parameter_metavar() -> None: app = typer.Typer(rich_markup_mode=None) @@ -379,3 +360,63 @@ def main(names: list[str] = typer.Option(None)): result = runner.invoke(app, [], default_map={"names": "not-a-list"}) assert result.exit_code == 2 assert "Invalid value" in result.output + + +def test_parameter_name_casing(): + app = typer.Typer() + + @app.command() + def main( + arg1: int, + arg2: int = 42, + arg3: int = typer.Argument(...), + ARG4: int = typer.Argument(42), + ARG5: int = typer.Option(...), + arg6: int = typer.Option(42), + arg7: int = typer.Argument(42, metavar="meta7"), + arg8: int = typer.Argument(metavar="ARG8"), + arg9: int = typer.Option(metavar="ARG9"), + ): + print( + f"arg1={arg1} arg2={arg2} arg3={arg3} ARG4={ARG4} ARG5={ARG5} " + f"arg6={arg6} arg7={arg7} arg8={arg8} arg9={arg9}" + ) + + result = runner.invoke( + app, + [ + "1", + "3", + "4", + "7", + "8", + "--arg2", + "2", + "--ARG5", + "5", + "--arg6", + "6", + "--ARG9", + "9", + ], + ) + assert result.exit_code == 0 + assert ( + "arg1=1 arg2=2 arg3=3 ARG4=4 ARG5=5 arg6=6 arg7=7 arg8=8 arg9=9" + in result.output + ) + + result = runner.invoke(app, ["1", "3", "4", "7", "8", "--ARG5", "5", "--ARG9", "9"]) + assert result.exit_code == 0 + assert ( + "arg1=1 arg2=42 arg3=3 ARG4=4 ARG5=5 arg6=42 arg7=7 arg8=8 arg9=9" + in result.output + ) + + result = runner.invoke(app, ["1", "3", "4", "7", "8", "--arg5", "5", "--ARG9", "9"]) + assert result.exit_code != 0 + assert "No such option: --arg5" in result.output + + result = runner.invoke(app, ["1", "3", "4", "7", "8", "--ARG5", "5", "--arg9", "9"]) + assert result.exit_code != 0 + assert "No such option: --arg9" in result.output diff --git a/tests/test_others.py b/tests/test_others.py index e02c356ce7..f6790ac2e3 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -406,7 +406,7 @@ def main(arg1, arg2: int, arg3: "int", arg4: bool = False, arg5: "bool" = False) result = runner.invoke(app, ["Hello", "2", "invalid"]) - assert "Invalid value for 'ARG3'" in result.output + assert "Invalid value for 'arg3'" in result.output assert "Input should be a valid integer" in result.output result = runner.invoke(app, ["Hello", "2", "3", "--arg4", "--arg5"]) assert ( diff --git a/tests/test_prog_name.py b/tests/test_prog_name.py index cfb5a3464f..5de6fd3f7a 100644 --- a/tests/test_prog_name.py +++ b/tests/test_prog_name.py @@ -10,4 +10,4 @@ def test_custom_prog_name(): capture_output=True, encoding="utf-8", ) - assert "Usage: custom-name [OPTIONS] I" in result.stdout + assert "Usage: custom-name [OPTIONS] {i}" in result.stdout diff --git a/tests/test_rich_markup_mode.py b/tests/test_rich_markup_mode.py index abfae82790..94fb4c2ae8 100644 --- a/tests/test_rich_markup_mode.py +++ b/tests/test_rich_markup_mode.py @@ -25,7 +25,7 @@ def main(arg: str): assert "Hello World" in result.stdout result = runner.invoke(app, ["--help"]) - assert "ARG [required]" in result.stdout + assert "arg [required]" in result.stdout assert all(c not in result.stdout for c in rounded) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 87af6510eb..2304b16aa1 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -101,7 +101,7 @@ def main(bar: str): result = runner.invoke(app, ["--help"]) assert "Usage" in result.stdout - assert "BAR" in result.stdout + assert "{bar}" in result.stdout @needs_rich @@ -233,8 +233,8 @@ def main( arg1: int, arg2: int = 42, arg3: int = typer.Argument(...), - arg4: int = typer.Argument(42), - arg5: int = typer.Option(...), + ARG4: int = typer.Argument(42), + ARG5: int = typer.Option(...), arg6: int = typer.Option(42), arg7: int = typer.Argument(42, metavar="meta7"), arg8: int = typer.Argument(metavar="ARG8"), @@ -243,14 +243,16 @@ def main( pass # pragma: no cover result = runner.invoke(app, ["--help"]) - assert "Usage: main [OPTIONS] ARG1 ARG3 [ARG4] [meta7] ARG8 arg9" in result.stdout + assert ( + "Usage: main [OPTIONS] {arg1} {arg3} [ARG4] [meta7] {ARG8} {arg9}" in result.stdout + ) out_nospace = result.stdout.replace(" ", "") # arguments assert "arg1" in out_nospace assert "arg3" in out_nospace - assert "[arg4]" in out_nospace + assert "[ARG4]" in out_nospace assert "[meta7]" in out_nospace assert "ARG8" in out_nospace assert "arg9" in out_nospace @@ -261,5 +263,5 @@ def main( # options assert "--arg2" in out_nospace - assert "--arg5" in out_nospace + assert "--ARG5" in out_nospace assert "--arg6" in out_nospace diff --git a/tests/test_tutorial/test_arguments/test_default/test_tutorial001.py b/tests/test_tutorial/test_arguments/test_default/test_tutorial001.py index 75104a7d9b..81212ce98b 100644 --- a/tests/test_tutorial/test_arguments/test_default/test_tutorial001.py +++ b/tests/test_tutorial/test_arguments/test_default/test_tutorial001.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "[default: Wade Wilson]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_default/test_tutorial002.py b/tests/test_tutorial/test_arguments/test_default/test_tutorial002.py index f68b61df82..37eedd779d 100644 --- a/tests/test_tutorial/test_arguments/test_default/test_tutorial002.py +++ b/tests/test_tutorial/test_arguments/test_default/test_tutorial002.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "[default: (dynamic)]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial001.py b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial001.py index 689030a258..179f6936fc 100644 --- a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial001.py +++ b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial001.py @@ -27,7 +27,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "env var: AWESOME_NAME" in result.output assert "default: World" in result.output @@ -37,7 +37,7 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): monkeypatch.setattr(typer.core, "HAS_RICH", False) result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "env var: AWESOME_NAME" in result.output assert "default: World" in result.output diff --git a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial002.py b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial002.py index 23679b04b5..3d06a1c869 100644 --- a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial002.py +++ b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial002.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "env var: AWESOME_NAME, GOD_NAME" in result.output assert "default: World" in result.output diff --git a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial003.py b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial003.py index ee979e4762..042620f9a0 100644 --- a/tests/test_tutorial/test_arguments/test_envvar/test_tutorial003.py +++ b/tests/test_tutorial/test_arguments/test_envvar/test_tutorial003.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "env var: AWESOME_NAME" not in result.output assert "default: World" in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial001.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial001.py index fabe28be28..e2e24fe647 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial001.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial001.py @@ -26,9 +26,9 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] NAME" in result.output + assert "[OPTIONS] {name}" in result.output assert "Arguments" in result.output - assert "NAME" in result.output + assert "{name}" in result.output assert "The name of the user to greet" in result.output assert "[required]" in result.output @@ -37,9 +37,9 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): monkeypatch.setattr(typer.core, "HAS_RICH", False) result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] NAME" in result.output + assert "[OPTIONS] {name}" in result.output assert "Arguments" in result.output - assert "NAME" in result.output + assert "{name}" in result.output assert "The name of the user to greet" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial002.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial002.py index 91eb30e375..f3fd10a9d1 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial002.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial002.py @@ -25,10 +25,10 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] NAME" in result.output + assert "[OPTIONS] {name}" in result.output assert "Say hi to NAME very gently, like Dirk." in result.output assert "Arguments" in result.output - assert "NAME" in result.output + assert "{name}" in result.output assert "The name of the user to greet" in result.output assert "[required]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial003.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial003.py index 20bd6b76ea..dc7ef0234c 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial003.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial003.py @@ -25,10 +25,10 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Say hi to NAME very gently, like Dirk." in result.output assert "Arguments" in result.output - assert "NAME" in result.output + assert "[name]" in result.output assert "Who to greet" in result.output assert "[default: World]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial004.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial004.py index 7a20f48979..451e3272ad 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial004.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial004.py @@ -25,10 +25,10 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Say hi to NAME very gently, like Dirk." in result.output assert "Arguments" in result.output - assert "NAME" in result.output + assert "[name]" in result.output assert "Who to greet" in result.output assert "[default: World]" not in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial005.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial005.py index 8f6d356d81..4cb17b4e86 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial005.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial005.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "Usage: main [OPTIONS] [NAME]" in result.output + assert "Usage: main [OPTIONS] [name]" in result.output assert "Arguments" in result.output assert "Who to greet" in result.output assert "[default: (Deadpoolio the amazing's name)]" in result.output diff --git a/tests/test_tutorial/test_arguments/test_help/test_tutorial008.py b/tests/test_tutorial/test_arguments/test_help/test_tutorial008.py index f21c883434..004dbbcfe1 100644 --- a/tests/test_tutorial/test_arguments/test_help/test_tutorial008.py +++ b/tests/test_tutorial/test_arguments/test_help/test_tutorial008.py @@ -26,7 +26,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Say hi to NAME very gently, like Dirk." in result.output assert "Arguments" not in result.output assert "[default: World]" not in result.output @@ -36,7 +36,7 @@ def test_help_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): monkeypatch.setattr(typer.core, "HAS_RICH", False) result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output assert "Say hi to NAME very gently, like Dirk." in result.output assert "Arguments" not in result.output assert "[default: World]" not in result.output diff --git a/tests/test_tutorial/test_arguments/test_optional/test_tutorial000.py b/tests/test_tutorial/test_arguments/test_optional/test_tutorial000.py index 8f0bd34c7f..76d86ed82e 100644 --- a/tests/test_tutorial/test_arguments/test_optional/test_tutorial000.py +++ b/tests/test_tutorial/test_arguments/test_optional/test_tutorial000.py @@ -39,7 +39,7 @@ def test_cli(app: typer.Typer): def test_cli_missing_argument(app: typer.Typer): result = runner.invoke(app) assert result.exit_code == 2 - assert "Missing argument 'NAME'" in result.output + assert "Missing argument 'name'" in result.output def test_script(mod: ModuleType): diff --git a/tests/test_tutorial/test_arguments/test_optional/test_tutorial001.py b/tests/test_tutorial/test_arguments/test_optional/test_tutorial001.py index 7e26b9eb86..60d4b5d30d 100644 --- a/tests/test_tutorial/test_arguments/test_optional/test_tutorial001.py +++ b/tests/test_tutorial/test_arguments/test_optional/test_tutorial001.py @@ -26,7 +26,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_call_no_arg(mod: ModuleType): result = runner.invoke(mod.app) assert result.exit_code != 0 - assert "Missing argument 'NAME'." in result.output + assert "Missing argument 'name'." in result.output def test_call_no_arg_standalone(mod: ModuleType): @@ -40,7 +40,7 @@ def test_call_no_arg_no_rich(monkeypatch: pytest.MonkeyPatch, mod: ModuleType): monkeypatch.setattr(typer.core, "HAS_RICH", False) result = runner.invoke(mod.app) assert result.exit_code != 0 - assert "Error: Missing argument 'NAME'" in result.output + assert "Error: Missing argument 'name'" in result.output def test_call_arg(mod: ModuleType): diff --git a/tests/test_tutorial/test_arguments/test_optional/test_tutorial002.py b/tests/test_tutorial/test_arguments/test_optional/test_tutorial002.py index 3e3fdba384..b1b1c9b07b 100644 --- a/tests/test_tutorial/test_arguments/test_optional/test_tutorial002.py +++ b/tests/test_tutorial/test_arguments/test_optional/test_tutorial002.py @@ -25,7 +25,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAME]" in result.output + assert "[OPTIONS] [name]" in result.output def test_call_no_arg(mod: ModuleType): diff --git a/tests/test_tutorial/test_arguments/test_optional/test_tutorial003.py b/tests/test_tutorial/test_arguments/test_optional/test_tutorial003.py index 60addad04d..274616e04c 100644 --- a/tests/test_tutorial/test_arguments/test_optional/test_tutorial003.py +++ b/tests/test_tutorial/test_arguments/test_optional/test_tutorial003.py @@ -15,7 +15,7 @@ def test_call_no_arg(): result = runner.invoke(app) assert result.exit_code != 0 - assert "Missing argument 'NAME'." in result.output + assert "Missing argument 'name'." in result.output def test_call_no_arg_standalone(): @@ -29,7 +29,7 @@ def test_call_no_arg_no_rich(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(typer.core, "HAS_RICH", False) result = runner.invoke(app) assert result.exit_code != 0 - assert "Error: Missing argument 'NAME'" in result.output + assert "Error: Missing argument 'name'" in result.output def test_call_arg(): diff --git a/tests/test_tutorial/test_commands/test_arguments/test_tutorial001.py b/tests/test_tutorial/test_commands/test_arguments/test_tutorial001.py index 4efc1fe9ef..58ac250108 100644 --- a/tests/test_tutorial/test_commands/test_arguments/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_arguments/test_tutorial001.py @@ -13,13 +13,13 @@ def test_help_create(): result = runner.invoke(app, ["create", "--help"]) assert result.exit_code == 0 - assert "create [OPTIONS] USERNAME" in result.output + assert "create [OPTIONS] {username}" in result.output def test_help_delete(): result = runner.invoke(app, ["delete", "--help"]) assert result.exit_code == 0 - assert "delete [OPTIONS] USERNAME" in result.output + assert "delete [OPTIONS] {username}" in result.output def test_create(): diff --git a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py index 9993765b37..751022c673 100644 --- a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py @@ -39,14 +39,14 @@ def test_help(mod: ModuleType): def test_help_create(mod: ModuleType): result = runner.invoke(mod.app, ["create", "--help"]) assert result.exit_code == 0 - assert "create [OPTIONS] USERNAME" in result.output + assert "create [OPTIONS] {username}" in result.output assert "Create a new user with USERNAME." in result.output def test_help_delete(mod: ModuleType): result = runner.invoke(mod.app, ["delete", "--help"]) assert result.exit_code == 0 - assert "delete [OPTIONS] USERNAME" in result.output + assert "delete [OPTIONS] {username}" in result.output assert "Delete a user with USERNAME." in result.output assert "--force" in result.output assert "--no-force" in result.output diff --git a/tests/test_tutorial/test_commands/test_help/test_tutorial007.py b/tests/test_tutorial/test_commands/test_help/test_tutorial007.py index 76c7cb2a4f..7b8fe367ed 100644 --- a/tests/test_tutorial/test_commands/test_help/test_tutorial007.py +++ b/tests/test_tutorial/test_commands/test_help/test_tutorial007.py @@ -36,7 +36,7 @@ def test_main_help(mod: ModuleType): def test_create_help(mod: ModuleType): result = runner.invoke(mod.app, ["create", "--help"]) assert result.exit_code == 0 - assert "create [OPTIONS] USERNAME [LASTNAME]" in result.output + assert "create [OPTIONS] {username} [lastname]" in result.output assert "username" in result.output assert "The username to create" in result.output assert "Secondary Arguments" in result.output diff --git a/tests/test_tutorial/test_first_steps/test_tutorial002.py b/tests/test_tutorial/test_first_steps/test_tutorial002.py index 952f983963..baceee2bc2 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial002.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial002.py @@ -15,7 +15,7 @@ def test_1(): result = runner.invoke(app, []) assert result.exit_code != 0 - assert "Missing argument 'NAME'" in result.output + assert "Missing argument 'name'" in result.output def test_2(): diff --git a/tests/test_tutorial/test_first_steps/test_tutorial003.py b/tests/test_tutorial/test_first_steps/test_tutorial003.py index e92f23521e..5c5ddb39cf 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial003.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial003.py @@ -15,7 +15,7 @@ def test_1(): result = runner.invoke(app, ["Camila"]) assert result.exit_code != 0 - assert "Missing argument 'LASTNAME'" in result.output + assert "Missing argument 'lastname'" in result.output def test_2(): diff --git a/tests/test_tutorial/test_first_steps/test_tutorial004.py b/tests/test_tutorial/test_first_steps/test_tutorial004.py index 6ac326f074..0232605f97 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial004.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial004.py @@ -16,9 +16,9 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "Arguments" in result.output - assert "NAME" in result.output + assert "{name}" in result.output assert "[required]" in result.output - assert "LASTNAME" in result.output + assert "{lastname}" in result.output assert "[required]" in result.output assert "--formal" in result.output assert "--no-formal" in result.output diff --git a/tests/test_tutorial/test_first_steps/test_tutorial005.py b/tests/test_tutorial/test_first_steps/test_tutorial005.py index 05c38a11fc..48c80573f1 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial005.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial005.py @@ -16,7 +16,7 @@ def test_help(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "Arguments" in result.output - assert "NAME" in result.output + assert "{name}" in result.output assert "[required]" in result.output assert "--lastname" in result.output assert "" in result.output diff --git a/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py b/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py index 0fd8ed8f33..c31fabce69 100644 --- a/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py +++ b/tests/test_tutorial/test_multiple_values/test_arguments_with_multiple_values/test_tutorial002.py @@ -27,7 +27,7 @@ def get_mod(request: pytest.FixtureRequest) -> ModuleType: def test_help(mod: ModuleType): result = runner.invoke(mod.app, ["--help"]) assert result.exit_code == 0 - assert "[OPTIONS] [NAMES]..." in result.output + assert "[OPTIONS] [names]..." in result.output assert "Arguments" in result.output assert "[default: Harry, Hermione, Ron]" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py index 84f630f882..e4f2130342 100644 --- a/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_datetime/test_tutorial001.py @@ -35,7 +35,7 @@ def test_main_datetime_object(): def test_invalid(): result = runner.invoke(app, ["july-19-1989"]) assert result.exit_code != 0 - assert "Invalid value for 'BIRTH'" in result.output + assert "Invalid value for 'birth'" in result.output assert "should be a valid datetime" in result.output diff --git a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py index 9f90d76547..11678e1b7b 100644 --- a/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py +++ b/tests/test_tutorial/test_parameter_types/test_uuid/test_tutorial001.py @@ -28,7 +28,7 @@ def test_main_with_uuid_object(): def test_invalid_uuid(): result = runner.invoke(app, ["7479706572-72756c6573"]) assert result.exit_code != 0 - assert "Invalid value for 'USER_ID'" in result.output + assert "Invalid value for 'user_id'" in result.output assert "should be a valid UUID" in result.output diff --git a/tests/test_tutorial/test_typer_app/test_tutorial001.py b/tests/test_tutorial/test_typer_app/test_tutorial001.py index a951fe1881..175841b304 100644 --- a/tests/test_tutorial/test_typer_app/test_tutorial001.py +++ b/tests/test_tutorial/test_typer_app/test_tutorial001.py @@ -13,7 +13,7 @@ def test_no_arg(): result = runner.invoke(app) assert result.exit_code != 0 - assert "Missing argument 'NAME'." in result.output + assert "Missing argument 'name'." in result.output def test_arg(): diff --git a/typer/_click/core.py b/typer/_click/core.py index 9ed01b2851..afff00f5fe 100644 --- a/typer/_click/core.py +++ b/typer/_click/core.py @@ -864,14 +864,6 @@ def _parse_decls( ) -> tuple[str | None, list[str], list[str]]: pass # pragma: no cover - @property - def human_readable_name(self) -> str: - """Returns the human readable name of this parameter. This is the - same as the name for options, but the metavar for arguments. - """ - assert self.name is not None, "self.name should be set" - return self.name - @overload def get_default(self, ctx: Context, call: Literal[True] = True) -> Any | None: ... @@ -1028,13 +1020,6 @@ def get_help_record(self, ctx: Context) -> tuple[str, str] | None: def get_usage_pieces(self, ctx: Context) -> list[str]: return [] - def get_error_hint(self) -> str: - """Get a stringified version of the param for use in error messages to - indicate which param caused the error. - """ - hint_list = self.opts or [self.human_readable_name] - return " / ".join(f"'{x}'" for x in hint_list) - def shell_complete(self, ctx: Context, incomplete: str) -> list["CompletionItem"]: """Return a list of completions for the incomplete value. If a ``shell_complete`` function was given during init, it is used. diff --git a/typer/core.py b/typer/core.py index f7e124e5d6..8e379f8d01 100644 --- a/typer/core.py +++ b/typer/core.py @@ -153,6 +153,16 @@ def shell_complete( # fall-back, specifically also required for path's return [] + @property + def display_name_raw(self) -> str: + if self.metavar is not None: + return self.metavar + assert self.name is not None + return self.name + + def get_error_hint(self) -> str: + return f"'{self.display_name_raw}'" + def display_name_type(self, ctx: _click.Context) -> str | None: return self.metavar @@ -444,13 +454,6 @@ def __init__( ) _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) - @property - def human_readable_name(self) -> str: - if self.metavar is not None: - return self.metavar - assert self.name is not None, "self.name or self.metavar should be set" - return self.name.upper() - def _get_default_string( self, *, @@ -466,35 +469,29 @@ def _get_default_string( ) def display_name(self) -> str: - """Argument display name for usage/help (no type suffix).""" - if self.metavar is not None: - var = self.metavar - if not self.required and not var.startswith("["): - var = f"[{var}]" - return var - var = (self.name or "").upper() + """Argument display name for help listings (no type suffix).""" if not self.required: - var = f"[{var}]" - return var + return f"[{self.display_name_raw}]" + return self.display_name_raw def rich_display_name(self) -> str: """Argument display name for the Rich help name column.""" - if self.metavar is not None: - return self.display_name() - label = self.display_name() - if self.name: - label = label.replace(self.name.upper(), self.name) - if self.nargs != 1: - label += "..." - return label - - def usage_display_name(self) -> str: - """Argument name for the usage line and plain-text help records.""" name = self.display_name() if self.metavar is None and self.nargs != 1: name += "..." return name + def usage_display_name(self) -> str: + """Argument name for the usage line only.""" + name = self.display_name_raw + if self.required: + name = f"{{{name}}}" + else: + name = f"[{name}]" + if self.nargs != 1: + name += "..." + return name + def _extract_default_help_str( self, *, ctx: _click.Context ) -> Any | Callable[[], Any] | None: @@ -503,7 +500,7 @@ def _extract_default_help_str( def get_help_record(self, ctx: _click.Context) -> tuple[str, str] | None: if self.hidden: return None - name = self.usage_display_name() + name = self.rich_display_name() help = self.help or "" extra = [] if self.show_envvar: @@ -575,7 +572,7 @@ def _parse_decls( raise TypeError("Argument is marked as exposed, but does not have a name.") if len(decls) == 1: name = arg = decls[0] - name = name.replace("-", "_").lower() + name = name.replace("-", "_") else: raise TypeError( "Arguments take exactly one parameter declaration, got" @@ -586,9 +583,6 @@ def _parse_decls( def get_usage_pieces(self, ctx: _click.Context) -> list[str]: return [self.usage_display_name()] - def get_error_hint(self) -> str: - return f"'{self.display_name()}'" - def add_to_parser(self, parser: _OptionParser, ctx: _click.Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) @@ -712,7 +706,8 @@ def __init__( self.rich_help_panel = rich_help_panel def get_error_hint(self) -> str: - result = super().get_error_hint() + hint_list = self.opts or [self.display_name_raw] + result = " / ".join(f"'{x}'" for x in hint_list) if self.show_envvar and self.envvar is not None: result += f" (env var: '{self.envvar}')" return result @@ -752,7 +747,7 @@ def _parse_decls( if name is None and possible_names: possible_names.sort(key=lambda x: -len(x[0])) # group long options first - name = possible_names[0][1].replace("-", "_").lower() + name = possible_names[0][1].replace("-", "_") if not name.isidentifier(): name = None diff --git a/typer/main.py b/typer/main.py index 0f9d8e4efb..52e8e3ff43 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1347,6 +1347,22 @@ def get_command_name(name: str) -> str: return name.lower().replace("_", "-") +def get_option_flag_name(name: str) -> str: + return name.replace("_", "-") + + +def get_default_option_flag_name(name: str, metavar: str | None) -> str: + flag_name = get_option_flag_name(name) + if metavar is None: + return flag_name + if ( + get_option_flag_name(metavar).replace("-", "_").casefold() + == flag_name.replace("-", "_").casefold() + ): + return get_option_flag_name(metavar) + return flag_name + + def get_params_ctx_param_name_from_function( callback: Callable[..., Any] | None, ) -> tuple[list[TyperArgument | TyperOption], str | None]: @@ -1488,7 +1504,9 @@ def get_param(param: ParamMeta) -> TyperArgument | TyperOption: tuple_nargs = descriptor.tuple_arity if isinstance(parameter_info, OptionInfo): - default_option_name = get_command_name(param.name) + default_option_name = get_default_option_flag_name( + param.name, parameter_info.metavar + ) if is_flag: default_option_declaration = ( f"--{default_option_name}/--no-{default_option_name}" From 27c784073d492571f29d44e8cd90f2f1ee166d95 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:02:28 +0000 Subject: [PATCH 73/73] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_rich_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 2304b16aa1..6d11652d77 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -244,7 +244,8 @@ def main( result = runner.invoke(app, ["--help"]) assert ( - "Usage: main [OPTIONS] {arg1} {arg3} [ARG4] [meta7] {ARG8} {arg9}" in result.stdout + "Usage: main [OPTIONS] {arg1} {arg3} [ARG4] [meta7] {ARG8} {arg9}" + in result.stdout ) out_nospace = result.stdout.replace(" ", "")