From 873846e3dd3c470c9d5e2be6bcf0c7ecf02a6221 Mon Sep 17 00:00:00 2001 From: jorenham Date: Mon, 18 May 2026 14:08:10 +0200 Subject: [PATCH 1/2] `types`: fix variance issues and improve type coverage --- src/click/types.py | 198 ++++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 81 deletions(-) diff --git a/src/click/types.py b/src/click/types.py index 556f20f2b8..ab74e6a2a2 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -26,7 +26,12 @@ from .core import Parameter from .shell_completion import CompletionItem -ParamTypeValue = t.TypeVar("ParamTypeValue") +_ValueT = t.TypeVar("_ValueT") +_ValueT_contra = t.TypeVar("_ValueT_contra", contravariant=True) +_ValueT_co = t.TypeVar("_ValueT_co", covariant=True) + +_FloatValueT = t.TypeVar("_FloatValueT", bound=float) +_FloatValueT_co = t.TypeVar("_FloatValueT_co", bound=float, covariant=True) class ParamTypeInfoDict(t.TypedDict): @@ -34,7 +39,7 @@ class ParamTypeInfoDict(t.TypedDict): name: str -class ParamType(t.Generic[ParamTypeValue], abc.ABC): +class ParamType(t.Generic[_ValueT_co], abc.ABC): """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -53,7 +58,7 @@ class ParamType(t.Generic[ParamTypeValue], abc.ABC): """ is_composite: t.ClassVar[bool] = False - arity: t.ClassVar[int] = 1 + arity: int = 1 # read-only #: the descriptive name of this type name: str @@ -92,7 +97,7 @@ def __call__( value: t.Any, param: Parameter | None = None, ctx: Context | None = None, - ) -> ParamTypeValue | None: + ) -> _ValueT_co | None: if value is not None: return self.convert(value, param, ctx) return None @@ -109,7 +114,7 @@ def get_missing_message(self, param: Parameter, ctx: Context | None) -> str | No def convert( self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: + ) -> _ValueT_co: """Convert the value to the correct type. This is not called if the value is ``None`` (the missing value). @@ -131,7 +136,7 @@ def convert( """ # The default returns the value as-is so subclasses that only customize # metadata are not forced to redeclare ``convert``. - return t.cast("ParamTypeValue", value) + return t.cast("_ValueT_co", value) def split_envvar_value(self, rv: str) -> cabc.Sequence[str]: """Given a value from an environment variable this splits it up @@ -170,29 +175,32 @@ def shell_complete( return [] -class CompositeParamType(ParamType[ParamTypeValue]): - is_composite = True +class CompositeParamType(ParamType[_ValueT_co]): + is_composite: t.ClassVar[bool] = True @property @abc.abstractmethod def arity(self) -> int: ... # type: ignore[override] -class FuncParamTypeInfoDict(ParamTypeInfoDict): - func: t.Callable[[t.Any], t.Any] +class FuncParamTypeInfoDict(ParamTypeInfoDict, t.Generic[_ValueT_contra, _ValueT_co]): + func: t.Callable[[_ValueT_contra], _ValueT_co] + +class FuncParamType(ParamType[_ValueT_co], t.Generic[_ValueT_contra, _ValueT_co]): + name: str + func: t.Callable[[_ValueT_contra], _ValueT_co] -class FuncParamType(ParamType[ParamTypeValue]): - def __init__(self, func: t.Callable[[t.Any], ParamTypeValue]) -> None: - self.name: str = func.__name__ + def __init__(self, func: t.Callable[[_ValueT_contra], _ValueT_co]) -> None: + self.name = func.__name__ self.func = func - def to_info_dict(self) -> FuncParamTypeInfoDict: + def to_info_dict(self) -> FuncParamTypeInfoDict[_ValueT_contra, _ValueT_co]: return {"func": self.func, **super().to_info_dict()} def convert( - self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: + self, value: _ValueT_contra, param: Parameter | None, ctx: Context | None + ) -> _ValueT_co: try: return self.func(value) except ValueError as exc: @@ -202,7 +210,7 @@ def convert( try: message = str(value) except UnicodeError: - message = value.decode("utf-8", "replace") + message = t.cast("bytes", value).decode("utf-8", "replace") self.fail(message, param, ctx) @@ -211,8 +219,8 @@ class UnprocessedParamType(ParamType[t.Any]): name = "text" def convert( - self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> t.Any: + self, value: _ValueT, param: Parameter | None, ctx: Context | None + ) -> _ValueT: return value def __repr__(self) -> str: @@ -228,29 +236,28 @@ def convert( if isinstance(value, bytes): enc = _get_argv_encoding() try: - value = value.decode(enc) + return value.decode(enc) except UnicodeError: fs_enc = sys.getfilesystemencoding() if fs_enc != enc: try: - value = value.decode(fs_enc) + return value.decode(fs_enc) except UnicodeError: - value = value.decode("utf-8", "replace") + return value.decode("utf-8", "replace") else: - value = value.decode("utf-8", "replace") - return value # type: ignore[no-any-return] + return value.decode("utf-8", "replace") return str(value) def __repr__(self) -> str: return "STRING" -class ChoiceInfoDict(ParamTypeInfoDict): - choices: cabc.Sequence[t.Any] +class ChoiceInfoDict(ParamTypeInfoDict, t.Generic[_ValueT_co]): + choices: tuple[_ValueT_co, ...] case_sensitive: bool -class Choice(ParamType[ParamTypeValue], t.Generic[ParamTypeValue]): +class Choice(ParamType[_ValueT_co], t.Generic[_ValueT_co]): """The choice type allows a value to be checked against a fixed set of supported values. @@ -273,15 +280,18 @@ class Choice(ParamType[ParamTypeValue], t.Generic[ParamTypeValue]): Choice normalization can be overridden via :meth:`normalize_choice`. """ - name = "choice" + name: str = "choice" + + choices: tuple[_ValueT_co, ...] + case_sensitive: bool def __init__( - self, choices: cabc.Iterable[ParamTypeValue], case_sensitive: bool = True + self, choices: cabc.Iterable[_ValueT_co], case_sensitive: bool = True ) -> None: - self.choices: cabc.Sequence[ParamTypeValue] = tuple(choices) + self.choices = tuple(choices) self.case_sensitive = case_sensitive - def to_info_dict(self) -> ChoiceInfoDict: + def to_info_dict(self) -> ChoiceInfoDict[_ValueT_co]: return { "choices": self.choices, "case_sensitive": self.case_sensitive, @@ -290,7 +300,7 @@ def to_info_dict(self) -> ChoiceInfoDict: def _normalized_mapping( self, ctx: Context | None = None - ) -> cabc.Mapping[ParamTypeValue, str]: + ) -> cabc.Mapping[_ValueT_co, str]: """ Returns mapping where keys are the original choices and the values are the normalized values that are accepted via the command line. @@ -306,7 +316,7 @@ def _normalized_mapping( for choice in self.choices } - def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: + def normalize_choice(self, choice: object, ctx: Context | None) -> str: """ Normalize a choice value, used to map a passed string to a choice. Each choice must have a unique normalized value. @@ -327,7 +337,7 @@ def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: return normed_value def get_metavar(self, param: Parameter, ctx: Context) -> str | None: - if param.param_type_name == "option" and not param.show_choices: # type: ignore + if param.param_type_name == "option" and not param.show_choices: # type: ignore[attr-defined] choice_metavars = [ convert_type(type(choice)).name.upper() for choice in self.choices ] @@ -356,7 +366,7 @@ def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: def convert( self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: + ) -> _ValueT_co: """ For a given value from the parser, normalize it and find its matching normalized value in the list of choices. Then return the @@ -446,8 +456,10 @@ class DateTime(ParamType[datetime]): name = "datetime" + formats: cabc.Sequence[str] + def __init__(self, formats: cabc.Sequence[str] | None = None): - self.formats: cabc.Sequence[str] = formats or [ + self.formats = formats or [ "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", @@ -456,7 +468,7 @@ def __init__(self, formats: cabc.Sequence[str] | None = None): def to_info_dict(self) -> DateTimeInfoDict: return {"formats": self.formats, **super().to_info_dict()} - def get_metavar(self, param: Parameter, ctx: Context) -> str | None: + def get_metavar(self, param: Parameter, ctx: Context) -> str: return f"[{'|'.join(self.formats)}]" def _try_to_convert_date(self, value: t.Any, format: str) -> datetime | None: @@ -492,12 +504,14 @@ def __repr__(self) -> str: return "DateTime" -class _NumberParamTypeBase(ParamType[ParamTypeValue]): - _number_class: t.Callable[[t.Any], ParamTypeValue] +class _NumberParamTypeBase( + ParamType[_ValueT_co], t.Generic[_ValueT_contra, _ValueT_co] +): + _number_class: t.Callable[[_ValueT_contra], _ValueT_co] def convert( - self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: + self, value: _ValueT_contra, param: Parameter | None, ctx: Context | None + ) -> _ValueT_co: try: return self._number_class(value) except ValueError: @@ -510,19 +524,28 @@ def convert( ) -class NumberRangeInfoDict(ParamTypeInfoDict): - min: float | None - max: float | None +class NumberRangeInfoDict(ParamTypeInfoDict, t.Generic[_FloatValueT_co]): + min: _FloatValueT_co | None + max: _FloatValueT_co | None min_open: bool max_open: bool clamp: bool -class _NumberRangeBase(_NumberParamTypeBase[ParamTypeValue]): +class _NumberRangeBase( + _NumberParamTypeBase[_ValueT_contra, _FloatValueT_co], + t.Generic[_ValueT_contra, _FloatValueT_co], +): + min: _FloatValueT_co | None + max: _FloatValueT_co | None + min_open: bool + max_open: bool + clamp: bool + def __init__( self, - min: float | None = None, - max: float | None = None, + min: _FloatValueT_co | None = None, + max: _FloatValueT_co | None = None, min_open: bool = False, max_open: bool = False, clamp: bool = False, @@ -533,7 +556,7 @@ def __init__( self.max_open = max_open self.clamp = clamp - def to_info_dict(self) -> NumberRangeInfoDict: + def to_info_dict(self) -> NumberRangeInfoDict[_FloatValueT_co]: return { "min": self.min, "max": self.max, @@ -544,8 +567,8 @@ def to_info_dict(self) -> NumberRangeInfoDict: } def convert( - self, value: t.Any, param: Parameter | None, ctx: Context | None - ) -> ParamTypeValue: + self, value: _ValueT_contra, param: Parameter | None, ctx: Context | None + ) -> _FloatValueT_co: import operator rv = super().convert(value, param, ctx) @@ -553,17 +576,17 @@ def convert( max = self.max lt_min: bool = min is not None and ( operator.le if self.min_open else operator.lt - )(rv, min) # type: ignore[arg-type] + )(rv, min) gt_max: bool = max is not None and ( operator.ge if self.max_open else operator.gt - )(rv, max) # type: ignore[arg-type] + )(rv, max) if self.clamp: if min is not None and lt_min: - return self._clamp(min, 1, self.min_open) # type: ignore[arg-type] + return self._clamp(min, 1, self.min_open) if max is not None and gt_max: - return self._clamp(max, -1, self.max_open) # type: ignore[arg-type] + return self._clamp(max, -1, self.max_open) if lt_min or gt_max: self.fail( @@ -578,8 +601,13 @@ def convert( @abc.abstractmethod def _clamp( - self, bound: ParamTypeValue, dir: t.Literal[1, -1], open: bool - ) -> ParamTypeValue: + # Covariant type variables cannot be used in input positions, so we use a + # separate method-scoped type variable instead. + self: _NumberRangeBase[t.Any, _FloatValueT], + bound: _FloatValueT, + dir: t.Literal[1, -1], + open: bool, + ) -> _FloatValueT: """Find the valid value to clamp to bound in the given direction. @@ -608,7 +636,7 @@ def __repr__(self) -> str: return f"<{type(self).__name__} {self._describe_range()}{clamp}>" -class IntParamType(_NumberParamTypeBase[int]): +class IntParamType(_NumberParamTypeBase[t.SupportsInt | t.SupportsIndex, int]): name = "integer" _number_class = int @@ -616,7 +644,7 @@ def __repr__(self) -> str: return "INT" -class IntRange(_NumberRangeBase[int], IntParamType): +class IntRange(_NumberRangeBase[int, int], IntParamType): """Restrict an :data:`click.INT` value to a range of accepted values. See :ref:`ranges`. @@ -640,7 +668,7 @@ def _clamp(self, bound: int, dir: t.Literal[1, -1], open: bool) -> int: return bound + dir -class FloatParamType(_NumberParamTypeBase[float]): +class FloatParamType(_NumberParamTypeBase[t.SupportsFloat | t.SupportsIndex, float]): name = "float" _number_class = float @@ -648,7 +676,7 @@ def __repr__(self) -> str: return "FLOAT" -class FloatRange(_NumberRangeBase[float], FloatParamType): +class FloatRange(_NumberRangeBase[float, float], FloatParamType): """Restrict a :data:`click.FLOAT` value to a range of accepted values. See :ref:`ranges`. @@ -764,7 +792,7 @@ class UUIDParameterType(ParamType[uuid.UUID]): name = "uuid" def convert( - self, value: t.Any, param: Parameter | None, ctx: Context | None + self, value: uuid.UUID | str, param: Parameter | None, ctx: Context | None ) -> uuid.UUID: if isinstance(value, uuid.UUID): return value @@ -820,6 +848,12 @@ class File(ParamType[t.IO[t.Any]]): name = "filename" envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + mode: str + encoding: str | None + errors: str | None + lazy: bool | None + atomic: bool + def __init__( self, mode: str = "r", @@ -859,8 +893,6 @@ def convert( if _is_file_like(value): return value - value = t.cast("str | os.PathLike[str]", value) - try: lazy = self.resolve_lazy_flag(value) @@ -914,7 +946,7 @@ def shell_complete( return [CompletionItem(incomplete, type="file")] -def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]: +def _is_file_like(value: t.Any) -> te.TypeIs[t.IO[t.Any]]: return hasattr(value, "read") or hasattr(value, "write") @@ -962,6 +994,16 @@ class Path(ParamType[str | bytes | os.PathLike[str]]): envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + exists: bool + file_okay: bool + dir_okay: bool + readable: bool + writable: bool + executable: bool + resolve_path: bool + allow_dash: bool + name: str + def __init__( self, exists: bool = False, @@ -971,9 +1013,9 @@ def __init__( readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, - path_type: type[t.Any] | None = None, + path_type: type | None = None, executable: bool = False, - ): + ) -> None: self.exists = exists self.file_okay = file_okay self.dir_okay = dir_okay @@ -982,10 +1024,10 @@ def __init__( self.executable = executable self.resolve_path = resolve_path self.allow_dash = allow_dash - self.type = path_type + self.type: type | None = path_type if self.file_okay and not self.dir_okay: - self.name: str = _("file") + self.name = _("file") elif self.dir_okay and not self.file_okay: self.name = _("directory") else: @@ -1201,20 +1243,14 @@ def _guess_type( @t.overload def convert_type(ty: None, default: None = None) -> StringParamType: ... - - @t.overload def convert_type( - ty: type[t.Any] | ParamType[t.Any], default: t.Any | None = None + ty: type | ParamType[t.Any], default: t.Any | None = None ) -> ParamType[t.Any]: ... - - @t.overload def convert_type( ty: t.Any | None, default: t.Any | None = None ) -> ParamType[t.Any]: ... - - def convert_type( ty: t.Any | None = None, default: t.Any | None = None ) -> ParamType[t.Any]: @@ -1270,26 +1306,26 @@ def convert_type( #: it is provided. #: #: .. versionadded:: 4.0 -UNPROCESSED = UnprocessedParamType() +UNPROCESSED: t.Final[UnprocessedParamType] = UnprocessedParamType() #: A unicode string parameter type which is the implicit default. This #: can also be selected by using ``str`` as type. -STRING = StringParamType() +STRING: t.Final[StringParamType] = StringParamType() #: An integer parameter. This can also be selected by using ``int`` as #: type. -INT = IntParamType() +INT: t.Final[IntParamType] = IntParamType() #: A floating point value parameter. This can also be selected by using #: ``float`` as type. -FLOAT = FloatParamType() +FLOAT: t.Final[FloatParamType] = FloatParamType() #: A boolean parameter. This is the default for boolean flags. This can #: also be selected by using ``bool`` as a type. -BOOL = BoolParamType() +BOOL: t.Final[BoolParamType] = BoolParamType() #: A UUID parameter. -UUID = UUIDParameterType() +UUID: t.Final[UUIDParameterType] = UUIDParameterType() class OptionHelpExtra(t.TypedDict, total=False): From 22ea58b6f568ca32fa4d069ee6e30c6045dc1797 Mon Sep 17 00:00:00 2001 From: jorenham Date: Mon, 18 May 2026 14:25:45 +0200 Subject: [PATCH 2/2] fix generic typed dict `TypeError` on Python 3.10 --- src/click/types.py | 51 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/click/types.py b/src/click/types.py index ab74e6a2a2..2245c58e5e 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -183,8 +183,18 @@ class CompositeParamType(ParamType[_ValueT_co]): def arity(self) -> int: ... # type: ignore[override] -class FuncParamTypeInfoDict(ParamTypeInfoDict, t.Generic[_ValueT_contra, _ValueT_co]): - func: t.Callable[[_ValueT_contra], _ValueT_co] +if t.TYPE_CHECKING: + # on Python 3.10 this will raise a TypeError + + class FuncParamTypeInfoDict( + ParamTypeInfoDict, + t.Generic[_ValueT_contra, _ValueT_co], + ): + func: t.Callable[[_ValueT_contra], _ValueT_co] +else: + + class FuncParamTypeInfoDict(ParamTypeInfoDict): + func: t.Callable[[t.Any], t.Any] class FuncParamType(ParamType[_ValueT_co], t.Generic[_ValueT_contra, _ValueT_co]): @@ -252,9 +262,17 @@ def __repr__(self) -> str: return "STRING" -class ChoiceInfoDict(ParamTypeInfoDict, t.Generic[_ValueT_co]): - choices: tuple[_ValueT_co, ...] - case_sensitive: bool +if t.TYPE_CHECKING: + # on Python 3.10 this will raise a TypeError + + class ChoiceInfoDict(ParamTypeInfoDict, t.Generic[_ValueT_co]): + choices: tuple[_ValueT_co, ...] + case_sensitive: bool +else: + + class ChoiceInfoDict(ParamTypeInfoDict): + choices: tuple[t.Any, ...] + case_sensitive: bool class Choice(ParamType[_ValueT_co], t.Generic[_ValueT_co]): @@ -524,12 +542,23 @@ def convert( ) -class NumberRangeInfoDict(ParamTypeInfoDict, t.Generic[_FloatValueT_co]): - min: _FloatValueT_co | None - max: _FloatValueT_co | None - min_open: bool - max_open: bool - clamp: bool +if t.TYPE_CHECKING: + # on Python 3.10 this will raise a TypeError + + class NumberRangeInfoDict(ParamTypeInfoDict, t.Generic[_FloatValueT_co]): + min: _FloatValueT_co | None + max: _FloatValueT_co | None + min_open: bool + max_open: bool + clamp: bool +else: + + class NumberRangeInfoDict(ParamTypeInfoDict): + min: t.Any | None + max: t.Any | None + min_open: bool + max_open: bool + clamp: bool class _NumberRangeBase(