From 63d7865db22577165914d7085dd2198af8a5437b Mon Sep 17 00:00:00 2001 From: "Andreas P. Cuny" Date: Tue, 10 May 2022 14:21:15 +0200 Subject: [PATCH] Add select_choices --- cli_ui/__init__.py | 61 +++++++++++++++++++++++++++++++++++++ cli_ui/tests/test_cli_ui.py | 37 ++++++++++++++++++++++ docs/changelog.rst | 5 +++ docs/index.rst | 15 +++++++++ tbump.toml | 2 +- 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/cli_ui/__init__.py b/cli_ui/__init__.py index 69f921c..814c093 100644 --- a/cli_ui/__init__.py +++ b/cli_ui/__init__.py @@ -6,10 +6,12 @@ import inspect import io import os +import re import sys import time import traceback from typing import IO, Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from operator import itemgetter import colorama import tabulate @@ -537,6 +539,65 @@ def ask_choice( return res +def select_choices( + *prompt: Token, + choices: List[Any], + func_desc: Optional[FuncDesc] = None, + sort: Optional[bool] = True, +) -> Any: + """Ask the user to select one or multiple from a list of choices. Note: delimit your choices by space, comma or semi colon. + + Will loop until: + * the user enters a valid index + * or leaves the prompt empty + + In the last case, `None` will be returned + + :param prompt: a list of tokens suitable for :func:`info` + :param choices: a list of arbitrary elements + :param func_desc: a callable. It will be used to display and + sort the list of choices (unless ``sort`` is False) + Defaults to the identity function. + :param sort: whether to sort the list of choices. + + :return: the selected choice(s). + + """ + if func_desc is None: + func_desc = lambda x: str(x) + tokens = get_ask_tokens(prompt) + info(*tokens) + if sort: + choices.sort(key=func_desc) + for i, choice in enumerate(choices, start=1): + choice_desc = func_desc(choice) + info(" ", blue, "%i" % i, reset, choice_desc) + keep_asking = True + res = None + while keep_asking: + answer = read_input() + if not answer: + return None + try: + import re + + index = [int(item) for item in re.split(r'; |, |\s |;|,|\s',answer)] + index = [x-1 for x in index] # convert to true index + # index = int(answer) + except ValueError: + info("Please enter a valid number") + continue + + try: + res = itemgetter(*index)(choices) + # res = choices[index - 1] + keep_asking = False + except: + info("Please enter valid selection number(s)") + continue + + return res + def ask_yes_no(*question: Token, default: bool = False) -> bool: """Ask the user to answer by yes or no""" diff --git a/cli_ui/tests/test_cli_ui.py b/cli_ui/tests/test_cli_ui.py index 9ff3b3f..b1064ea 100644 --- a/cli_ui/tests/test_cli_ui.py +++ b/cli_ui/tests/test_cli_ui.py @@ -301,6 +301,7 @@ def func_desc(fruit: Fruit) -> str: fruits = [Fruit("apple", 42), Fruit("banana", 10), Fruit("orange", 12)] with mock.patch("builtins.input") as m: m.side_effect = ["nan", "5", "2"] + actual = cli_ui.ask_choice( "Select a fruit", choices=fruits, func_desc=operator.attrgetter("name") ) @@ -321,6 +322,42 @@ def test_ask_choice_ctrl_c() -> None: with mock.patch("builtins.input") as m: m.side_effect = KeyboardInterrupt cli_ui.ask_choice("Select a animal", choices=["cat", "dog", "cow"]) + + +def test_select_choices() -> None: + class Fruit: + def __init__(self, name: str, price: int): + self.name = name + self.price = price + + def func_desc(fruit: Fruit) -> str: + return fruit.name + + fruits = [Fruit("apple", 42), Fruit("banana", 10), Fruit("orange", 12)] + with mock.patch("builtins.input") as m: + m.side_effect = ["nan", "5", "1, 2"] + actual = cli_ui.select_choices( + "Select a fruit", choices=fruits, func_desc=operator.attrgetter("name") + ) + assert actual[0].name == "apple" + assert actual[0].price == 42 + assert actual[1].name == "banana" + assert actual[1].price == 10 + assert m.call_count == 3 + + +def test_select_choices_empty_input() -> None: + with mock.patch("builtins.input") as m: + m.side_effect = [""] + res = cli_ui.select_choices("Select a animal", choices=["cat", "dog", "cow"]) + assert res is None + + +def test_select_choices_ctrl_c() -> None: + with pytest.raises(KeyboardInterrupt): + with mock.patch("builtins.input") as m: + m.side_effect = KeyboardInterrupt + cli_ui.select_choices("Select a animal", choices=["cat", "dog", "cow"]) def test_quiet(message_recorder: MessageRecorder) -> None: diff --git a/docs/changelog.rst b/docs/changelog.rst index 7a47601..25f6adc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,11 @@ Changelog ---------- +v0.16.2 (2022-05-10) +++++++++++++++++++++ + +* Add multi selection method `cli_ui.select_choices` + v0.16.1 (2022-03-12) ++++++++++++++++++++ diff --git a/docs/index.rst b/docs/index.rst index 70bf14b..4bd5840 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -316,6 +316,21 @@ Asking for user input .. versionchanged:: 0.7 The :py:exc:`KeyboardInterrupt` exception is no longer caught by this function. + +.. autofunction:: select_choices + + + :: + + >>> choices = ["apple", "banana", "orange"] + >>> fruit = cli_ui.ask_choice("Select several fruits", choices=choices) + :: Select a fruit + 1 apple + 2 banana + 3 orange + <1,2> + >>> fruit + ('apple, 'banana') .. autofunction:: ask_yes_no diff --git a/tbump.toml b/tbump.toml index 0752e54..43aa497 100644 --- a/tbump.toml +++ b/tbump.toml @@ -1,7 +1,7 @@ github_url = "https://github.com/dmerejkowsky/python-cli-ui" [version] -current = "0.16.1" +current = "0.16.2" # Example of a semver regexp. # Make sure this matches current_version before