From 149bcca1f2c524b636653c72719b8309fbef30a1 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 6 Mar 2026 21:42:54 -0500 Subject: [PATCH 1/5] Update cmd2.Cmd.select to use prompt-toolkit choice Key Changes: - prompt_toolkit.shortcuts.choice integration: The select method now utilizes the modern, interactive choice shortcut when both stdin and stdout are TTYs. This provides a more user-friendly selection menu (usually supports arrow keys and searching). - Backward Compatibility: Maintained the original numbered-list implementation as a fallback for non-TTY environments. This ensures that existing scripts, pipes, and tests (which mock read_input) continue to function correctly. - Robust Argument Handling: Standardized the conversion of various input formats (strings, lists of strings, lists of tuples) to the (value, label) format required by choice. - Error Handling: Wrapped the choice call in a loop and a try-except block to correctly handle KeyboardInterrupt (Ctrl-C) by printing ^C and re-raising, and to handle cancellations by reprompting, maintaining consistency with original select behavior. --- cmd2/cmd2.py | 32 +++++++++++++++++++++++--------- examples/remove_settable.py | 11 ++++++++++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 9bfafd347..bd2e11ab2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -82,7 +82,7 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.output import DummyOutput, create_output from prompt_toolkit.patch_stdout import patch_stdout -from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, set_title +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title from rich.console import ( Group, RenderableType, @@ -4368,7 +4368,7 @@ def do_quit(self, _: argparse.Namespace) -> bool | None: return True def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], prompt: str = 'Your choice? ') -> Any: - """Present a numbered menu to the user. + """Present a menu to the user. Modeled after the bash shell's SELECT. Returns the item chosen. @@ -4385,15 +4385,29 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p local_opts = cast(list[tuple[Any, str | None]], list(zip(opts.split(), opts.split(), strict=False))) else: local_opts = opts - fulloptions: list[tuple[Any, str | None]] = [] + fulloptions: list[tuple[Any, str]] = [] for opt in local_opts: if isinstance(opt, str): fulloptions.append((opt, opt)) else: try: - fulloptions.append((opt[0], opt[1])) - except IndexError: - fulloptions.append((opt[0], opt[0])) + val = opt[0] + text = str(opt[1]) if len(opt) > 1 and opt[1] is not None else str(val) + fulloptions.append((val, text)) + except (IndexError, TypeError): + fulloptions.append((opt[0], str(opt[0]))) + + if self.stdin.isatty() and self.stdout.isatty(): + try: + while True: + result = choice(message=prompt, options=fulloptions) + if result is not None: + return result + except KeyboardInterrupt: + self.poutput('^C') + raise + + # Non-interactive fallback for idx, (_, text) in enumerate(fulloptions): self.poutput(' %2d. %s' % (idx + 1, text)) # noqa: UP031 @@ -4411,10 +4425,10 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p continue try: - choice = int(response) - if choice < 1: + choice_idx = int(response) + if choice_idx < 1: raise IndexError # noqa: TRY301 - return fulloptions[choice - 1][0] + return fulloptions[choice_idx - 1][0] except (ValueError, IndexError): self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:") diff --git a/examples/remove_settable.py b/examples/remove_settable.py index c2c338890..2fcd99a18 100755 --- a/examples/remove_settable.py +++ b/examples/remove_settable.py @@ -1,5 +1,8 @@ #!/usr/bin/env python -"""A sample application for cmd2 demonstrating how to remove one of the built-in runtime settable parameters.""" +"""A sample application for cmd2 demonstrating how to remove one of the built-in runtime settable parameters. + +It also demonstrates how to use the cmd2.Cmd.select method. +""" import cmd2 @@ -9,6 +12,12 @@ def __init__(self) -> None: super().__init__() self.remove_settable('debug') + def do_eat(self, arg): + sauce = self.select('sweet salty', 'Sauce? ') + result = '{food} with {sauce} sauce, yum!' + result = result.format(food=arg, sauce=sauce) + self.stdout.write(result + '\n') + if __name__ == '__main__': import sys From de5f9a849136c89390855f6057427acf6cb8baf3 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 6 Mar 2026 21:56:16 -0500 Subject: [PATCH 2/5] Add unit tests --- tests/test_cmd2.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index ea43938e5..5d76216cb 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1745,6 +1745,62 @@ def test_select_ctrl_c(outsim_app, monkeypatch) -> None: assert out.rstrip().endswith('^C') +def test_select_choice_tty(outsim_app, monkeypatch) -> None: + # Mock choice to return the first option + choice_mock = mock.MagicMock(name='choice', return_value='sweet') + monkeypatch.setattr("cmd2.cmd2.choice", choice_mock) + + # Mock isatty to be True for both stdin and stdout + monkeypatch.setattr(outsim_app.stdin, "isatty", lambda: True) + monkeypatch.setattr(outsim_app.stdout, "isatty", lambda: True) + + prompt = 'Sauce? ' + options = ['sweet', 'salty'] + result = outsim_app.select(options, prompt) + + assert result == 'sweet' + choice_mock.assert_called_once_with(message=prompt, options=[('sweet', 'sweet'), ('salty', 'salty')]) + + +def test_select_choice_tty_ctrl_c(outsim_app, monkeypatch) -> None: + # Mock choice to raise KeyboardInterrupt + choice_mock = mock.MagicMock(name='choice', side_effect=KeyboardInterrupt) + monkeypatch.setattr("cmd2.cmd2.choice", choice_mock) + + # Mock isatty to be True for both stdin and stdout + monkeypatch.setattr(outsim_app.stdin, "isatty", lambda: True) + monkeypatch.setattr(outsim_app.stdout, "isatty", lambda: True) + + prompt = 'Sauce? ' + options = ['sweet', 'salty'] + + with pytest.raises(KeyboardInterrupt): + outsim_app.select(options, prompt) + + out = outsim_app.stdout.getvalue() + assert out.rstrip().endswith('^C') + + +def test_select_uneven_tuples_labels(outsim_app, monkeypatch) -> None: + # Test that uneven tuples still work and labels are handled correctly + # Case 1: (value, label) - normal + # Case 2: (value,) - label should be value + # Case 3: (value, None) - label should be value + options = [('v1', 'l1'), ('v2',), ('v3', None)] + + # Mock read_input to return '1' + read_input_mock = mock.MagicMock(name='read_input', return_value='1') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) + + result = outsim_app.select(options, 'Choice? ') + assert result == 'v1' + + out = outsim_app.stdout.getvalue() + assert '1. l1' in out + assert '2. v2' in out + assert '3. v3' in out + + class HelpNoDocstringApp(cmd2.Cmd): greet_parser = cmd2.Cmd2ArgumentParser() greet_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE") From c061b9580f115ce1643e9d451c74081502a2c4e4 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 6 Mar 2026 22:18:55 -0500 Subject: [PATCH 3/5] Add unit test for uncovered code --- tests/test_cmd2.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 5d76216cb..5ffb92bc2 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1801,6 +1801,27 @@ def test_select_uneven_tuples_labels(outsim_app, monkeypatch) -> None: assert '3. v3' in out +def test_select_indexable_no_len(outsim_app, monkeypatch) -> None: + # Test that an object with __getitem__ but no __len__ works. + # This covers the except (IndexError, TypeError) block in select() + class IndexableNoLen: + def __getitem__(self, item: int) -> str: + if item == 0: + return 'value' + raise IndexError + + # Mock read_input to return '1' + read_input_mock = mock.MagicMock(name='read_input', return_value='1') + monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) + + options = [IndexableNoLen()] + result = outsim_app.select(options, 'Choice? ') + assert result == 'value' + + out = outsim_app.stdout.getvalue() + assert '1. value' in out + + class HelpNoDocstringApp(cmd2.Cmd): greet_parser = cmd2.Cmd2ArgumentParser() greet_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE") From 589914edbaa7df301e55b17f2ba0966cea5240ee Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 6 Mar 2026 22:26:18 -0500 Subject: [PATCH 4/5] Updated CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfe70506..6d8fdaa5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ prompt is displayed. - New settables: - **max_column_completion_results**: (int) the maximum number of completion results to display in a single column + - `cmd2.Cmd.select` has been revamped to use the + [choice](https://python-prompt-toolkit.readthedocs.io/en/3.0.52/pages/asking_for_a_choice.html) + function from `prompt-toolkit` when both **stdin** and **stdout** are TTYs ## 3.4.0 (March 3, 2026) From 0551fa8434335e86126eb30ec142c24e4a2c3fa7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 7 Mar 2026 14:28:35 -0500 Subject: [PATCH 5/5] Addressed PR comments --- cmd2/cmd2.py | 7 ++++--- docs/features/misc.md | 4 ++++ examples/read_input.py | 15 ++++++++++++++- examples/remove_settable.py | 11 +---------- tests/test_cmd2.py | 30 ++++++++++++++++++------------ 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bea22494b..c95d4cb7f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -73,7 +73,7 @@ filters, print_formatted_text, ) -from prompt_toolkit.application import get_app +from prompt_toolkit.application import create_app_session, get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import Completer, DummyCompleter from prompt_toolkit.formatted_text import ANSI, FormattedText @@ -4399,10 +4399,11 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p except (IndexError, TypeError): fulloptions.append((opt[0], str(opt[0]))) - if self.stdin.isatty() and self.stdout.isatty(): + if self._is_tty_session(self.main_session): try: while True: - result = choice(message=prompt, options=fulloptions) + with create_app_session(input=self.main_session.input, output=self.main_session.output): + result = choice(message=prompt, options=fulloptions) if result is not None: return result except KeyboardInterrupt: diff --git a/docs/features/misc.md b/docs/features/misc.md index f358a5c57..7e5fa9628 100644 --- a/docs/features/misc.md +++ b/docs/features/misc.md @@ -34,6 +34,10 @@ Sauce? 2 wheaties with salty sauce, yum! ``` +See the `do_eat` method in the +[read_input.py](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) file for a +example of how to use `select. + ## Disabling Commands `cmd2` supports disabling commands during runtime. This is useful if certain commands should only be diff --git a/examples/read_input.py b/examples/read_input.py index 24286110f..7c5347490 100755 --- a/examples/read_input.py +++ b/examples/read_input.py @@ -1,5 +1,8 @@ #!/usr/bin/env python -"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion.""" +"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion. + +It also demonstrates how to use the cmd2.Cmd.select method. +""" import contextlib @@ -94,6 +97,16 @@ def do_custom_parser(self, _) -> None: else: self.custom_history.append(input_str) + def do_eat(self, arg): + """Example of using the select method for reading multiple choice input. + + Usage: eat wheatties + """ + sauce = self.select('sweet salty', 'Sauce? ') + result = '{food} with {sauce} sauce, yum!' + result = result.format(food=arg, sauce=sauce) + self.stdout.write(result + '\n') + if __name__ == '__main__': import sys diff --git a/examples/remove_settable.py b/examples/remove_settable.py index 2fcd99a18..c2c338890 100755 --- a/examples/remove_settable.py +++ b/examples/remove_settable.py @@ -1,8 +1,5 @@ #!/usr/bin/env python -"""A sample application for cmd2 demonstrating how to remove one of the built-in runtime settable parameters. - -It also demonstrates how to use the cmd2.Cmd.select method. -""" +"""A sample application for cmd2 demonstrating how to remove one of the built-in runtime settable parameters.""" import cmd2 @@ -12,12 +9,6 @@ def __init__(self) -> None: super().__init__() self.remove_settable('debug') - def do_eat(self, arg): - sauce = self.select('sweet salty', 'Sauce? ') - result = '{food} with {sauce} sauce, yum!' - result = result.format(food=arg, sauce=sauce) - self.stdout.write(result + '\n') - if __name__ == '__main__': import sys diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d2afa2458..01a3bef1c 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1750,13 +1750,16 @@ def test_select_choice_tty(outsim_app, monkeypatch) -> None: choice_mock = mock.MagicMock(name='choice', return_value='sweet') monkeypatch.setattr("cmd2.cmd2.choice", choice_mock) - # Mock isatty to be True for both stdin and stdout - monkeypatch.setattr(outsim_app.stdin, "isatty", lambda: True) - monkeypatch.setattr(outsim_app.stdout, "isatty", lambda: True) - prompt = 'Sauce? ' options = ['sweet', 'salty'] - result = outsim_app.select(options, prompt) + + with create_pipe_input() as pipe_input: + outsim_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + ) + + result = outsim_app.select(options, prompt) assert result == 'sweet' choice_mock.assert_called_once_with(message=prompt, options=[('sweet', 'sweet'), ('salty', 'salty')]) @@ -1767,17 +1770,20 @@ def test_select_choice_tty_ctrl_c(outsim_app, monkeypatch) -> None: choice_mock = mock.MagicMock(name='choice', side_effect=KeyboardInterrupt) monkeypatch.setattr("cmd2.cmd2.choice", choice_mock) - # Mock isatty to be True for both stdin and stdout - monkeypatch.setattr(outsim_app.stdin, "isatty", lambda: True) - monkeypatch.setattr(outsim_app.stdout, "isatty", lambda: True) - prompt = 'Sauce? ' options = ['sweet', 'salty'] - with pytest.raises(KeyboardInterrupt): - outsim_app.select(options, prompt) + # Mock isatty to be True for both stdin and stdout + with create_pipe_input() as pipe_input: + outsim_app.main_session = PromptSession( + input=pipe_input, + output=DummyOutput(), + ) - out = outsim_app.stdout.getvalue() + with pytest.raises(KeyboardInterrupt): + outsim_app.select(options, prompt) + + out = outsim_app.stdout.getvalue() assert out.rstrip().endswith('^C')