From 6d4f7b4aad4ef88bfb14aa6de308cbcd8c0ac65c Mon Sep 17 00:00:00 2001 From: doiken Date: Wed, 11 Mar 2026 01:28:21 +0900 Subject: [PATCH] fix: show custom error message in prompt with hide_input=True --- CHANGES.rst | 4 ++++ src/click/termui.py | 15 +++++++++++- tests/test_termui.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2e29b245d4..80b25f5e6a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,10 @@ Unreleased +- Show custom error messages from types when ``prompt`` with + ``hide_input=True`` fails validation, instead of always showing a + generic message. Built-in type messages mask the input value. + :issue:`2809` - Fix handling of ``flag_value`` when ``is_flag=False`` to allow such options to be used without an explicit value. :issue:`3084` - Hide ``Sentinel.UNSET`` values as ``None`` when using ``lookup_default()``. diff --git a/src/click/termui.py b/src/click/termui.py index 2e98a0771c..94a146d6e5 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -178,7 +178,20 @@ def prompt_func(text: str) -> str: result = value_proc(value) except UsageError as e: if hide_input: - echo(_("Error: The value you entered was invalid."), err=err) + repr_val = repr(value) + if repr_val in e.message: + # Built-in type pattern: mask the repr'd value. + msg = e.message.replace(repr_val, "'***'") + elif value in e.message: + # Raw value found: could be a coincidental or + # unquoted match. Ambiguous, use generic. + msg = _("The value you entered was invalid.") + else: + # Value not found: show as-is, assuming custom + # types with hide_input=True avoid leaking input. + msg = e.message + + echo(_("Error: {msg}").format(msg=msg), err=err) else: echo(_("Error: {e.message}").format(e=e), err=err) continue diff --git a/tests/test_termui.py b/tests/test_termui.py index 8220431bb4..d5a9836335 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -710,3 +710,58 @@ def cli(flag): assert result.output == expected_output assert not result.stderr assert result.exit_code == 0 if expected not in (REPEAT, INVALID) else 1 + + +class _CustomTypeNoValue(click.ParamType): + name = "custom" + + def convert(self, value, param, ctx): + if len(value) < 4: + self.fail("Password must be at least 4 characters", param, ctx) + return value + + +class _CustomTypeWithRawValue(click.ParamType): + name = "custom_raw" + + def convert(self, value, param, ctx): + if value == "bad": + self.fail(f"rejected: {value}", param, ctx) + return value + + +@pytest.mark.parametrize( + ("type", "expected_fragment", "unexpected_fragment"), + [ + pytest.param( + click.INT, + "'***' is not a valid integer", + "bad", + id="builtin-int-masks-repr-value", + ), + pytest.param( + _CustomTypeNoValue(), + "Password must be at least 4 characters", + None, + id="custom-no-value-shows-message", + ), + pytest.param( + _CustomTypeWithRawValue(), + "The value you entered was invalid", + "bad", + id="custom-raw-value-falls-back-to-generic", + ), + ], +) +def test_hide_input_error_message(runner, type, expected_fragment, unexpected_fragment): + """https://github.com/pallets/click/issues/2809""" + + @click.command() + @click.option("--password", prompt=True, hide_input=True, type=type) + def cli(password): + click.echo(password) + + result = runner.invoke(cli, input="bad") + assert expected_fragment in result.output + if unexpected_fragment is not None: + assert unexpected_fragment not in result.output