Repository: pallets/click (python)

Issue:
Allow customizing fail message for invalid choice in `click.types.Choice`
Move the fail art of `convert()` in the `Choice` class to a new method `get_invalid_choice_fail_message(self)` so in my subclass of `click.Choice` I can customize the way it shows the failing value for the choice

Some CLI option choices are multi-part meaning they are separated by some value, like a comma separated list for example. Maybe each section of this value represents a different part of a schema and it is nice to show a custom message about the specific part.

Contrived version:
Let says my choices are `["maine::goat", "maine::themepark"]` and I give it "maine::toast"`, it'd be nice to say the `maine` part was correct and the `toast` part was not.




Pull request:
feat: new method for choice fail
Refactors so the failing happens in a new public method.

- fixes #2621
- 
Ensure each step in CONTRIBUTING.rst is complete by adding an "x" to
each box below.

If only docs were changed, these aren't relevant and can be removed.
-->

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 495eb2a..387f84f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -54,6 +54,8 @@ Unreleased
 -   When using ``Option.envvar`` with ``Option.flag_value``, the ``flag_value``
     will always be used instead of the value of the environment variable.
     :issue:`2746` :pr:`2788`
+-   Add ``Choice.get_invalid_choice_message`` method for customizing the
+    invalid choice message. :issue:`2621` :pr:`2622`
 
 
 Version 8.1.8
diff --git a/docs/api.rst b/docs/api.rst
index 09efd03..7c070e8 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -134,6 +134,7 @@ Types
 .. autoclass:: Path
 
 .. autoclass:: Choice
+   :members:
 
 .. autoclass:: IntRange
 
diff --git a/src/click/types.py b/src/click/types.py
index 2195f3a..354c7e3 100644
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -304,16 +304,21 @@ class Choice(ParamType):
         if normed_value in normed_choices:
             return normed_choices[normed_value]
 
+        self.fail(self.get_invalid_choice_message(value), param, ctx)
+
+    def get_invalid_choice_message(self, value: t.Any) -> str:
+        """Get the error message when the given choice is invalid.
+
+        :param value: The invalid value.
+
+        .. versionadded:: 8.2
+        """
         choices_str = ", ".join(map(repr, self.choices))
-        self.fail(
-            ngettext(
-                "{value!r} is not {choice}.",
-                "{value!r} is not one of {choices}.",
-                len(self.choices),
-            ).format(value=value, choice=choices_str, choices=choices_str),
-            param,
-            ctx,
-        )
+        return ngettext(
+            "{value!r} is not {choice}.",
+            "{value!r} is not one of {choices}.",
+            len(self.choices),
+        ).format(value=value, choice=choices_str, choices=choices_str)
 
     def __repr__(self) -> str:
         return f"Choice({list(self.choices)})"


Test patch:
diff --git a/tests/test_types.py b/tests/test_types.py
index 79068e1..667953a 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -244,3 +244,9 @@ def test_invalid_path_with_esc_sequence():
             click.Path(dir_okay=False).convert(tempdir, None, None)
 
     assert "my\\ndir" in exc_info.value.message
+
+
+def test_choice_get_invalid_choice_message():
+    choice = click.Choice(["a", "b", "c"])
+    message = choice.get_invalid_choice_message("d")
+    assert message == "'d' is not one of 'a', 'b', 'c'."