Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
35 changes: 25 additions & 10 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 @@ -4370,7 +4370,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 @@ -4387,15 +4387,30 @@ 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._is_tty_session(self.main_session):
try:
while True:
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:
self.poutput('^C')
raise

# Non-interactive fallback
for idx, (_, text) in enumerate(fulloptions):
self.poutput(' %2d. %s' % (idx + 1, text)) # noqa: UP031

Expand All @@ -4413,10 +4428,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
4 changes: 4 additions & 0 deletions docs/features/misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion examples/read_input.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,89 @@ 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)

prompt = 'Sauce? '
options = ['sweet', 'salty']

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')])


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)

prompt = 'Sauce? '
options = ['sweet', 'salty']

# 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(),
)

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