Skip to content

Commit ead6094

Browse files
Update cmd2.Cmd.select to use prompt-toolkit choice (#1600)
* 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. Co-authored-by: Kevin Van Brunt <kmvanbrunt@gmail.com>
1 parent e5d2de7 commit ead6094

File tree

5 files changed

+129
-11
lines changed

5 files changed

+129
-11
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ prompt is displayed.
7878
- New settables:
7979
- **max_column_completion_results**: (int) the maximum number of completion results to
8080
display in a single column
81+
- `cmd2.Cmd.select` has been revamped to use the
82+
[choice](https://python-prompt-toolkit.readthedocs.io/en/3.0.52/pages/asking_for_a_choice.html)
83+
function from `prompt-toolkit` when both **stdin** and **stdout** are TTYs
8184

8285
## 3.4.0 (March 3, 2026)
8386

cmd2/cmd2.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
filters,
7474
print_formatted_text,
7575
)
76-
from prompt_toolkit.application import get_app
76+
from prompt_toolkit.application import create_app_session, get_app
7777
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
7878
from prompt_toolkit.completion import Completer, DummyCompleter
7979
from prompt_toolkit.formatted_text import ANSI, FormattedText
@@ -82,7 +82,7 @@
8282
from prompt_toolkit.key_binding import KeyBindings
8383
from prompt_toolkit.output import DummyOutput, create_output
8484
from prompt_toolkit.patch_stdout import patch_stdout
85-
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, set_title
85+
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
8686
from rich.console import (
8787
Group,
8888
RenderableType,
@@ -4370,7 +4370,7 @@ def do_quit(self, _: argparse.Namespace) -> bool | None:
43704370
return True
43714371

43724372
def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], prompt: str = 'Your choice? ') -> Any:
4373-
"""Present a numbered menu to the user.
4373+
"""Present a menu to the user.
43744374
43754375
Modeled after the bash shell's SELECT. Returns the item chosen.
43764376
@@ -4387,15 +4387,30 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
43874387
local_opts = cast(list[tuple[Any, str | None]], list(zip(opts.split(), opts.split(), strict=False)))
43884388
else:
43894389
local_opts = opts
4390-
fulloptions: list[tuple[Any, str | None]] = []
4390+
fulloptions: list[tuple[Any, str]] = []
43914391
for opt in local_opts:
43924392
if isinstance(opt, str):
43934393
fulloptions.append((opt, opt))
43944394
else:
43954395
try:
4396-
fulloptions.append((opt[0], opt[1]))
4397-
except IndexError:
4398-
fulloptions.append((opt[0], opt[0]))
4396+
val = opt[0]
4397+
text = str(opt[1]) if len(opt) > 1 and opt[1] is not None else str(val)
4398+
fulloptions.append((val, text))
4399+
except (IndexError, TypeError):
4400+
fulloptions.append((opt[0], str(opt[0])))
4401+
4402+
if self._is_tty_session(self.main_session):
4403+
try:
4404+
while True:
4405+
with create_app_session(input=self.main_session.input, output=self.main_session.output):
4406+
result = choice(message=prompt, options=fulloptions)
4407+
if result is not None:
4408+
return result
4409+
except KeyboardInterrupt:
4410+
self.poutput('^C')
4411+
raise
4412+
4413+
# Non-interactive fallback
43994414
for idx, (_, text) in enumerate(fulloptions):
44004415
self.poutput(' %2d. %s' % (idx + 1, text)) # noqa: UP031
44014416

@@ -4413,10 +4428,10 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
44134428
continue
44144429

44154430
try:
4416-
choice = int(response)
4417-
if choice < 1:
4431+
choice_idx = int(response)
4432+
if choice_idx < 1:
44184433
raise IndexError # noqa: TRY301
4419-
return fulloptions[choice - 1][0]
4434+
return fulloptions[choice_idx - 1][0]
44204435
except (ValueError, IndexError):
44214436
self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:")
44224437

docs/features/misc.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ Sauce? 2
3434
wheaties with salty sauce, yum!
3535
```
3636

37+
See the `do_eat` method in the
38+
[read_input.py](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) file for a
39+
example of how to use `select.
40+
3741
## Disabling Commands
3842

3943
`cmd2` supports disabling commands during runtime. This is useful if certain commands should only be

examples/read_input.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/usr/bin/env python
2-
"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion."""
2+
"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion.
3+
4+
It also demonstrates how to use the cmd2.Cmd.select method.
5+
"""
36

47
import contextlib
58

@@ -94,6 +97,16 @@ def do_custom_parser(self, _) -> None:
9497
else:
9598
self.custom_history.append(input_str)
9699

100+
def do_eat(self, arg):
101+
"""Example of using the select method for reading multiple choice input.
102+
103+
Usage: eat wheatties
104+
"""
105+
sauce = self.select('sweet salty', 'Sauce? ')
106+
result = '{food} with {sauce} sauce, yum!'
107+
result = result.format(food=arg, sauce=sauce)
108+
self.stdout.write(result + '\n')
109+
97110

98111
if __name__ == '__main__':
99112
import sys

tests/test_cmd2.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,6 +1745,89 @@ def test_select_ctrl_c(outsim_app, monkeypatch) -> None:
17451745
assert out.rstrip().endswith('^C')
17461746

17471747

1748+
def test_select_choice_tty(outsim_app, monkeypatch) -> None:
1749+
# Mock choice to return the first option
1750+
choice_mock = mock.MagicMock(name='choice', return_value='sweet')
1751+
monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)
1752+
1753+
prompt = 'Sauce? '
1754+
options = ['sweet', 'salty']
1755+
1756+
with create_pipe_input() as pipe_input:
1757+
outsim_app.main_session = PromptSession(
1758+
input=pipe_input,
1759+
output=DummyOutput(),
1760+
)
1761+
1762+
result = outsim_app.select(options, prompt)
1763+
1764+
assert result == 'sweet'
1765+
choice_mock.assert_called_once_with(message=prompt, options=[('sweet', 'sweet'), ('salty', 'salty')])
1766+
1767+
1768+
def test_select_choice_tty_ctrl_c(outsim_app, monkeypatch) -> None:
1769+
# Mock choice to raise KeyboardInterrupt
1770+
choice_mock = mock.MagicMock(name='choice', side_effect=KeyboardInterrupt)
1771+
monkeypatch.setattr("cmd2.cmd2.choice", choice_mock)
1772+
1773+
prompt = 'Sauce? '
1774+
options = ['sweet', 'salty']
1775+
1776+
# Mock isatty to be True for both stdin and stdout
1777+
with create_pipe_input() as pipe_input:
1778+
outsim_app.main_session = PromptSession(
1779+
input=pipe_input,
1780+
output=DummyOutput(),
1781+
)
1782+
1783+
with pytest.raises(KeyboardInterrupt):
1784+
outsim_app.select(options, prompt)
1785+
1786+
out = outsim_app.stdout.getvalue()
1787+
assert out.rstrip().endswith('^C')
1788+
1789+
1790+
def test_select_uneven_tuples_labels(outsim_app, monkeypatch) -> None:
1791+
# Test that uneven tuples still work and labels are handled correctly
1792+
# Case 1: (value, label) - normal
1793+
# Case 2: (value,) - label should be value
1794+
# Case 3: (value, None) - label should be value
1795+
options = [('v1', 'l1'), ('v2',), ('v3', None)]
1796+
1797+
# Mock read_input to return '1'
1798+
read_input_mock = mock.MagicMock(name='read_input', return_value='1')
1799+
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
1800+
1801+
result = outsim_app.select(options, 'Choice? ')
1802+
assert result == 'v1'
1803+
1804+
out = outsim_app.stdout.getvalue()
1805+
assert '1. l1' in out
1806+
assert '2. v2' in out
1807+
assert '3. v3' in out
1808+
1809+
1810+
def test_select_indexable_no_len(outsim_app, monkeypatch) -> None:
1811+
# Test that an object with __getitem__ but no __len__ works.
1812+
# This covers the except (IndexError, TypeError) block in select()
1813+
class IndexableNoLen:
1814+
def __getitem__(self, item: int) -> str:
1815+
if item == 0:
1816+
return 'value'
1817+
raise IndexError
1818+
1819+
# Mock read_input to return '1'
1820+
read_input_mock = mock.MagicMock(name='read_input', return_value='1')
1821+
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
1822+
1823+
options = [IndexableNoLen()]
1824+
result = outsim_app.select(options, 'Choice? ')
1825+
assert result == 'value'
1826+
1827+
out = outsim_app.stdout.getvalue()
1828+
assert '1. value' in out
1829+
1830+
17481831
class HelpNoDocstringApp(cmd2.Cmd):
17491832
greet_parser = cmd2.Cmd2ArgumentParser()
17501833
greet_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE")

0 commit comments

Comments
 (0)