From a09c1c655eef3d43181eb46036b66cf058d72df4 Mon Sep 17 00:00:00 2001 From: doiken Date: Wed, 11 Mar 2026 01:28:21 +0900 Subject: [PATCH 1/3] 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 77f4b4b4b..1b8ae19b3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -53,6 +53,10 @@ Unreleased fail. :issue:`3105` :pr:`3211` - Add ``click.get_pager_file`` for file-like access to an output pager. :pr:`1572` +- Show custom error messages from types when :func:`prompt` with + ``hide_input=True`` fails validation, instead of always showing a + generic message. Built-in type messages mask the input value. + :issue:`2809` :pr:`3256` Version 8.3.3 ------------- diff --git a/src/click/termui.py b/src/click/termui.py index 08d732895..f81d1d7c8 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -203,7 +203,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 7aa260084..1fb293f6e 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -988,3 +988,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 From efd4daf3e3d9a5455d8045a16b1c73a9ef331104 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 30 Apr 2026 10:00:36 +0200 Subject: [PATCH 2/3] Demonstrate limits of substring matching --- tests/test_termui.py | 73 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/tests/test_termui.py b/tests/test_termui.py index 1fb293f6e..14bc40bc6 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1008,30 +1008,97 @@ def convert(self, value, param, ctx): return value +class _PasswordLengthType(click.ParamType): + """Mirrors the issue's original use case: a password validator + that references the user-typed value in its error message without + quoting it. + """ + + name = "password" + + def convert(self, value, param, ctx): + if len(value) < 10: + self.fail(f"{value} is too short", param, ctx) + return value + + +class _MixedQuotedAndRawType(click.ParamType): + """Custom type that mentions the user input both quoted (built-in + pattern) and raw within the same message. + """ + + name = "mixed" + + def convert(self, value, param, ctx): + self.fail(f"got {value!r} which is the same as {value}", param, ctx) + + +class _StaticMessageType(click.ParamType): + """Custom type whose error message never references the value.""" + + name = "static" + + def convert(self, value, param, ctx): + self.fail("Authentication failed for this account", param, ctx) + + @pytest.mark.parametrize( - ("type", "expected_fragment", "unexpected_fragment"), + ("type", "user_input", "expected_fragment", "unexpected_fragment"), [ pytest.param( click.INT, + "bad", "'***' is not a valid integer", "bad", id="builtin-int-masks-repr-value", ), pytest.param( _CustomTypeNoValue(), + "bad", "Password must be at least 4 characters", None, id="custom-no-value-shows-message", ), pytest.param( _CustomTypeWithRawValue(), + "bad", "The value you entered was invalid", "bad", id="custom-raw-value-falls-back-to-generic", ), + pytest.param( + _PasswordLengthType(), + "PASSWORD", + "'***' is too short", + "PASSWORD", + id="unquoted-custom-message-should-mask-not-fallback", + ), + pytest.param( + _MixedQuotedAndRawType(), + "leakybits", + "Error:", + "leakybits", + id="repr-branch-leaves-raw-occurrence-visible", + ), + pytest.param( + click.IntRange(min=10, max=99), + "1", + "is not in the range", + None, + id="intrange-numeric-substring-falls-back-to-generic", + ), + pytest.param( + _StaticMessageType(), + "ent", + "Authentication failed for this account", + None, + id="partial-word-match-falls-back-to-generic", + ), ], ) -def test_hide_input_error_message(runner, type, expected_fragment, unexpected_fragment): +def test_hide_input_error_message( + runner, type, user_input, expected_fragment, unexpected_fragment +): """https://github.com/pallets/click/issues/2809""" @click.command() @@ -1039,7 +1106,7 @@ def test_hide_input_error_message(runner, type, expected_fragment, unexpected_fr def cli(password): click.echo(password) - result = runner.invoke(cli, input="bad") + result = runner.invoke(cli, input=user_input) assert expected_fragment in result.output if unexpected_fragment is not None: assert unexpected_fragment not in result.output From 61bdc2ae81f6d0dc19bd247504544659245df056 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 8 May 2026 16:39:06 +0200 Subject: [PATCH 3/3] Implement a regex-based solution to hide hidden input --- src/click/termui.py | 41 +++++++------ tests/test_termui.py | 142 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/click/termui.py b/src/click/termui.py index f81d1d7c8..20bef05c9 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -4,6 +4,7 @@ import inspect import io import itertools +import re import sys import typing as t from contextlib import AbstractContextManager @@ -53,6 +54,27 @@ _ansi_reset_all = "\033[0m" +_HIDDEN_INPUT_MASK = "'***'" + + +def _mask_hidden_input(message: str, value: str) -> str: + """Replace occurrences of ``value`` in ``message`` with a fixed mask. + + Both ``repr(value)`` (the form built-in :class:`ParamType` errors use + via ``{value!r}``) and the raw value are masked. The raw-value pass + uses word-boundary lookarounds so a substring like ``"1"`` does not + match inside ``"10"``, and ``"ent"`` does not match inside + ``"Authentication"``. The empty string is skipped to avoid matching + at every boundary. + """ + message = message.replace(repr(value), _HIDDEN_INPUT_MASK) + if value: + message = re.sub( + rf"(? str: import getpass @@ -202,23 +224,8 @@ def prompt_func(text: str) -> str: try: result = value_proc(value) except UsageError as e: - if hide_input: - 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) + 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 diff --git a/tests/test_termui.py b/tests/test_termui.py index 14bc40bc6..6f99700fb 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1042,6 +1042,44 @@ def convert(self, value, param, ctx): self.fail("Authentication failed for this account", param, ctx) +class _RejectAllRawType(click.ParamType): + """Always rejects, with the raw value (unquoted) in the message.""" + + name = "reject_all_raw" + + def convert(self, value, param, ctx): + self.fail(f"rejected: {value}", param, ctx) + + +class _MultiRawType(click.ParamType): + """Mentions the raw value multiple times in the same message.""" + + name = "multi_raw" + + def convert(self, value, param, ctx): + self.fail(f"got {value} but {value} is bad", param, ctx) + + +class _MultiReprType(click.ParamType): + """Mentions ``repr(value)`` multiple times in the same message.""" + + name = "multi_repr" + + def convert(self, value, param, ctx): + self.fail(f"got {value!r} and {value!r}", param, ctx) + + +class _ApostropheReprType(click.ParamType): + """Custom type whose ``repr(value)`` switches to double quotes when + the value itself contains a single quote. + """ + + name = "apostrophe_repr" + + def convert(self, value, param, ctx): + self.fail(f"rejected {value!r}", param, ctx) + + @pytest.mark.parametrize( ("type", "user_input", "expected_fragment", "unexpected_fragment"), [ @@ -1062,9 +1100,9 @@ def convert(self, value, param, ctx): pytest.param( _CustomTypeWithRawValue(), "bad", - "The value you entered was invalid", + "rejected: '***'", "bad", - id="custom-raw-value-falls-back-to-generic", + id="custom-raw-value-masked", ), pytest.param( _PasswordLengthType(), @@ -1076,9 +1114,9 @@ def convert(self, value, param, ctx): pytest.param( _MixedQuotedAndRawType(), "leakybits", - "Error:", + "got '***' which is the same as '***'", "leakybits", - id="repr-branch-leaves-raw-occurrence-visible", + id="mixed-quoted-and-raw-both-masked-at-source", ), pytest.param( click.IntRange(min=10, max=99), @@ -1094,6 +1132,60 @@ def convert(self, value, param, ctx): None, id="partial-word-match-falls-back-to-generic", ), + # When the raw (unquoted) value appears in the message, mask it instead + # of replacing the whole message with a generic fallback that throws + # useful information away. + pytest.param( + _RejectAllRawType(), + "secret", + "rejected: '***'", + "secret", + id="raw-value-should-be-masked-not-fallback", + ), + # When the raw value occurs more than + # once unquoted, every occurrence must be masked. + pytest.param( + _MultiRawType(), + "secret", + "got '***' but '***' is bad", + "secret", + id="multi-occurrence-raw-mask-all", + ), + pytest.param( + _MultiReprType(), + "secret", + "got '***' and '***'", + "secret", + id="multi-occurrence-repr-mask-all", + ), + pytest.param( + _PasswordLengthType(), + "a.b*c+", + "'***' is too short", + "a.b*c+", + id="regex-special-chars-must-be-escaped", + ), + pytest.param( + _PasswordLengthType(), + "пароль", + "'***' is too short", + "пароль", + id="unicode-value-masked", + ), + pytest.param( + _ApostropheReprType(), + "it's", + "rejected '***'", + "it's", + id="apostrophe-in-value-uses-double-quote-repr", + ), + pytest.param( + _MixedQuotedAndRawType(), + "leakybits", + "got '***' which is the same as '***'", + "leakybits", + id="mixed-quoted-and-raw-mask-both", + ), ], ) def test_hide_input_error_message( @@ -1110,3 +1202,45 @@ def cli(password): assert expected_fragment in result.output if unexpected_fragment is not None: assert unexpected_fragment not in result.output + + +def test_hide_input_confirmation_prompt_mismatch_unaffected(runner): + """The ``hide_input`` mask logic only applies to ``value_proc`` + failures. The separate ``confirmation_prompt`` mismatch path must + keep emitting its own message, with no value leak from either entry. + """ + + @click.command() + @click.option("--password", prompt=True, confirmation_prompt=True, hide_input=True) + def cli(password): + click.echo(f"got: {password}") + + # First pair mismatches, second pair matches. + result = runner.invoke(cli, input="firstone\nsecondone\nfinalone\nfinalone\n") + assert "Error: The two entered values do not match." in result.output + assert "firstone" not in result.output + assert "secondone" not in result.output + # Successful prompt echoes the final value back via the command body. + assert "got: finalone" in result.output + assert result.exit_code == 0 + + +def test_hide_input_value_never_leaks_when_err_true(runner): + """``click.prompt(..., err=True)`` routes its error message to + stderr. The masking logic must apply on that path too: the raw + input must not appear on either stream. + """ + + @click.command() + def cli(): + value = click.prompt( + "Password", + hide_input=True, + type=_PasswordLengthType(), + err=True, + ) + click.echo(value) + + result = runner.invoke(cli, input="leaky\n", mix_stderr=False) + assert "leaky" not in result.stdout + assert "leaky" not in result.stderr