Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
32 changes: 23 additions & 9 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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():
Comment thread
tleonhardt marked this conversation as resolved.
Outdated
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

Expand All @@ -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)}:")

Expand Down
11 changes: 10 additions & 1 deletion examples/remove_settable.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,6 +12,12 @@ def __init__(self) -> None:
super().__init__()
self.remove_settable('debug')

def do_eat(self, arg):
Comment thread
tleonhardt marked this conversation as resolved.
Outdated
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
Expand Down
77 changes: 77 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,83 @@ 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)
Comment thread
tleonhardt marked this conversation as resolved.
Outdated
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


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")
Expand Down
Loading