From 316ba8e41d05648fc3cb6ac821b54eeb36a4e229 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Thu, 16 Jul 2026 07:23:28 +0200 Subject: [PATCH 1/2] `click.prompt` typing clarifications / improvements --- CHANGES.md | 7 ++++ pyproject.toml | 3 ++ src/click/termui.py | 80 +++++++++++++++++++++++++++++++------------ src/click/types.py | 34 ++++++++++++++++-- tests/test_imports.py | 2 ++ uv.lock | 4 +++ 6 files changed, 105 insertions(+), 25 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c808c22d19..45ef4215ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,13 @@ 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` + or `value_proc` 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`. + Additionally, a `prompt()` default that is already of the expected type is + returned as-is instead of doing a round trip through `value_proc` or the + type conversion. {pr}`3407` ## Version 8.4.2 diff --git a/pyproject.toml b/pyproject.toml index 0156d5e8ac..863d6a111a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">=3.10" +dependencies = [ + "typing_extensions; python_version < '3.13'", +] [project.urls] Donate = "https://palletsprojects.com/donate" diff --git a/src/click/termui.py b/src/click/termui.py index 4bd74ed19a..a662659764 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import collections.abc as cabc import inspect import io @@ -26,7 +27,13 @@ if t.TYPE_CHECKING: from ._termui_impl import ProgressBar + if sys.version_info >= (3, 13): + from typing import TypeIs + else: + from typing_extensions import TypeIs + V = t.TypeVar("V") +C = t.TypeVar("C") # The prompt functions to use. The doc tools currently override these # functions to customize how they work. @@ -108,39 +115,51 @@ 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 +def _is_expected_type( + default: object, + type: ParamType[V, t.Any] | V | None, +) -> TypeIs[V]: + return builtins.type(default) is builtins.type(type) + + def prompt( text: str, - default: t.Any | None = None, + default: V | C | 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, C | str] | V | None = None, + value_proc: t.Callable[[C | 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 +189,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 + ``default`` no longer passes through the ``value_proc`` callback, + nor the constructor of the types of ``type`` or ``default`` field, + when it is the same type as ``type``. + .. 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. @@ -217,19 +241,31 @@ def prompt_func(text: str) -> str: confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) while True: + result: V | None = None while True: - value = prompt_func(prompt) + value: C | str = prompt_func(prompt) if value: break elif default is not None: - value = default + if _is_expected_type(default=default, type=type): + # It's the expected type, don't reparse it. + result = default + else: + # It's not the expected type. Pass it through value_proc before + # returning. + value = t.cast("C | str", default) break - try: - result = value_proc(value) - except UsageError as e: - message = _mask_hidden_input(e.message, value) if hide_input else e.message - echo(_("Error: {message}").format(message=message), err=err) - continue + if result is None: + try: + result = t.cast("V", value_proc(value)) + except UsageError as e: + message = ( + _mask_hidden_input(e.message, t.cast("str", value)) + if hide_input + else e.message + ) + echo(_("Error: {message}").format(message=message), err=err) + continue if not confirmation_prompt: return result while True: diff --git a/src/click/types.py b/src/click/types.py index a27983b3a2..7ce2ff6466 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -19,6 +19,12 @@ from .utils import LazyFile from .utils import safecall +# TypeVar(default=...) support. +if sys.version_info >= (3, 13): + from typing import TypeVar +else: + from typing_extensions import TypeVar + if t.TYPE_CHECKING: import typing_extensions as te @@ -29,6 +35,7 @@ _ValueT = t.TypeVar("_ValueT") _ValueT_contra = t.TypeVar("_ValueT_contra", contravariant=True) _ValueT_co = t.TypeVar("_ValueT_co", covariant=True) +_InputT_contra = TypeVar("_InputT_contra", contravariant=True, default=t.Any) _FloatValueT = t.TypeVar("_FloatValueT", bound=float) _FloatValueT_co = t.TypeVar("_FloatValueT_co", bound=float, covariant=True) @@ -39,7 +46,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 +68,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 @@ -98,9 +110,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: t.Any, + value: _InputT_contra, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> _ValueT_co: ... + + def __call__( + self, + value: _InputT_contra | None, param: Parameter | None = None, ctx: Context | None = None, ) -> _ValueT_co | None: @@ -119,7 +147,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_imports.py b/tests/test_imports.py index 74b78642bc..6e8a4261fd 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -28,6 +28,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, ALLOWED_IMPORTS = { "__future__", "abc", + "builtins", "codecs", "collections", "collections.abc", @@ -50,6 +51,7 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "threading", "types", "typing", + "typing_extensions", "uuid", "weakref", } diff --git a/uv.lock b/uv.lock index 46ae78248a..5714dd7b1f 100644 --- a/uv.lock +++ b/uv.lock @@ -175,6 +175,9 @@ wheels = [ name = "click" version = "8.5.0.dev0" source = { editable = "." } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] [package.dev-dependencies] dev = [ @@ -218,6 +221,7 @@ typing = [ ] [package.metadata] +requires-dist = [{ name = "typing-extensions", marker = "python_full_version < '3.13'" }] [package.metadata.requires-dev] dev = [ From e8c928d94cd116123892fb08d2c93829e97ae75f Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 16 Jul 2026 07:57:57 +0200 Subject: [PATCH 2/2] Make `prompt`/`ParamType` typing work without runtime `typing_extensions` --- CHANGES.md | 12 ++--- pyproject.toml | 3 -- src/click/termui.py | 83 ++++++++++++++++++--------------- src/click/types.py | 33 ++++++++++--- tests/test_imports.py | 2 - tests/test_types.py | 22 +++++++++ tests/test_utils/test_prompt.py | 26 +++++++++++ tests/typing/typing_prompt.py | 50 ++++++++++++++++++++ uv.lock | 4 -- 9 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 tests/typing/typing_prompt.py diff --git a/CHANGES.md b/CHANGES.md index 45ef4215ea..3af9d535ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,13 +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` - or `value_proc` 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`. - Additionally, a `prompt()` default that is already of the expected type is - returned as-is instead of doing a round trip through `value_proc` or the - type conversion. {pr}`3407` +- `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/pyproject.toml b/pyproject.toml index 863d6a111a..0156d5e8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,9 +14,6 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">=3.10" -dependencies = [ - "typing_extensions; python_version < '3.13'", -] [project.urls] Donate = "https://palletsprojects.com/donate" diff --git a/src/click/termui.py b/src/click/termui.py index a662659764..e932f1a1cc 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -1,6 +1,5 @@ from __future__ import annotations -import builtins import collections.abc as cabc import inspect import io @@ -27,13 +26,7 @@ if t.TYPE_CHECKING: from ._termui_impl import ProgressBar - if sys.version_info >= (3, 13): - from typing import TypeIs - else: - from typing_extensions import TypeIs - V = t.TypeVar("V") -C = t.TypeVar("C") # The prompt functions to use. The doc tools currently override these # functions to customize how they work. @@ -141,20 +134,43 @@ def _format_default(default: V) -> V | str: return default -def _is_expected_type( - default: object, - type: ParamType[V, t.Any] | V | None, -) -> TypeIs[V]: - return builtins.type(default) is builtins.type(type) +@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: V | C | str | None = None, + default: V | str | None = None, hide_input: bool = False, confirmation_prompt: bool | str = False, - type: ParamType[V, C | str] | V | None = None, - value_proc: t.Callable[[C | str], V] | 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, @@ -190,9 +206,9 @@ def prompt( prompt will be "Group by (day, week): ". .. versionchanged:: 8.5.0 - ``default`` no longer passes through the ``value_proc`` callback, - nor the constructor of the types of ``type`` or ``default`` field, - when it is the same type as ``type``. + 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 @@ -241,31 +257,22 @@ def prompt_func(text: str) -> str: confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) while True: - result: V | None = None while True: - value: C | str = prompt_func(prompt) + value = prompt_func(prompt) if value: break elif default is not None: - if _is_expected_type(default=default, type=type): - # It's the expected type, don't reparse it. - result = default - else: - # It's not the expected type. Pass it through value_proc before - # returning. - value = t.cast("C | str", 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 - if result is None: - try: - result = t.cast("V", value_proc(value)) - except UsageError as e: - message = ( - _mask_hidden_input(e.message, t.cast("str", value)) - if hide_input - else e.message - ) - echo(_("Error: {message}").format(message=message), err=err) - continue + try: + result = value_proc(value) + except UsageError as e: + message = _mask_hidden_input(e.message, value) if hide_input else e.message + echo(_("Error: {message}").format(message=message), err=err) + continue if not confirmation_prompt: return result while True: diff --git a/src/click/types.py b/src/click/types.py index 7ce2ff6466..e7f68ab453 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -19,12 +19,6 @@ from .utils import LazyFile from .utils import safecall -# TypeVar(default=...) support. -if sys.version_info >= (3, 13): - from typing import TypeVar -else: - from typing_extensions import TypeVar - if t.TYPE_CHECKING: import typing_extensions as te @@ -35,7 +29,18 @@ _ValueT = t.TypeVar("_ValueT") _ValueT_contra = t.TypeVar("_ValueT_contra", contravariant=True) _ValueT_co = t.TypeVar("_ValueT_co", covariant=True) -_InputT_contra = TypeVar("_InputT_contra", contravariant=True, default=t.Any) + +# 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) @@ -89,6 +94,20 @@ class ParamType(t.Generic[_ValueT_co, _InputT_contra], 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. diff --git a/tests/test_imports.py b/tests/test_imports.py index 6e8a4261fd..74b78642bc 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -28,7 +28,6 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, ALLOWED_IMPORTS = { "__future__", "abc", - "builtins", "codecs", "collections", "collections.abc", @@ -51,7 +50,6 @@ def tracking_import(module, locals=None, globals=None, fromlist=None, "threading", "types", "typing", - "typing_extensions", "uuid", "weakref", } 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) diff --git a/uv.lock b/uv.lock index 5714dd7b1f..46ae78248a 100644 --- a/uv.lock +++ b/uv.lock @@ -175,9 +175,6 @@ wheels = [ name = "click" version = "8.5.0.dev0" source = { editable = "." } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] [package.dev-dependencies] dev = [ @@ -221,7 +218,6 @@ typing = [ ] [package.metadata] -requires-dist = [{ name = "typing-extensions", marker = "python_full_version < '3.13'" }] [package.metadata.requires-dev] dev = [