From f66ef3778014017ccc664d47f2cdb81f9a0537ef Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Sun, 3 May 2026 21:07:21 +0200 Subject: [PATCH 1/6] click.prompt typed, but blocked on convert_type --- src/click/termui.py | 53 +++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index 892e4c0bc..a7d1dcd8e 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -83,39 +83,44 @@ def _build_prompt( text: str, suffix: str, show_default: bool | str = False, - default: t.Any | None = None, + default: V | None = None, show_choices: bool = True, - type: ParamType[t.Any] | None = None, + type: ParamType[V] | V | 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 prompt( text: str, - default: t.Any | None = None, + default: V | 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] | 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. @@ -192,21 +197,23 @@ def prompt_func(text: str) -> str: confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) while True: + result = None while True: value = prompt_func(prompt) if value: break elif default is not None: - value = default + result = default break - try: - result = value_proc(value) - except UsageError as e: - if hide_input: - echo(_("Error: The value you entered was invalid."), err=err) - else: - echo(_("Error: {e.message}").format(e=e), err=err) - continue + if result is None: + try: + result = value_proc(value) + except UsageError as e: + if hide_input: + echo(_("Error: The value you entered was invalid."), err=err) + else: + echo(_("Error: {e.message}").format(e=e), err=err) + continue if not confirmation_prompt: return result while True: From a06e026f2b46dff76ca6b6282ee47bac6ecf84ea Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Mon, 4 May 2026 22:16:33 +0200 Subject: [PATCH 2/6] ParamType.__call__ overloading to make it compatible with Callable. --- src/click/types.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/click/types.py b/src/click/types.py index 355e98423..06318b429 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -87,6 +87,24 @@ 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, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> ParamTypeValue: + ... + def __call__( self, value: t.Any, From a3c0a3e21b625e6d512fd7d1b9a31c9fbe209d62 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Mon, 4 May 2026 22:23:56 +0200 Subject: [PATCH 3/6] Added change entries. --- CHANGES.rst | 3 +++ src/click/termui.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 76f2b0066..3f0328a80 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -51,6 +51,9 @@ Unreleased commands. :issue:`3107` :pr:`3228` - Add ``click.get_pager_file`` for file-like access to an output pager. :pr:`1572` +- ``click.prompt`` implementation changed slightly so that when the default + value is used, it does not do a round trip through the value processor or + the type conversion. :pr:`3407` Version 8.3.3 ------------- diff --git a/src/click/termui.py b/src/click/termui.py index a7d1dcd8e..4691763ce 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -150,6 +150,11 @@ def prompt( show_choices is true and text is "Group by" then the prompt will be "Group by (day, week): ". + .. versionchanged:: 8.4.0 + ``default`` no longer passes through the ``value_proc` callback, + nor the constructor of the ``type`` or the type of the ``default`` + field. + .. 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. From 4a15bfad0cb3d8401d86d2655b5e9f209e2cd9de Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Tue, 5 May 2026 17:23:02 +0200 Subject: [PATCH 4/6] Adhere to original accepted input and make mypy happy. --- src/click/termui.py | 36 ++++++++++++++++++++++++++++-------- src/click/types.py | 23 ++++++++++++++--------- tests/test_imports.py | 1 + 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index 4691763ce..a34950bf5 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 @@ -25,7 +26,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. @@ -83,9 +90,9 @@ def _build_prompt( text: str, suffix: str, show_default: bool | str = False, - default: V | None = None, + default: object | None = None, show_choices: bool = True, - type: ParamType[V] | V | None = None, + type: object | None = None, ) -> str: prompt = text if type is not None and show_choices and isinstance(type, Choice): @@ -109,13 +116,20 @@ 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) + + def prompt( text: str, - default: V | None = None, + default: V | C | str | None = None, hide_input: bool = False, confirmation_prompt: bool | str = False, - type: ParamType[V] | V | None = None, - value_proc: t.Callable[[str], V] | 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, @@ -202,13 +216,19 @@ def prompt_func(text: str) -> str: confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) while True: - result = None + result: V | None = None while True: - value = prompt_func(prompt) + value: C | str = prompt_func(prompt) if value: break elif default is not None: - result = 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 = default break if result is None: try: diff --git a/src/click/types.py b/src/click/types.py index 06318b429..bd7a97d49 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 @@ -26,7 +32,8 @@ from .core import Parameter from .shell_completion import CompletionItem -ParamTypeValue = t.TypeVar("ParamTypeValue") +ParamTypeValue = TypeVar("ParamTypeValue") +ParamTypeInputValue = TypeVar("ParamTypeInputValue", default=t.Any) class ParamTypeInfoDict(t.TypedDict): @@ -34,7 +41,7 @@ class ParamTypeInfoDict(t.TypedDict): name: str -class ParamType(t.Generic[ParamTypeValue], abc.ABC): +class ParamType(t.Generic[ParamTypeValue, ParamTypeInputValue], abc.ABC): """Represents the type of a parameter. Validates and converts values from the command line or Python into the correct type. @@ -93,21 +100,19 @@ def __call__( value: None, param: Parameter | None = None, ctx: Context | None = None, - ) -> None: - ... + ) -> None: ... @t.overload def __call__( self, - value: t.Any, + value: ParamTypeInputValue, param: Parameter | None = None, ctx: Context | None = None, - ) -> ParamTypeValue: - ... + ) -> ParamTypeValue: ... def __call__( self, - value: t.Any, + value: ParamTypeInputValue | None, param: Parameter | None = None, ctx: Context | None = None, ) -> ParamTypeValue | None: @@ -126,7 +131,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: ParamTypeInputValue, param: Parameter | None, ctx: Context | None ) -> ParamTypeValue: """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 74b78642b..a2bb9c954 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", From 352db9b056e3249d70c73f3faa0b4a53ff7f81c3 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Tue, 5 May 2026 16:50:47 +0100 Subject: [PATCH 5/6] Make pyright happy. --- src/click/termui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index a34950bf5..2900d8267 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -228,11 +228,11 @@ def prompt_func(text: str) -> str: else: # It's not the expected type. Pass it through value_proc before # returning. - value = default + value = t.cast(C | str, default) # type: ignore break if result is None: try: - result = value_proc(value) + result = t.cast(V, value_proc(value)) except UsageError as e: if hide_input: echo(_("Error: The value you entered was invalid."), err=err) From be84142f7e8fe7ac27ac44ee62aa923a1242ce92 Mon Sep 17 00:00:00 2001 From: Andreas Backx Date: Fri, 8 May 2026 22:21:09 +0100 Subject: [PATCH 6/6] Added typing-extensions to py 3.13< and updates change entries. --- CHANGES.rst | 9 ++++++--- pyproject.toml | 1 + src/click/termui.py | 8 ++++---- tests/test_imports.py | 1 + uv.lock | 6 +++++- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3f0328a80..bf2494c70 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -51,9 +51,12 @@ Unreleased commands. :issue:`3107` :pr:`3228` - Add ``click.get_pager_file`` for file-like access to an output pager. :pr:`1572` -- ``click.prompt`` implementation changed slightly so that when the default - value is used, it does not do a round trip through the value processor or - the type conversion. :pr:`3407` +- ``click.prompt`` and ``ParamType`` fully generically typed with the latter + receiving a new optional ``ParamTypeInputValue`` generic type for the + expected input type that defaults to ``Any``. Additionally, + ``click.prompt`` implementation changed slightly so that when a default + value is the same type as the expected type, it does not do a round trip + through the value processor nor the type conversion. :pr:`3407` Version 8.3.3 ------------- diff --git a/pyproject.toml b/pyproject.toml index 5a0e37d04..92a24a116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "colorama; platform_system == 'Windows'", + "typing_extensions; python_version < '3.13'", ] [project.urls] diff --git a/src/click/termui.py b/src/click/termui.py index 2900d8267..9191b22f3 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -165,9 +165,9 @@ def prompt( prompt will be "Group by (day, week): ". .. versionchanged:: 8.4.0 - ``default`` no longer passes through the ``value_proc` callback, - nor the constructor of the ``type`` or the type of the ``default`` - field. + ``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 @@ -228,7 +228,7 @@ def prompt_func(text: str) -> str: else: # It's not the expected type. Pass it through value_proc before # returning. - value = t.cast(C | str, default) # type: ignore + value = t.cast(C | str, default) # type: ignore break if result is None: try: diff --git a/tests/test_imports.py b/tests/test_imports.py index a2bb9c954..6e8a4261f 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -51,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 278506184..b27fe37e5 100644 --- a/uv.lock +++ b/uv.lock @@ -177,6 +177,7 @@ version = "8.3.3" source = { editable = "." } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] [package.dev-dependencies] @@ -221,7 +222,10 @@ typing = [ ] [package.metadata] -requires-dist = [{ name = "colorama", marker = "sys_platform == 'win32'" }] +requires-dist = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] [package.metadata.requires-dev] dev = [