Repository: Textualize/rich
Pull request #3052 (2023-07-23 17:29:13)

Case insensitive prompt choices
## Type of changes

- [ ] Bug fix
- [x] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Optional flag for `Prompt.ask` allowing case-insensitive responses. Just add `case_sensitive=False` to the command options:

```python
from rich.prompt import Prompt
name = Prompt.ask("Enter your name", choices=["Paul", "Jessica", "Duncan"], default="Paul", case_sensitive=False)
```

Now, it would accept "paul" or "Paul" as valid responses. Defaults to True, which is the usual result.





Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3562f6df..1c25b6ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 
 - Adds missing parameters to Panel.fit https://github.com/Textualize/rich/issues/3142
+- Adds a `case_sensitive` parameter to `prompt.Prompt`. This determines if the
+  response is treated as case-sensitive. Defaults to `True`.
 
 ### Fixed
 
@@ -47,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed Text.expand_tabs not expanding spans.
 - Fixed TimeElapsedColumn from showing negative.
 - Fix for escaping strings with a trailing backslash https://github.com/Textualize/rich/issues/2987
-- Fixed exception in Markdown with partial table https://github.com/Textualize/rich/issues/3053 
+- Fixed exception in Markdown with partial table https://github.com/Textualize/rich/issues/3053
 - Fixed the HTML export template so that the `<html>` tag comes before the `<head>` tag https://github.com/Textualize/rich/issues/3021
 - Fixed issue with custom classes overwriting `__eq__` https://github.com/Textualize/rich/issues/2875
 - Fix rich.pretty.install breakage in iPython https://github.com/Textualize/rich/issues/3013
@@ -61,7 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Text.tab_size now defaults to `None` to indicate that Console.tab_size should be used.
 
-
 ## [13.4.2] - 2023-06-12
 
 ### Changed
@@ -2021,10 +2022,10 @@ Major version bump for a breaking change to `Text.stylize signature`, which corr
 [13.5.3]: https://github.com/textualize/rich/compare/v13.5.2...v13.5.3
 [13.5.2]: https://github.com/textualize/rich/compare/v13.5.1...v13.5.2
 [13.5.1]: https://github.com/textualize/rich/compare/v13.5.0...v13.5.1
-[13.5.0]: https://github.com/textualize/rich/compare/v13.4.2...v13.5.0 
-[13.4.2]: https://github.com/textualize/rich/compare/v13.4.1...v13.4.2  
-[13.4.1]: https://github.com/textualize/rich/compare/v13.4.0...v13.4.1  
-[13.4.0]: https://github.com/textualize/rich/compare/v13.3.5...v13.4.0  
+[13.5.0]: https://github.com/textualize/rich/compare/v13.4.2...v13.5.0
+[13.4.2]: https://github.com/textualize/rich/compare/v13.4.1...v13.4.2
+[13.4.1]: https://github.com/textualize/rich/compare/v13.4.0...v13.4.1
+[13.4.0]: https://github.com/textualize/rich/compare/v13.3.5...v13.4.0
 [13.3.5]: https://github.com/textualize/rich/compare/v13.3.4...v13.3.5
 [13.3.4]: https://github.com/textualize/rich/compare/v13.3.3...v13.3.4
 [13.3.3]: https://github.com/textualize/rich/compare/v13.3.2...v13.3.3
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index ba8759ea..1cdc5c31 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -47,6 +47,7 @@ The following people have contributed to the development of Rich:
 - [Kylian Point](https://github.com/p0lux)
 - [Kyle Pollina](https://github.com/kylepollina)
 - [Sebastián Ramírez](https://github.com/tiangolo)
+- [Grant Ramsay](https://github.com/seapagan)
 - [Felipe Guedes](https://github.com/guedesfelipe)
 - [Min RK](https://github.com/minrk)
 - [Clément Robert](https://github.com/neutrinoceros)
diff --git a/docs/source/prompt.rst b/docs/source/prompt.rst
index 088aa8e7..b998cdfa 100644
--- a/docs/source/prompt.rst
+++ b/docs/source/prompt.rst
@@ -18,6 +18,13 @@ If you supply a list of choices, the prompt will loop until the user enters one
     >>> from rich.prompt import Prompt
     >>> name = Prompt.ask("Enter your name", choices=["Paul", "Jessica", "Duncan"], default="Paul")
 
+By default this is case sensitive, but you can set `case_sensitive=False` to make it case insensitive::
+
+    >>> from rich.prompt import Prompt
+    >>> name = Prompt.ask("Enter your name", choices=["Paul", "Jessica", "Duncan"], default="Paul", case_sensitive=False)
+
+Now, it would accept "paul" or "Paul" as valid responses.
+
 In addition to :class:`~rich.prompt.Prompt` which returns strings, you can also use :class:`~rich.prompt.IntPrompt` which asks the user for an integer, and :class:`~rich.prompt.FloatPrompt` for floats.
 
 The :class:`~rich.prompt.Confirm` class is a specialized prompt which may be used to ask the user a simple yes / no question. Here's an example::
@@ -30,4 +37,4 @@ The Prompt class was designed to be customizable via inheritance. See `prompt.py
 
 To see some of the prompts in action, run the following command from the command line::
 
-    python -m rich.prompt
\ No newline at end of file
+    python -m rich.prompt
diff --git a/rich/prompt.py b/rich/prompt.py
index 064c959b..761584ba 100644
--- a/rich/prompt.py
+++ b/rich/prompt.py
@@ -36,6 +36,7 @@ class PromptBase(Generic[PromptType]):
         console (Console, optional): A Console instance or None to use global console. Defaults to None.
         password (bool, optional): Enable password input. Defaults to False.
         choices (List[str], optional): A list of valid choices. Defaults to None.
+        case_sensitive (bool, optional): Matching of choices should be case-sensitive. Defaults to True.
         show_default (bool, optional): Show default in prompt. Defaults to True.
         show_choices (bool, optional): Show choices in prompt. Defaults to True.
     """
@@ -57,6 +58,7 @@ class PromptBase(Generic[PromptType]):
         console: Optional[Console] = None,
         password: bool = False,
         choices: Optional[List[str]] = None,
+        case_sensitive: bool = True,
         show_default: bool = True,
         show_choices: bool = True,
     ) -> None:
@@ -69,6 +71,7 @@ class PromptBase(Generic[PromptType]):
         self.password = password
         if choices is not None:
             self.choices = choices
+        self.case_sensitive = case_sensitive
         self.show_default = show_default
         self.show_choices = show_choices
 
@@ -81,6 +84,7 @@ class PromptBase(Generic[PromptType]):
         console: Optional[Console] = None,
         password: bool = False,
         choices: Optional[List[str]] = None,
+        case_sensitive: bool = True,
         show_default: bool = True,
         show_choices: bool = True,
         default: DefaultType,
@@ -97,6 +101,7 @@ class PromptBase(Generic[PromptType]):
         console: Optional[Console] = None,
         password: bool = False,
         choices: Optional[List[str]] = None,
+        case_sensitive: bool = True,
         show_default: bool = True,
         show_choices: bool = True,
         stream: Optional[TextIO] = None,
@@ -111,6 +116,7 @@ class PromptBase(Generic[PromptType]):
         console: Optional[Console] = None,
         password: bool = False,
         choices: Optional[List[str]] = None,
+        case_sensitive: bool = True,
         show_default: bool = True,
         show_choices: bool = True,
         default: Any = ...,
@@ -126,6 +132,7 @@ class PromptBase(Generic[PromptType]):
             console (Console, optional): A Console instance or None to use global console. Defaults to None.
             password (bool, optional): Enable password input. Defaults to False.
             choices (List[str], optional): A list of valid choices. Defaults to None.
+            case_sensitive (bool, optional): Matching of choices should be case-sensitive. Defaults to True.
             show_default (bool, optional): Show default in prompt. Defaults to True.
             show_choices (bool, optional): Show choices in prompt. Defaults to True.
             stream (TextIO, optional): Optional text file open for reading to get input. Defaults to None.
@@ -135,6 +142,7 @@ class PromptBase(Generic[PromptType]):
             console=console,
             password=password,
             choices=choices,
+            case_sensitive=case_sensitive,
             show_default=show_default,
             show_choices=show_choices,
         )
@@ -212,7 +220,9 @@ class PromptBase(Generic[PromptType]):
             bool: True if choice was valid, otherwise False.
         """
         assert self.choices is not None
-        return value.strip() in self.choices
+        if self.case_sensitive:
+            return value.strip() in self.choices
+        return value.strip().lower() in [choice.lower() for choice in self.choices]
 
     def process_response(self, value: str) -> PromptType:
         """Process response from user, convert to prompt type.
@@ -232,9 +242,17 @@ class PromptBase(Generic[PromptType]):
         except ValueError:
             raise InvalidResponse(self.validate_error_message)
 
-        if self.choices is not None and not self.check_choice(value):
-            raise InvalidResponse(self.illegal_choice_message)
-
+        if self.choices is not None:
+            if not self.check_choice(value):
+                raise InvalidResponse(self.illegal_choice_message)
+
+            if not self.case_sensitive:
+                # return the original choice, not the lower case version
+                return_value = self.response_type(
+                    self.choices[
+                        [choice.lower() for choice in self.choices].index(value.lower())
+                    ]
+                )
         return return_value
 
     def on_validate_error(self, value: str, error: InvalidResponse) -> None:
@@ -346,7 +364,6 @@ class Confirm(PromptBase[bool]):
 
 
 if __name__ == "__main__":  # pragma: no cover
-
     from rich import print
 
     if Confirm.ask("Run [i]prompt[/i] tests?", default=True):
@@ -372,5 +389,12 @@ if __name__ == "__main__":  # pragma: no cover
         fruit = Prompt.ask("Enter a fruit", choices=["apple", "orange", "pear"])
         print(f"fruit={fruit!r}")
 
+        doggie = Prompt.ask(
+            "What's the best Dog? (Case INSENSITIVE)",
+            choices=["Border Terrier", "Collie", "Labradoodle"],
+            case_sensitive=False,
+        )
+        print(f"doggie={doggie!r}")
+
     else:
         print("[b]OK :loudly_crying_face:")


Test patch:
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index 9a41cc39..11bffa71 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -1,7 +1,7 @@
 import io
 
 from rich.console import Console
-from rich.prompt import Prompt, IntPrompt, Confirm
+from rich.prompt import Confirm, IntPrompt, Prompt
 
 
 def test_prompt_str():
@@ -21,6 +21,24 @@ def test_prompt_str():
     assert output == expected
 
 
+def test_prompt_str_case_insensitive():
+    INPUT = "egg\nFoO"
+    console = Console(file=io.StringIO())
+    name = Prompt.ask(
+        "what is your name",
+        console=console,
+        choices=["foo", "bar"],
+        default="baz",
+        case_sensitive=False,
+        stream=io.StringIO(INPUT),
+    )
+    assert name == "foo"
+    expected = "what is your name [foo/bar] (baz): Please select one of the available options\nwhat is your name [foo/bar] (baz): "
+    output = console.file.getvalue()
+    print(repr(output))
+    assert output == expected
+
+
 def test_prompt_str_default():
     INPUT = ""
     console = Console(file=io.StringIO())