diff --git a/CHANGES.md b/CHANGES.md index c808c22d19..3af9d535ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,11 @@ Unreleased `flag_value` and `default` keep their unset sentinel at construction (resolved lazily on read) so `is UNSET` reliably tells a user-supplied value from an auto-derived one. Behavior is unchanged. {pr}`3641` +- `prompt()` is now generically typed and returns the type produced by + `type`, `value_proc`, or a matching `default` instead of `Any`. + {class}`ParamType` takes a second optional type parameter describing the + input value it accepts (`ParamType[int, str]` for a type converting + strings to integers), defaulting to `Any`. {pr}`3407` ## Version 8.4.2 diff --git a/src/click/termui.py b/src/click/termui.py index 4bd74ed19a..e932f1a1cc 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -108,39 +108,74 @@ def _build_prompt( text: str, suffix: str, show_default: bool | str = False, - default: t.Any | None = None, + default: object | None = None, show_choices: bool = True, - type: ParamType[t.Any] | None = None, + type: object | None = None, ) -> str: prompt = text if type is not None and show_choices and isinstance(type, Choice): prompt += f" ({', '.join(map(str, type.choices))})" - if isinstance(show_default, str): - default = f"({show_default})" - if default is not None and show_default: - prompt = f"{prompt} [{_format_default(default)}]" - return f"{prompt}{suffix}" + default_preview = "" + if show_default: + if isinstance(show_default, str): + default_preview = f" [({show_default})]" + elif default is not None: + default_preview = f" [{_format_default(default)}]" + return f"{prompt}{default_preview}{suffix}" -def _format_default(default: t.Any) -> t.Any: - if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): - return default.name +def _format_default(default: V) -> V | str: + if isinstance(default, (io.IOBase, LazyFile)): + name = getattr(default, "name", None) + + if name is not None: + return str(name) return default +@t.overload +def prompt( + text: str, + default: str | None = None, + hide_input: bool = False, + confirmation_prompt: bool | str = False, + type: None = None, + value_proc: None = None, + prompt_suffix: str = ": ", + show_default: bool | str = True, + err: bool = False, + show_choices: bool = True, +) -> str: ... + + +@t.overload +def prompt( + text: str, + default: V | str | None = None, + hide_input: bool = False, + confirmation_prompt: bool | str = False, + type: ParamType[V, str] | type[V] | None = None, + value_proc: t.Callable[[str], V] | None = None, + prompt_suffix: str = ": ", + show_default: bool | str = True, + err: bool = False, + show_choices: bool = True, +) -> V: ... + + def prompt( text: str, - default: t.Any | None = None, + default: V | str | None = None, hide_input: bool = False, confirmation_prompt: bool | str = False, - type: ParamType[t.Any] | t.Any | None = None, - value_proc: t.Callable[[str], t.Any] | None = None, + type: ParamType[V, str] | type[V] | None = None, + value_proc: t.Callable[[str], V] | None = None, prompt_suffix: str = ": ", show_default: bool | str = True, err: bool = False, show_choices: bool = True, -) -> t.Any: +) -> V: """Prompts a user for input. This is a convenience function that can be used to prompt a user for input later. @@ -170,6 +205,11 @@ def prompt( show_choices is true and text is "Group by" then the prompt will be "Group by (day, week): ". + .. versionchanged:: 8.5.0 + Generically typed: the return type is narrowed by ``type``, + ``value_proc``, or ``default`` instead of being ``Any``. Runtime + behavior is unchanged. + .. versionchanged:: 8.3.3 ``show_default`` can be a string to show a custom value instead of the actual default, matching the help text behavior. @@ -222,7 +262,10 @@ def prompt_func(text: str) -> str: if value: break elif default is not None: - value = default + # Defaults of any type are accepted and round trip through + # value_proc like typed input, so the annotation is only + # accurate for typed input. + value = t.cast("str", default) break try: result = value_proc(value) diff --git a/src/click/types.py b/src/click/types.py index a27983b3a2..e7f68ab453 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -30,6 +30,18 @@ _ValueT_contra = t.TypeVar("_ValueT_contra", contravariant=True) _ValueT_co = t.TypeVar("_ValueT_co", covariant=True) +# The input type parameter of ParamType defaults to Any. TypeVar defaults +# (PEP 696) landed in typing on Python 3.13, so type checkers get the +# default from typing_extensions, which is never imported at runtime. On +# older Pythons the runtime TypeVar carries no default; +# ParamType.__class_getitem__ fills in the omitted parameter instead. +if t.TYPE_CHECKING: + _InputT_contra = te.TypeVar("_InputT_contra", contravariant=True, default=t.Any) +elif sys.version_info >= (3, 13): + _InputT_contra = t.TypeVar("_InputT_contra", contravariant=True, default=t.Any) +else: + _InputT_contra = t.TypeVar("_InputT_contra", contravariant=True) + _FloatValueT = t.TypeVar("_FloatValueT", bound=float) _FloatValueT_co = t.TypeVar("_FloatValueT_co", bound=float, covariant=True) @@ -39,7 +51,7 @@ class ParamTypeInfoDict(t.TypedDict): name: str -class ParamType(t.Generic[_ValueT_co], abc.ABC): +class ParamType(t.Generic[_ValueT_co, _InputT_contra], abc.ABC): """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -61,6 +73,11 @@ class ParamType(t.Generic[_ValueT_co], abc.ABC): converted value type (``ParamType[int]`` for an integer-returning type) so that :meth:`convert` and downstream consumers carry the narrowed return type. + + .. versionchanged:: 8.5.0 + Accepts a second optional type parameter for the input value type + that :meth:`convert` accepts (``ParamType[int, str]`` for a type + converting strings to integers), defaulting to ``Any``. """ is_composite: t.ClassVar[bool] = False @@ -77,6 +94,20 @@ class ParamType(t.Generic[_ValueT_co], abc.ABC): #: Windows). envvar_list_splitter: t.ClassVar[str | None] = None + if sys.version_info < (3, 13): + # ``_InputT_contra`` carries its ``Any`` default only for type + # checkers: ``TypeVar(default=...)`` (PEP 696) is unavailable at + # runtime before Python 3.13. Fill in the omitted input type + # parameter by hand so ``ParamType[int]`` keeps working. + def __class_getitem__(cls, params: t.Any) -> t.Any: + if cls is ParamType: + if not isinstance(params, tuple): + params = (params,) + if len(params) == 1: + params = (*params, t.Any) + # Checkers cannot see Generic.__class_getitem__ through super(). + return super().__class_getitem__(params) # type: ignore[misc] + def to_info_dict(self) -> ParamTypeInfoDict: """Gather information that could be useful for a tool generating user-facing documentation. @@ -98,9 +129,25 @@ def to_info_dict(self) -> ParamTypeInfoDict: return {"param_type": param_type, "name": name} + @t.overload + def __call__( + self, + value: None, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> None: ... + + @t.overload + def __call__( + self, + value: _InputT_contra, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> _ValueT_co: ... + def __call__( self, - value: t.Any, + value: _InputT_contra | None, param: Parameter | None = None, ctx: Context | None = None, ) -> _ValueT_co | None: @@ -119,7 +166,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 + self, value: _InputT_contra, param: Parameter | None, ctx: Context | None ) -> _ValueT_co: """Convert the value to the correct type. This is not called if the value is ``None`` (the missing value). diff --git a/tests/test_types.py b/tests/test_types.py index 1ab2547dc5..63992ccb4b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -4,6 +4,7 @@ import subprocess import sys import tempfile +import typing as t import pytest @@ -308,3 +309,24 @@ def test_choice_get_invalid_choice_message(): choice = click.Choice(["a", "b", "c"]) message = choice.get_invalid_choice_message("d", ctx=None) assert message == "'d' is not one of 'a', 'b', 'c'." + + +def test_param_type_input_parameter_defaults_at_runtime(): + """Omitting the input type parameter works at runtime on every + supported Python. The ``Any`` default is native (PEP 696) on Python + 3.13+, and backfilled by ``ParamType.__class_getitem__`` before + that.""" + assert t.get_args(click.ParamType[int]) == (int, t.Any) + assert t.get_args(click.ParamType[int, str]) == (int, str) + + +def test_param_type_subclass_omitting_input_parameter(): + class DoublingType(click.ParamType[int]): + name = "doubling" + + def convert(self, value, param, ctx): + return int(value) * 2 + + doubling = DoublingType() + assert doubling("21") == 42 + assert doubling(None) is None diff --git a/tests/test_utils/test_prompt.py b/tests/test_utils/test_prompt.py index 7386c65172..1b223afc30 100644 --- a/tests/test_utils/test_prompt.py +++ b/tests/test_utils/test_prompt.py @@ -14,6 +14,32 @@ def test_prompt_cast_default(capfd, monkeypatch): assert isinstance(value, int) +def test_prompt_default_round_trips_through_type(capfd, monkeypatch): + """A default that already has the converted value type still passes + through ``ParamType.convert``, so conversion side effects keep + applying to defaults.""" + + class DoublingType(click.ParamType[int]): + name = "doubling" + + def convert(self, value, param, ctx): + return int(value) * 2 + + monkeypatch.setattr(sys, "stdin", StringIO("\n")) + value = click.prompt("value", default=5, type=DoublingType()) + capfd.readouterr() + assert value == 10 + + +def test_prompt_default_validated_by_type(capfd, monkeypatch): + """An out-of-range default is rejected by the type's validation and + prompts again instead of being returned as-is.""" + monkeypatch.setattr(sys, "stdin", StringIO("\n7\n")) + value = click.prompt("value", default=100, type=click.IntRange(0, 10)) + capfd.readouterr() + assert value == 7 + + @pytest.mark.skipif(WIN, reason="Different behavior on windows.") def test_prompts_abort(monkeypatch, capsys): def f(_): diff --git a/tests/typing/typing_prompt.py b/tests/typing/typing_prompt.py new file mode 100644 index 0000000000..5150ef9d28 --- /dev/null +++ b/tests/typing/typing_prompt.py @@ -0,0 +1,50 @@ +from typing_extensions import assert_type + +import click + +# Without ``type``, ``value_proc``, or a non-``str`` ``default``, the raw +# string input is returned. +assert_type(click.prompt("Name"), str) +assert_type(click.prompt("Name", default="bob"), str) + +# The return type is narrowed by the ``type`` argument, whether it is a +# ParamType instance or a plain class, independently of the default's type. +assert_type(click.prompt("Age", type=click.INT), int) +assert_type(click.prompt("Age", type=click.IntRange(0, 130), default=18), int) +assert_type(click.prompt("Age", type=int), int) +assert_type(click.prompt("Age", type=int, default="100"), int) + +# The return type is narrowed by the ``value_proc`` argument. + + +def to_float(value: str) -> float: + return float(value) + + +assert_type(click.prompt("Ratio", value_proc=to_float), float) + +# The return type is narrowed by the ``default`` argument alone. +assert_type(click.prompt("Age", default=18), int) + + +# A custom type may declare both its converted value and accepted input +# types. Omitting the input type parameter defaults it to ``Any``. +class DoublingType(click.ParamType[int, str]): + name = "doubling" + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> int: + return int(value) * 2 + + +assert_type(click.prompt("Num", type=DoublingType()), int) +assert_type(DoublingType()("21"), int) +assert_type(DoublingType()(None), None) + + +class SimpleType(click.ParamType[int]): + name = "simple" + + +assert_type(SimpleType()("21"), int)