Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 58 additions & 15 deletions src/click/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 50 additions & 3 deletions src/click/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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).
Expand Down
22 changes: 22 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import subprocess
import sys
import tempfile
import typing as t

import pytest

Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions tests/test_utils/test_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(_):
Expand Down
50 changes: 50 additions & 0 deletions tests/typing/typing_prompt.py
Original file line number Diff line number Diff line change
@@ -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)