Repository: Kamik423/cutie Branch: main Commit: e9beac3435bc Files: 15 Total size: 67.9 KB Directory structure: gitextract_er58z1a9/ ├── .circleci/ │ └── config.yml ├── .gitignore ├── Makefile ├── cutie.py ├── example.py ├── license.md ├── readme.md ├── requirements.txt ├── setup.py └── test/ ├── __init__.py ├── test_get_number.py ├── test_prompt_yes_or_no.py ├── test_secure_input.py ├── test_select.py └── test_select_multiple.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2.1 orbs: python: circleci/python@2.1.1 workflows: build_and_test: jobs: - build_and_test jobs: build_and_test: executor: python/default steps: - checkout - python/install-packages: pkg-manager: pip - run: name: Run tests command: make tests - persist_to_workspace: root: ~/project paths: - . ================================================ FILE: .gitignore ================================================ ._* __pycache__/ *.pyc .mypy_cache/ htmlcov/ .coverage .DS_Store .Trashes build cutie.egg-info dist ================================================ FILE: Makefile ================================================ .PHONY: tests, coverage, lint, release tests: python -m unittest coverage: python -m coverage erase python -m coverage run --source=cutie -m unittest python -m coverage report python -m coverage html coveralls black: black *.py release: tests, black python setup.py sdist bdist_wheel twine upload dist/* ================================================ FILE: cutie.py ================================================ #! /usr/bin/env python3 """ Commandline User Tools for Input Easification """ __version__ = "0.3.2" __author__ = "Hans / Kamik423" __license__ = "MIT" import getpass from typing import List, Optional import readchar from colorama import init init() class DefaultKeys: """List of default keybindings. Attributes: interrupt(List[str]): Keys that cause a keyboard interrupt. select(List[str]): Keys that trigger list element selection. confirm(List[str]): Keys that trigger list confirmation. delete(List[str]): Keys that trigger character deletion. down(List[str]): Keys that select the element below. up(List[str]): Keys that select the element above. """ interrupt: List[str] = [readchar.key.CTRL_C, readchar.key.CTRL_D] select: List[str] = [readchar.key.SPACE] confirm: List[str] = [readchar.key.ENTER] delete: List[str] = [readchar.key.BACKSPACE] down: List[str] = [readchar.key.DOWN, "j"] up: List[str] = [readchar.key.UP, "k"] def get_number( prompt: str, min_value: Optional[float] = None, max_value: Optional[float] = None, allow_float: bool = True, ) -> float: """Get a number from user input. If an invalid number is entered the user will be prompted again. Args: prompt (str): The prompt asking the user to input. min_value (float, optional): The [inclusive] minimum value. max_value (float, optional): The [inclusive] maximum value. allow_float (bool, optional): Allow floats or force integers. Returns: float: The number input by the user. """ return_value: Optional[float] = None while return_value is None: input_value = input(prompt + " ") try: return_value = float(input_value) except ValueError: print("Not a valid number.\033[K\033[1A\r\033[K", end="") if not allow_float and return_value is not None: if return_value != int(return_value): print("Has to be an integer.\033[K\033[1A\r\033[K", end="") return_value = None if min_value is not None and return_value is not None: if return_value < min_value: print(f"Has to be at least {min_value}.\033[K\033[1A\r\033[K", end="") return_value = None if max_value is not None and return_value is not None: if return_value > max_value: print(f"Has to be at most {max_value}.\033[1A\r\033[K", end="") return_value = None if return_value is not None: break print("\033[K", end="") if allow_float: return return_value return int(return_value) def secure_input(prompt: str) -> str: """Get secure input without showing it in the command line. Args: prompt (str): The prompt asking the user to input. Returns: str: The secure input. """ return getpass.getpass(prompt + " ") def select( options: List[str], caption_indices: Optional[List[int]] = None, deselected_prefix: str = "\033[1m[ ]\033[0m ", selected_prefix: str = "\033[1m[\033[32;1mx\033[0;1m]\033[0m ", caption_prefix: str = "", selected_index: int = 0, confirm_on_select: bool = True, ) -> int: """Select an option from a list. Args: options (List[str]): The options to select from. caption_indices (List[int], optional): Non-selectable indices. deselected_prefix (str, optional): Prefix for deselected option ([ ]). selected_prefix (str, optional): Prefix for selected option ([x]). caption_prefix (str, optional): Prefix for captions (). selected_index (int, optional): The index to be selected at first. confirm_on_select (bool, optional): Select keys also confirm. Returns: int: The index that has been selected. """ print("\n" * (len(options) - 1)) if caption_indices is None: caption_indices = [] while True: print(f"\033[{len(options) + 1}A") for i, option in enumerate(options): if i not in caption_indices: print( "\033[K{}{}".format( selected_prefix if i == selected_index else deselected_prefix, option, ) ) elif i in caption_indices: print("\033[K{}{}".format(caption_prefix, options[i])) keypress = readchar.readkey() if keypress in DefaultKeys.up: new_index = selected_index while new_index > 0: new_index -= 1 if new_index not in caption_indices: selected_index = new_index break elif keypress in DefaultKeys.down: new_index = selected_index while new_index < len(options) - 1: new_index += 1 if new_index not in caption_indices: selected_index = new_index break elif ( keypress in DefaultKeys.confirm or confirm_on_select and keypress in DefaultKeys.select ): break elif keypress in DefaultKeys.interrupt: raise KeyboardInterrupt return selected_index def select_multiple( options: List[str], caption_indices: Optional[List[int]] = None, deselected_unticked_prefix: str = "\033[1m( )\033[0m ", deselected_ticked_prefix: str = "\033[1m(\033[32mx\033[0;1m)\033[0m ", selected_unticked_prefix: str = "\033[32;1m{ }\033[0m ", selected_ticked_prefix: str = "\033[32;1m{x}\033[0m ", caption_prefix: str = "", ticked_indices: Optional[List[int]] = None, cursor_index: int = 0, minimal_count: int = 0, maximal_count: Optional[int] = None, hide_confirm: bool = True, deselected_confirm_label: str = "\033[1m(( confirm ))\033[0m", selected_confirm_label: str = "\033[1;32m{{ confirm }}\033[0m", ) -> List[int]: """Select multiple options from a list. Args: options (List[str]): The options to select from. caption_indices (List[int], optional): Non-selectable indices. deselected_unticked_prefix (str, optional): Prefix for lines that are not selected and not ticked (( )). deselected_ticked_prefix (str, optional): Prefix for lines that are not selected but ticked ((x)). selected_unticked_prefix (str, optional): Prefix for lines that are selected but not ticked ({ }). selected_ticked_prefix (str, optional): Prefix for lines that are selected and ticked ({x}). caption_prefix (str, optional): Prefix for captions (). ticked_indices (List[int], optional): Indices that are ticked initially. cursor_index (int, optional): The index the cursor starts at. minimal_count (int, optional): The minimal amount of lines that have to be ticked. maximal_count (int, optional): The maximal amount of lines that have to be ticked. hide_confirm (bool, optional): Hide the confirm button. This causes to confirm the entire selection and not just tick the line. deselected_confirm_label (str, optional): The confirm label if not selected ((( confirm ))). selected_confirm_label (str, optional): The confirm label if selected ({{ confirm }}). Returns: List[int]: The indices that have been selected """ print("\n" * (len(options) - 1)) if caption_indices is None: caption_indices = [] if ticked_indices is None: ticked_indices = [] max_index = len(options) - (1 if hide_confirm else 0) error_message = "" while True: print(f"\033[{len(options) + 1}A") for i, option in enumerate(options): prefix = "" if i in caption_indices: prefix = caption_prefix elif i == cursor_index: if i in ticked_indices: prefix = selected_ticked_prefix else: prefix = selected_unticked_prefix else: if i in ticked_indices: prefix = deselected_ticked_prefix else: prefix = deselected_unticked_prefix print("\033[K{}{}".format(prefix, option)) if hide_confirm: print(f"{error_message}\033[K", end="", flush=True) else: if cursor_index == max_index: print( f"{selected_confirm_label} {error_message}\033[K", end="", flush=True, ) else: print( f"{deselected_confirm_label} {error_message}\033[K", end="", flush=True, ) error_message = "" keypress = readchar.readkey() if keypress in DefaultKeys.up: new_index = cursor_index while new_index > 0: new_index -= 1 if new_index not in caption_indices: cursor_index = new_index break elif keypress in DefaultKeys.down: new_index = cursor_index while new_index + 1 <= max_index: new_index += 1 if new_index not in caption_indices: cursor_index = new_index break elif ( hide_confirm and keypress in DefaultKeys.confirm or not hide_confirm and cursor_index == max_index ): if minimal_count > len(ticked_indices): error_message = f"Must select at least {minimal_count} options" elif maximal_count is not None and maximal_count < len(ticked_indices): error_message = f"Must select at most {maximal_count} options" else: break elif ( keypress in DefaultKeys.select or not hide_confirm and keypress in DefaultKeys.confirm ): if cursor_index in ticked_indices: ticked_indices.remove(cursor_index) else: ticked_indices.append(cursor_index) elif keypress in DefaultKeys.interrupt: raise KeyboardInterrupt print("\r\033[K", end="", flush=True) return ticked_indices def prompt_yes_or_no( question: str, yes_text: str = "Yes", no_text: str = "No", has_to_match_case: bool = False, enter_empty_confirms: bool = True, default_is_yes: bool = False, deselected_prefix: str = " ", selected_prefix: str = "\033[31m>\033[0m ", char_prompt: bool = True, ) -> Optional[bool]: """Prompt the user to input yes or no. Args: question (str): The prompt asking the user to input. yes_text (str, optional): The text corresponding to 'yes'. no_text (str, optional): The text corresponding to 'no'. has_to_match_case (bool, optional): Does the case have to match. enter_empty_confirms (bool, optional): Does enter on empty string work. default_is_yes (bool, optional): Is yes selected by default (no). deselected_prefix (str, optional): Prefix if something is deselected. selected_prefix (str, optional): Prefix if something is selected (> ) char_prompt (bool, optional): Add a [Y/N] to the prompt. Returns: Optional[bool]: The bool what has been selected. """ is_yes = default_is_yes is_selected = enter_empty_confirms current_message = "" yn_prompt = f" ({yes_text[0]}/{no_text[0]}) " if char_prompt else ": " print() while True: yes = is_yes and is_selected no = not is_yes and is_selected print("\033[K" f"{selected_prefix if yes else deselected_prefix}{yes_text}") print("\033[K" f"{selected_prefix if no else deselected_prefix}{no_text}") print( "\033[3A\r\033[K" f"{question}{yn_prompt}{current_message}", end="", flush=True, ) keypress = readchar.readkey() if keypress in DefaultKeys.down or keypress in DefaultKeys.up: is_yes = not is_yes is_selected = True current_message = yes_text if is_yes else no_text elif keypress in DefaultKeys.delete: if current_message: current_message = current_message[:-1] elif keypress in DefaultKeys.interrupt: raise KeyboardInterrupt elif keypress in DefaultKeys.confirm: if is_selected: break elif keypress in "\t": if is_selected: current_message = yes_text if is_yes else no_text else: current_message += keypress match_yes = yes_text match_no = no_text match_text = current_message if not has_to_match_case: match_yes = match_yes.upper() match_no = match_no.upper() match_text = match_text.upper() if match_no.startswith(match_text): is_selected = True is_yes = False elif match_yes.startswith(match_text): is_selected = True is_yes = True else: is_selected = False print() print("\033[K\n\033[K\n\033[K\n\033[3A") return is_selected and is_yes ================================================ FILE: example.py ================================================ #! /usr/bin/env python3 """Example script demonstrating usage of cutie. """ import cutie def main(): """Main.""" if cutie.prompt_yes_or_no("Are you brave enough to continue?"): # List of names to select from, including some captions names = [ "Kings:", "Arthur, King of the Britons", "Knights of the Round Table:", "Sir Lancelot the Brave", "Sir Robin the Not-Quite-So-Brave-as-Sir-Lancelot", "Sir Bedevere the Wise", "Sir Galahad the Pure", "Swedish captions:", "Møøse", ] # Names which are captions and thus not selectable captions = [0, 2, 7] # Get the name name = names[cutie.select(names, caption_indices=captions, selected_index=8)] print(f"Welcome, {name}") # Get an integer greater or equal to 0 age = cutie.get_number("What is your age?", min_value=0, allow_float=False) nemeses_options = [ "The French", "The Police", "The Knights Who Say Ni", "Women", "The Black Knight", "The Bridge Keeper", "Especially terrifying:", "The Rabbit of Caerbannog", ] print("Choose your nemeses") # Choose multiple options from a list nemeses_indices = cutie.select_multiple( nemeses_options, caption_indices=[6], hide_confirm=False ) nemeses = [ nemesis for nemesis_index, nemesis in enumerate(nemeses_options) if nemesis_index in nemeses_indices ] # Get input without showing it being typed quest = cutie.secure_input("What is your quest?") print(f"{name}'s quest (who is {age}) is {quest}.") if nemeses: if len(nemeses) == 1: print(f"His nemesis is {nemeses[0]}.") else: print(f'His nemeses are {" and ".join(nemeses)}.') else: print("He has no nemesis.") if __name__ == "__main__": main() ================================================ FILE: license.md ================================================ # The MIT License (MIT) Copyright © 2018 Hans Schülein Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: readme.md ================================================ # CUTIE *Command line User Tools for Input Easification* [![CircleCI](https://dl.circleci.com/status-badge/img/gh/Kamik423/cutie/tree/main.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/Kamik423/cutie/tree/main) [![Coverage Status](https://coveralls.io/repos/github/Kamik423/cutie/badge.svg?branch=coveralls_integration)](https://coveralls.io/github/Kamik423/cutie?branch=coveralls_integration) [![PRs Welcome](https://img.shields.io/badge/Homepage-GitHub-green.svg)](https://github.com/kamik423/cutie) [![PyPI version](https://badge.fury.io/py/cutie.svg)](https://badge.fury.io/py/cutie) [![PyPI license](https://img.shields.io/pypi/l/cutie.svg)](https://pypi.python.org/pypi/cutie/) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/cutie.svg)](https://pypi.python.org/pypi/cutie/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![GitHub contributors](https://img.shields.io/github/contributors/Kamik423/cutie.svg)](https://GitHub.com/Kamik423/cutie/graphs/contributors/) A tool for handling common user input functions in an elegant way. It supports asking yes or no questions, selecting an element from a list with arrow keys or vim arrow keys, forcing the user to input a number and secure text entry while having many customization options. For example the yes or no input supports forcing the user to match case, tab autocomplete and switching option with the arrow keys. The number input allows setting a minum and a maximum, entering floats or forcing the user to use integers. It will only return once the user inputs a number in that format, showing a warning to them if it does not conform. It should work on all major operating systems (Mac, Linux, Windows). ![example](https://github.com/Kamik423/cutie/blob/main/example.gif?raw=true) ## Usage These are the main functions of cutie. [example.py](https://github.com/Kamik423/cutie/blob/main/example.py) contains an extended version of this also showing off the `select_multiple` option. ```python import cutie if cutie.prompt_yes_or_no("Are you brave enough to continue?"): # List of names to select from, including some captions names = [ "Kings:", "Arthur, King of the Britons", "Knights of the Round Table:", "Sir Lancelot the Brave", "Sir Robin the Not-Quite-So-Brave-as-Sir-Lancelot", "Sir Bedevere the Wise", "Sir Galahad the Pure", "Swedish captions:", "Møøse", ] # Names which are captions and thus not selectable captions = [0, 2, 7] # Get the name name = names[cutie.select(names, caption_indices=captions, selected_index=8)] print(f"Welcome, {name}") # Get an integer greater or equal to 0 age = cutie.get_number("What is your age?", min_value=0, allow_float=False) # Get input without showing it being typed quest = cutie.secure_input("What is your quest?") print(f"{name}'s quest (who is {age}) is {quest}.") ``` When run, as demonstrated in the gif above it yields this output: ``` Are you brave enough to continue? (Y/N) Yes Kings: [ ] Arthur, King of the Britons Knights of the Round Table: [ ] Sir Lancelot the Brave [x] Sir Robin the Not-Quite-So-Brave-as-Sir-Lancelot [ ] Sir Bedevere the Wise [ ] Sir Galahad the Pure Swedish captions: [ ] Møøse Welcome, Sir Robin the Not-Quite-So-Brave-as-Sir-Lancelot What is your age? 31 What is your quest? Sir Robin the Not-Quite-So-Brave-as-Sir-Lancelot's quest (who is 31) is to find the holy grail. ``` ## Installation With pip from pypi: ```bash pip3 install cutie ``` With pip from source or in a virtual environment: ```bash pip3 install -r requirements.txt ``` ## Documentation All functions of cutie are explained here. If something is still unclear or you have questions about the implementation just take a look at [cutie.py](https://github.com/Kamik423/cutie/blob/main/cutie.py). The implementation is rather straight forward. ### get\_number Get a number from user input. If an invalid number is entered the user will be prompted again. A minimum and maximum value can be supplied. They are inclusive. If the `allow_float` option, which is `True` by default is set to `False` it forces the user to enter an integer. Getting any three digit number for example could be done like that: ```python number = cutie.get_number( "Please enter a three digit number:", min_value=100, max_value=999, allow_float=False ) # which is equivalent to number = cutie.get_number("Please enter a three digit number", 100, 999, False) ``` #### Arguments | argument | type | default | description | |:--------------|:----------------|:-----------|:-------------------------------------| | `prompt` | str | | The prompt asking the user to input. | | `min_value` | float, optional | - infinity | The [inclusive] minimum value. | | `max_value` | float, optional | infinity | The [inclusive] maximum value. | | `allow_float` | bool, optional | True | Allow floats or force integers. | #### Returns The number input by the user. ### secure\_input Get secure input without showing it in the command line. This could be used for passwords: ```python password = cutie.secure_input("Please enter your password:") ``` #### Arguments | argument | type | description | |:---------|:-----|:-------------------------------------| | `prompt` | str | The prompt asking the user to input. | #### Returns The secure input. ### select Select an option from a list. Captions or separators can be included between options by adding them as an option and including their index in `caption_indices`. A preselected index can be supplied. In its simplest case it could be used like this: ```python colors = ["red", "green", "blue", "yellow"] print("What is your favorite color?") favorite_color = colors[cutie.select(colors)] ``` With the high degree of customizability, however it is possible to do things like: ```python print("Select server to ping") server_id = cutie.select( servers, deselected_prefix=" ", selected_prefix="PING", selected_index=default_server_ip ) ``` #### Arguments | argument | type | default | description | |:--------------------|:--------------------|:--------|:-----------------------------------| | `options` | List[str] | | The options to select from. | | `caption_indices` | List[int], optional | `None` | Non-selectable indices. | | `deselected_prefix` | str, optional | `[ ]` | Prefix for deselected option. | | `selected_prefix` | str, optional | `[x]` | Prefix for selected option. | | `caption_prefix` | str, optional | ` ` | Prefix for captions. | | `selected_index` | int, optional | 0 | The index to be selected at first. | | `confirm_on_select` | bool, optional | True | Select keys also confirm. | #### Returns The index that has been selected. ### select\_multiple Select multiple options from a list. It per default shows a "confirm" button. In that case space bar and enter select a line. The button can be hidden. In that case space bar selects the line and enter confirms the selection. This is not in the example in this readme, but in [example.py](https://github.com/Kamik423/cutie/blob/main/example.py). ```python packages_to_update = cutie.select_multiple( outdated_packages, deselected_unticked_prefix=" KEEP ", deselected_ticked_prefix=" UPDATE ", selected_unticked_prefix="[ KEEP ]", selected_ticked_prefix="[UPDATE]", ticked_indices=list(range(len(outdated_packages))), deselected_confirm_label=" [[[[ UPDATE ]]]] ", selected_confirm_label="[ [[[[ UPDATE ]]]] ]" ) ``` #### Arguments | argument | type | default | description | |:-----------------------------|:--------------------|:----------------|:-----------------------------------------------------------------------------------------------------------| | `options` | List[str] | | The options to select from. | | `caption_indices` | List[int], optional | | Non-selectable indices. | | `deselected_unticked_prefix` | str, optional | `( )` | Prefix for lines that are not selected and not ticked . | | `deselected_ticked_prefix` | str, optional | `(x)` | Prefix for lines that are not selected but ticked . | | `selected_unticked_prefix` | str, optional | `{ }` | Prefix for lines that are selected but not ticked . | | `selected_ticked_prefix` | str, optional | `{x}` | Prefix for lines that are selected and ticked . | | `caption_prefix` | str, optional | ` ` | Prefix for captions. | | `ticked_indices` | List[int], optional | `[]` | Indices that are ticked initially. | | `cursor_index` | int, optional | 0 | The index the cursor starts at. | | `minimal_count` | int, optional | 0 | The minimal amount of lines that have to be ticked. | | `maximal_count` | int, optional | infinity | The maximal amount of lines that have to be ticked. | | `hide_confirm` | bool, optional | `True` | Hide the confirm button. This causes `` to confirm the entire selection and not just tick the line. | | `deselected_confirm_label` | str, optional | `(( confirm ))` | The confirm label if not selected. | | `selected_confirm_label` | str, optional | `{{ confirm }}` | The confirm label if selected. | #### Returns A list of indices that have been selected. ### prompt\_yes\_or\_no Prompt the user to input yes or no. This again can range from very simple to very highly customized: ```python if cutie.prompt_yes_or_no("Do you want to continue?"): do_continue() ``` ```python if cutie.prompt_yes_or_no( "Do you want to hear ze funniest joke in ze world? Proceed at your own risk.", yes_text="JA", no_text="nein", has_to_match_case=True, # The user has to type the exact case enter_empty_confirms=False, # An answer has to be selected ) ``` #### Arguments | argument | type | default | description | |:-----------------------|:---------------|:--------|:-------------------------------------| | `question` | str | | The prompt asking the user to input. | | `yes_text` | str, optional | `Yes` | The text corresponding to "yes". | | `no_text` | str, optional | `No` | The text corresponding to "no". | | `has_to_match_case` | bool, optional | `False` | Does the case have to match. | | `enter_empty_confirms` | bool, optional | True | Does enter on empty string work. | | `default_is_yes` | bool, optional | False | Is yes selected by default | | `deselected_prefix` | str, optional | ` ` | Prefix if something is deselected. | | `selected_prefix` | str, optional | `> ` | Prefix if something is selected | | `char_prompt` | bool, optional | `True` | Add a [Y/N] to the prompt. | #### Returns The bool what has been selected. ## Changelog ### 0.3.2 * CircleCI Integration ### 0.3.1 * Readme fixes for PyPi ### 0.3.0 * Unittests by [provinzkraut](https://github.com/provinzkraut) * Travis CI integration * Vim Arrow keys (`jk`) * Also showing error messages with `hide_confirm` option enabled in `select_multiple` * Consistenly crash on keyboard interrupt (Removes `prompt_yes_or_no`'s `abort_value`) * Set `hide_confirm` to default in `select_multiple` ([#9](https://github.com/Kamik423/cutie/issues/9)) * Black code style ### 0.2.2 * Fixed Python in examples * PEP8 Compliance by [Christopher Bilger](https://github.com/ChristopherBilg) * Fixed critical issue with pypi download ([#15](https://github.com/Kamik423/cutie/issues/15)) ### 0.2.1 * Expanded readme descriptions ### 0.2.0 * `select_multiple` * Tweaks to the readme ### 0.1.1 * Fixed pypi download not working ### 0.1.0 * `caption_indices` option by [dherrada](https://github.com/dherrada) ### 0.0.7 * Windows support by [Lhitrom](https://github.com/Lhitrom) ### 0.0.x * Initial upload and got everything working ## Contributing If you want to contribute, please feel free to suggest features or implement them yourself. Also **please report any issues and bugs you might find!** If you have a project that uses cutie please let me know and I'll link it here! ## Authors * Main project by [me](https://github.com/Kamik423). * Unittests, issues and advice by [provinzkraut](https://github.com/provinzkraut). * Windows support by [Lhitrom](https://github.com/Lhitrom). * `caption_indices` and tidbits by [dherrada](https://github.com/dherrada). * PEP8 Compliance by [Christopher Bilger](https://github.com/ChristopherBilg). ## License The project is licensed under the [MIT-License](https://github.com/Kamik423/cutie/blob/main/license.md). ## Acknowledgments * This project uses the module [Readchar](https://pypi.org/project/readchar/) for direct input handling. --- *GNU Terry Pratchett* ================================================ FILE: requirements.txt ================================================ colorama readchar != 3.0.5 ================================================ FILE: setup.py ================================================ """Setup module for PyPI / pip integration. """ import imp import setuptools with open("readme.md", encoding="utf-8") as file: LONG_DESCRIPTION = file.read() with open("cutie.py", encoding="utf-8") as file: # only go to first import since that module is not yet installed CUTIE_CONTENTS = file.read().split("import")[0] cutie = imp.new_module("cutie") exec(CUTIE_CONTENTS, cutie.__dict__) setuptools.setup( name="cutie", version=cutie.__version__, author=cutie.__author__, author_email="contact.kamik423@gmail.com", description=cutie.__doc__, long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", url="https://github.com/kamik423/cutie", py_modules=["cutie"], license=cutie.__license__, install_requires=["colorama", "readchar!=3.0.5"], python_requires=">=3.6", classifiers=[ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3 :: Only", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], ) ================================================ FILE: test/__init__.py ================================================ import readchar import cutie def PrintCall(states): def func(msg=None, state="selectable"): state_ = states[state] state_name: str kwargs = {} if isinstance(state_, str): state_name = state_ elif isinstance(state_, tuple): state_name = state_[0] if len(state) > 1: kwargs = state_[1] if msg: return ((state_name + msg,), kwargs) else: return ((state_name,), kwargs) return func def yield_input(*data, raise_on_empty=False): """ Closure that returns predefined data. If the data is exhausted raise a MockException or reraise the IndexError """ data = list(data) def func(*a, **kw): try: return data.pop(0) except IndexError as e: if raise_on_empty: raise MockException() else: raise e return func class InputContext: """ Context manager to simulate keyboard input returned by `readchar.readkey`, by replacing it in `cutie` with `yield_input` When the supplied keystrokes are exhausted a `MockException` will be raised. This can be used to terminate the execution at any desired point, rather than relying on internal control mechanisms. Usage: with InputContext(" ", "\r"): cutie.select(["foo", "bar"]) This will issue a space and enter keypress, selecting the first item and confirming. """ def __init__(self, *data, raise_on_empty=True): cutie.readchar.readkey = yield_input(*data, raise_on_empty=raise_on_empty) def __enter__(self): pass def __exit__(self, *a): cutie.readchar.readkey = readchar.readkey class MockException(Exception): pass ================================================ FILE: test/test_get_number.py ================================================ import unittest from unittest import mock import cutie from . import MockException class TestCutieGetNumber(unittest.TestCase): @mock.patch("cutie.print", side_effect=MockException) def test_invalid_number(self, mock_print): with mock.patch("cutie.input", return_value="foo"): with self.assertRaises(MockException): cutie.get_number("bar") mock_print.assert_called_once_with( "Not a valid number.\033[K\033[1A\r\033[K", end="" ) @mock.patch("cutie.print", side_effect=MockException) def test_not_allow_float(self, mock_print): with mock.patch("cutie.input", return_value="1.2"): with self.assertRaises(MockException): cutie.get_number("foo", allow_float=False) mock_print.assert_called_once_with( "Has to be an integer.\033[K\033[1A\r\033[K", end="" ) def test_allow_float_returns_float(self): with mock.patch("cutie.input", return_value="1.2"): val = cutie.get_number("foo") self.assertIsInstance(val, float) self.assertEqual(val, 1.2) def test_not_allow_float_returns_int(self): with mock.patch("cutie.input", return_value="1"): val = cutie.get_number("foo", allow_float=False) self.assertIsInstance(val, int) self.assertEqual(val, 1) @mock.patch("cutie.print", side_effect=MockException) def test_min_value_float_too_low(self, mock_print): with mock.patch("cutie.input", return_value="1.2"): with self.assertRaises(MockException): cutie.get_number("foo", min_value=1.3) mock_print.assert_called_once_with( "Has to be at least 1.3.\033[K\033[1A\r\033[K", end="" ) def test_min_value_float_equal(self): with mock.patch("cutie.input", return_value="1.2"): self.assertEqual(cutie.get_number("foo", min_value=1.2), 1.2) def test_min_value_float_greater(self): with mock.patch("cutie.input", return_value="1.3"): self.assertEqual(cutie.get_number("foo", min_value=1.2), 1.3) @mock.patch("cutie.print", side_effect=MockException) def test_min_value_int_too_low(self, mock_print): with mock.patch("cutie.input", return_value="1"): with self.assertRaises(MockException): cutie.get_number("foo", min_value=2) mock_print.assert_called_once_with( "Has to be at least 2.\033[K\033[1A\r\033[K", end="" ) def test_min_value_int_equal(self): with mock.patch("cutie.input", return_value="1"): self.assertEqual(cutie.get_number("foo", min_value=1), 1) def test_min_value_int_greater(self): with mock.patch("cutie.input", return_value="2"): self.assertEqual(cutie.get_number("foo", min_value=1), 2) @mock.patch("cutie.print", side_effect=MockException) def test_max_value_float_too_high(self, mock_print): with mock.patch("cutie.input", return_value="1.2"): with self.assertRaises(MockException): cutie.get_number("foo", max_value=1.1) mock_print.assert_called_once_with( "Has to be at most 1.1.\033[1A\r\033[K", end="" ) def test_max_value_float_equal(self): with mock.patch("cutie.input", return_value="1.1"): self.assertEqual(cutie.get_number("foo", max_value=1.1), 1.1) def test_max_value_float_smaller(self): with mock.patch("cutie.input", return_value="1.1"): self.assertEqual(cutie.get_number("foo", max_value=1.2), 1.1) @mock.patch("cutie.print", side_effect=MockException) def test_max_value_int_too_high(self, mock_print): with mock.patch("cutie.input", return_value="2"): with self.assertRaises(MockException): cutie.get_number("foo", max_value=1) mock_print.assert_called_once_with( "Has to be at most 1.\033[1A\r\033[K", end="" ) def test_max_value_int_equal(self): with mock.patch("cutie.input", return_value="1"): self.assertEqual(cutie.get_number("foo", max_value=1), 1) def test_max_value_int_smaller(self): with mock.patch("cutie.input", return_value="1"): self.assertEqual(cutie.get_number("foo", max_value=2), 1) @mock.patch("cutie.print") def test_print_finalize(self, mock_print): with mock.patch("cutie.input", return_value="1"): cutie.get_number("foo") mock_print.assert_called_once_with("\033[K", end="") ================================================ FILE: test/test_prompt_yes_or_no.py ================================================ import unittest from unittest import mock import readchar from . import InputContext, MockException, PrintCall, cutie print_call = PrintCall( { "selected": "\x1b[K\x1b[31m>\x1b[0m ", "selectable": "\x1b[K ", } ) class TestPromtYesOrNo(unittest.TestCase): default_yes_print_calls = [ (tuple(),), (("\x1b[K\x1b[31m>\x1b[0m Yes",),), (("\x1b[K No",),), (("\x1b[3A\r\x1b[Kfoo (Y/N) Yes",), {"end": "", "flush": True}), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] default_no_print_calls = [ (tuple(),), (("\x1b[K Yes",),), (("\x1b[K\x1b[31m>\x1b[0m No",),), (("\x1b[3A\r\x1b[Kfoo (Y/N) No",), {"end": "", "flush": True}), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] @mock.patch("cutie.print") def test_print_message(self, mock_print): expected_calls = [ (tuple(),), (("\x1b[K Yes",),), (("\x1b[K\x1b[31m>\x1b[0m No",),), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) ",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext(readchar.key.ENTER): cutie.prompt_yes_or_no("foo") self.assertEqual(mock_print.call_args_list, expected_calls) @mock.patch("cutie.print") def test_print_message_custom_prefixes(self, mock_print): expected_calls = [ (("\x1b[K+Yes",),), (("\x1b[K*No",),), ] with InputContext(readchar.key.ENTER): cutie.prompt_yes_or_no("foo", selected_prefix="*", deselected_prefix="+") self.assertEqual(mock_print.call_args_list[1:3], expected_calls) @mock.patch("cutie.print") def test_print_message_custom_yes_no_text(self, mock_print): expected_calls = [ (("\x1b[K bar",),), (("\x1b[K\x1b[31m>\x1b[0m baz",),), ] with InputContext(readchar.key.ENTER): cutie.prompt_yes_or_no("foo", yes_text="bar", no_text="baz") self.assertEqual(mock_print.call_args_list[1:3], expected_calls) @mock.patch("cutie.print") def test_print_message_default_is_yes(self, mock_print): expected_calls = [ (("\x1b[K\x1b[31m>\x1b[0m Yes",),), (("\x1b[K No",),), ] with InputContext(readchar.key.ENTER): cutie.prompt_yes_or_no("foo", default_is_yes=True) self.assertEqual(mock_print.call_args_list[1:3], expected_calls) @mock.patch("cutie.print") def test_move_up(self, mock_print): with InputContext(readchar.key.UP, readchar.key.ENTER): self.assertTrue(cutie.prompt_yes_or_no("foo")) self.assertEqual( mock_print.call_args_list[-5:], self.default_yes_print_calls ) @mock.patch("cutie.print") def test_move_up_over_boundary(self, mock_print): with InputContext(readchar.key.UP, readchar.key.UP, readchar.key.ENTER): self.assertFalse(cutie.prompt_yes_or_no("foo")) self.assertEqual( mock_print.call_args_list[-5:], self.default_no_print_calls ) @mock.patch("cutie.print") def test_move_down(self, mock_print): with InputContext(readchar.key.DOWN, readchar.key.ENTER): self.assertFalse(cutie.prompt_yes_or_no("foo", default_is_yes=True)) self.assertEqual( mock_print.call_args_list[-5:], self.default_no_print_calls ) @mock.patch("cutie.print") def test_move_down_over_boundary(self, mock_print): with InputContext(readchar.key.DOWN, readchar.key.ENTER): self.assertTrue(cutie.prompt_yes_or_no("foo")) self.assertEqual( mock_print.call_args_list[-5:], self.default_yes_print_calls ) @mock.patch("cutie.print") def test_backspace_delete_char(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes", "selected"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) Ye",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext(readchar.key.UP, readchar.key.BACKSPACE, readchar.key.ENTER): cutie.prompt_yes_or_no("foo") self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_ctrl_c_abort(self, *m): with InputContext(readchar.key.CTRL_C): with self.assertRaises(KeyboardInterrupt): cutie.prompt_yes_or_no("") @mock.patch("cutie.print") def test_ctrl_c_abort_with_input(self, *m): with InputContext(readchar.key.UP, readchar.key.CTRL_D): with self.assertRaises(KeyboardInterrupt): cutie.prompt_yes_or_no("") @mock.patch("cutie.print") def test_ctrl_d_abort(self, *m): with InputContext(readchar.key.CTRL_D): with self.assertRaises(KeyboardInterrupt): cutie.prompt_yes_or_no("") @mock.patch("cutie.print") def test_ctrl_d_abort_with_input(self, *m): with InputContext(readchar.key.UP, readchar.key.CTRL_D): with self.assertRaises(KeyboardInterrupt): cutie.prompt_yes_or_no("") @mock.patch("cutie.print") def test_enter_confirm_default(self, *m): with InputContext(readchar.key.ENTER): self.assertFalse(cutie.prompt_yes_or_no("")) @mock.patch("cutie.print") def test_enter_confirm_selection(self, *m): with InputContext(readchar.key.UP, readchar.key.ENTER): self.assertTrue(cutie.prompt_yes_or_no("")) @mock.patch("cutie.print") def test_tab_select(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes"), print_call("No", "selected"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) No",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext("\t", readchar.key.ENTER): cutie.prompt_yes_or_no("foo") self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_write_keypress_to_terminal(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes"), print_call("No", "selected"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) ",), {"end": "", "flush": True}, ), (tuple(),), print_call("Yes"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) f",), {"end": "", "flush": True}, ), (tuple(),), print_call("Yes"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) fo",), {"end": "", "flush": True}, ), (tuple(),), print_call("Yes"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) foo",), {"end": "", "flush": True}, ), ] with InputContext("f", "o", "o", readchar.key.CTRL_C): with self.assertRaises(KeyboardInterrupt): cutie.prompt_yes_or_no("foo") self.assertEqual(mock_print.call_args_list, expected_calls) @mock.patch("cutie.print") def test_write_keypress_to_terminal_resume_selection(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes", "selected"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) Yes",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext("f", readchar.key.DOWN, readchar.key.ENTER): self.assertTrue(cutie.prompt_yes_or_no("foo")) self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_evaluate_written_input_yes_ignorecase(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes", "selected"), print_call("No"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) yes",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext("y", "e", "s", readchar.key.ENTER): self.assertTrue(cutie.prompt_yes_or_no("foo")) self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_evaluate_written_input_yes_case_sensitive(self, mock_print): expected_calls = ( ("\x1b[3A\r\x1b[Kfoo (Y/N) yes",), {"end": "", "flush": True}, ) with InputContext("y", "e", "s", readchar.key.CTRL_C): res = None with self.assertRaises(KeyboardInterrupt): res = cutie.prompt_yes_or_no("foo", has_to_match_case=True) self.assertIsNone(res) self.assertEqual(mock_print.call_args_list[-1], expected_calls) @mock.patch("cutie.print") def test_evaluate_written_input_no_ignorecase(self, mock_print): expected_calls = [ (tuple(),), print_call("Yes"), print_call("No", "selected"), ( ("\x1b[3A\r\x1b[Kfoo (Y/N) no",), {"end": "", "flush": True}, ), (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), ] with InputContext("n", "o", readchar.key.ENTER): self.assertFalse(cutie.prompt_yes_or_no("foo")) self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_evaluate_written_input_no_case_sensitive(self, mock_print): expected_calls = ( ("\x1b[3A\r\x1b[Kfoo (Y/N) no",), {"end": "", "flush": True}, ) with InputContext("n", "o", readchar.key.CTRL_C): res = None with self.assertRaises(KeyboardInterrupt): res = cutie.prompt_yes_or_no("foo", has_to_match_case=True) self.assertIsNone(res) self.assertEqual(mock_print.call_args_list[-1], expected_calls) ================================================ FILE: test/test_secure_input.py ================================================ import unittest from unittest import mock import cutie class TestSecureInput(unittest.TestCase): def test_secure_input(self): with mock.patch("cutie.getpass.getpass", return_value="foo") as mock_getpass: self.assertEqual(cutie.secure_input("foo"), "foo") mock_getpass.assert_called_once_with("foo ") ================================================ FILE: test/test_select.py ================================================ import string import unittest from unittest import mock import readchar from . import InputContext, MockException, PrintCall, cutie print_call = PrintCall( { "selectable": "\x1b[K\x1b[1m[ ]\x1b[0m ", "selected": "\x1b[K\x1b[1m[\x1b[32;1mx\x1b[0;1m]\x1b[0m ", "caption": "\x1b[K", } ) class TestSelect(unittest.TestCase): @mock.patch("cutie.print", side_effect=MockException) def test_print_list_newlines(self, mock_print): args_list = ["foo", "bar"] with self.assertRaises(MockException): cutie.select(args_list) mock_print.assert_called_once_with("\n" * (len(args_list) - 1)) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_move_to_first_item(self, mock_print, *m): args_list = ["foo", "bar"] with self.assertRaises(MockException): cutie.select(args_list) self.assertEqual( mock_print.call_args_list[1], ((f"\033[{len(args_list) + 1}A",),) ) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [print_call("foo", "selected"), print_call("bar")] with self.assertRaises(MockException): cutie.select(args_list) self.assertEqual(mock_print.call_args_list[2:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_selected_index_set(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [print_call("foo"), print_call("bar", "selected")] with self.assertRaises(MockException): cutie.select(args_list, selected_index=1) self.assertEqual(mock_print.call_args_list[2:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_non_selectable(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [print_call("foo", "selected"), print_call("bar", "caption")] with self.assertRaises(MockException): cutie.select(args_list, caption_indices=[1]) self.assertEqual(mock_print.call_args_list[2:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_custom_prefixes(self, mock_print, *m): args_list = ["foo", "bar", "baz"] expected_calls = [(("\x1b[K*foo",),), (("\x1b[K+bar",),), (("\x1b[K$baz",),)] with self.assertRaises(MockException): cutie.select( args_list, caption_indices=[2], selected_prefix="*", deselected_prefix="+", caption_prefix="$", ) self.assertEqual(mock_print.call_args_list[2:], expected_calls) @mock.patch("cutie.print") def test_ignore_unrecognized_key(self, mock_print): exclude = [ "__builtins__", "__cached__", "__doc__", "__file__", "__loader__", "__name__", "__package__", "__spec__", "UP", "DOWN", "ENTER", "CTRL_C", "CTRL_D", ] all_keys = [ getattr(readchar.key, k) for k in dir(readchar.key) if k not in exclude ] all_keys.extend(string.printable) expected_calls = [ (("",),), (("\x1b[2A",),), (("\x1b[K\x1b[1m[\x1b[32;1mx\x1b[0;1m]\x1b[0m foo",),), ] for key in all_keys: with InputContext(readchar.key.DOWN, key, readchar.key.ENTER): selindex = cutie.select(["foo"]) self.assertEqual(selindex, 0) self.assertEqual(mock_print.call_args_list[:3], expected_calls) mock_print.reset_mock() @mock.patch("cutie.print") def test_move_up(self, *m): with InputContext(readchar.key.UP, readchar.key.ENTER): args_list = ["foo", "bar"] selindex = cutie.select(args_list, selected_index=1) self.assertEqual(selindex, 0) @mock.patch("cutie.print") def test_move_up_skip_caption(self, *m): with InputContext(readchar.key.UP, readchar.key.ENTER): args_list = ["foo", "bar", "baz"] selindex = cutie.select(args_list, selected_index=2, caption_indices=[1]) self.assertEqual(selindex, 0) @mock.patch("cutie.print") def test_move_down(self, *m): with InputContext(readchar.key.DOWN, readchar.key.ENTER): args_list = ["foo", "bar"] selindex = cutie.select(args_list) self.assertEqual(selindex, 1) @mock.patch("cutie.print") def test_move_down_skip_caption(self, *m): with InputContext(readchar.key.DOWN, readchar.key.ENTER): args_list = ["foo", "bar", "baz"] selindex = cutie.select(args_list, caption_indices=[1]) self.assertEqual(selindex, 2) @mock.patch("cutie.print") def test_keyboard_interrupt_ctrl_c_no_input(self, *m): with InputContext(readchar.key.CTRL_C): with self.assertRaises(KeyboardInterrupt): cutie.select(["foo"]) @mock.patch("cutie.print") def test_keyboard_interrupt_ctrl_c_selected(self, *m): with InputContext(readchar.key.DOWN, readchar.key.CTRL_C): with self.assertRaises(KeyboardInterrupt): cutie.select(["foo"], selected_index=0) @mock.patch("cutie.print") def test_keyboard_interrupt_ctrl_d_no_input(self, *m): with InputContext(readchar.key.CTRL_D): with self.assertRaises(KeyboardInterrupt): cutie.select(["foo"]) @mock.patch("cutie.print") def test_keyboard_interrupt_ctrl_d_selected(self, *m): with InputContext(readchar.key.DOWN, readchar.key.CTRL_D): with self.assertRaises(KeyboardInterrupt): cutie.select(["foo"], selected_index=0) ================================================ FILE: test/test_select_multiple.py ================================================ import string import unittest from unittest import mock import readchar from . import InputContext, MockException, PrintCall, cutie, yield_input print_call = PrintCall( { "selectable": "\x1b[K\x1b[1m( )\x1b[0m ", "selected": "\x1b[K\x1b[1m(\x1b[32mx\x1b[0;1m)\x1b[0m ", "caption": "\x1b[K", "active": "\x1b[K\x1b[32;1m{ }\x1b[0m ", "active-selected": "\x1b[K\x1b[32;1m{x}\x1b[0m ", "confirm": ("\x1b[1m(( confirm ))\x1b[0m \x1b[K", {"end": "", "flush": True}), "confirm-active": ( "\x1b[1;32m{{ confirm }}\x1b[0m \x1b[K", {"end": "", "flush": True}, ), "no_confirm_line": ("\033[K", {"end": "", "flush": True}), } ) PRINT_CALL_END = (("\r\x1b[K",), {"end": "", "flush": True}) class TestSelectMultiplePrint(unittest.TestCase): @mock.patch("cutie.print", side_effect=MockException) def test_list_newlines(self, mock_print): args_list = ["foo", "bar"] with self.assertRaises(MockException): cutie.select_multiple(args_list) mock_print.assert_called_once_with("\n" * (len(args_list) - 1)) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_move_to_first_item(self, mock_print, *m): args_list = ["foo", "bar"] with self.assertRaises(MockException): cutie.select_multiple(args_list) self.assertEqual( mock_print.call_args_list[1], ((f"\033[{len(args_list) + 1}A",),) ) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [ print_call("foo", "active"), print_call("bar", "selectable"), print_call(state="confirm"), ] with self.assertRaises(MockException): cutie.select_multiple(args_list) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_caption_indices(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [ print_call("foo", "caption"), print_call("bar"), print_call(state="no_confirm_line"), ] with self.assertRaises(MockException): cutie.select_multiple(args_list, caption_indices=[0]) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_selected(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [ print_call("foo"), print_call("bar", "active"), print_call(state="no_confirm_line"), ] with self.assertRaises(MockException): cutie.select_multiple(args_list, cursor_index=1) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_selected_and_ticked(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [ print_call("foo", "active-selected"), print_call("bar"), print_call(state="no_confirm_line"), ] with self.assertRaises(MockException): cutie.select_multiple(args_list, ticked_indices=[0]) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_options_deselected_unticked(self, mock_print, *m): args_list = ["foo", "bar"] expected_calls = [ print_call("foo"), print_call("bar"), print_call(state="no_confirm_line"), ] with self.assertRaises(MockException): cutie.select_multiple(args_list, cursor_index=2) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_deselected_confirm(self, mock_print, *m): expected_call = print_call(state="confirm") with self.assertRaises(MockException): cutie.select_multiple([], cursor_index=1, hide_confirm=False) self.assertEqual(mock_print.call_args_list[-1], expected_call) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_selected_confirm(self, mock_print, *m): expected_call = print_call(state="confirm-active") with self.assertRaises(MockException): cutie.select_multiple([], hide_confirm=False) self.assertEqual(mock_print.call_args_list[-1], expected_call) @mock.patch("cutie.readchar.readkey", side_effect=MockException) @mock.patch("cutie.print") def test_print_show_confirm(self, mock_print, *m): expected_calls = [print_call("foo", "active"), print_call(state="confirm")] with self.assertRaises(MockException): cutie.select_multiple(["foo"], hide_confirm=False) self.assertEqual(mock_print.call_args_list[2:], expected_calls) class TestSelectMultipleMoveAndSelect(unittest.TestCase): @mock.patch("cutie.print") def test_move_up(self, mock_print): call_args = ["foo", "bar"] expected_calls = [ print_call("foo", "active"), print_call("bar"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(readchar.key.UP, readchar.key.ENTER): cutie.select_multiple(call_args, cursor_index=1) self.assertEqual(mock_print.call_args_list[-4:], expected_calls) @mock.patch("cutie.print") def test_move_up_skip_caption(self, mock_print): call_args = ["foo", "bar", "baz"] expected_calls = [ print_call("foo", "active"), print_call("bar", "caption"), print_call("baz"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(readchar.key.UP, readchar.key.ENTER): cutie.select_multiple(call_args, cursor_index=2, caption_indices=[1]) self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_move_down(self, mock_print): call_args = ["foo", "bar"] expected_calls = [ print_call("foo"), print_call("bar", "active"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(readchar.key.DOWN, readchar.key.ENTER): cutie.select_multiple(call_args) self.assertEqual(mock_print.call_args_list[-4:], expected_calls) @mock.patch("cutie.print") def test_move_down_skip_caption(self, mock_print): call_args = ["foo", "bar", "baz"] expected_calls = [ print_call("foo"), print_call("bar", "caption"), print_call("baz", "active"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(readchar.key.DOWN, readchar.key.ENTER): cutie.select_multiple(call_args, caption_indices=[1]) self.assertEqual(mock_print.call_args_list[-5:], expected_calls) @mock.patch("cutie.print") def test_select(self, mock_print): call_args = ["foo", "bar"] expected_calls = [ print_call("foo", "selected"), print_call("bar", "active-selected"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(" ", readchar.key.DOWN, " ", readchar.key.ENTER): selected_indices = cutie.select_multiple(call_args) self.assertEqual(mock_print.call_args_list[-4:], expected_calls) self.assertEqual(selected_indices, [0, 1]) @mock.patch("cutie.print") def test_select_min_too_few(self, mock_print): call_args = ["foo"] expected_call = ( ("Must select at least 1 options\x1b[K",), {"end": "", "flush": True}, ) with InputContext(readchar.key.DOWN, readchar.key.ENTER): with self.assertRaises(MockException): cutie.select_multiple(call_args, minimal_count=1) self.assertEqual(mock_print.call_args_list[-1], expected_call) @mock.patch("cutie.print") def test_select_max_too_many(self, mock_print): call_args = ["foo"] expected_call = ( ("Must select at most 0 options\x1b[K",), {"end": "", "flush": True}, ) with InputContext(readchar.key.ENTER): with self.assertRaises(MockException): cutie.select_multiple(call_args, maximal_count=0, ticked_indices=[0]) self.assertEqual(mock_print.call_args_list[-1], expected_call) @mock.patch("cutie.print") def test_select_min_sufficient(self, mock_print): call_args = ["foo"] expected_calls = [ print_call("foo", "active-selected"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(" ", readchar.key.ENTER): selected_indices = cutie.select_multiple(call_args, minimal_count=1) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) self.assertEqual(selected_indices, [0]) @mock.patch("cutie.print") def test_deselect_on_min_sufficient(self, mock_print): call_args = ["foo", "bar"] expected_calls = [ print_call("foo", "selectable"), print_call("bar", "active-selected"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(" ", readchar.key.DOWN, readchar.key.ENTER): selected_indices = cutie.select_multiple( call_args, minimal_count=1, ticked_indices=[0, 1] ) self.assertEqual(mock_print.call_args_list[-4:], expected_calls) self.assertEqual(selected_indices, [1]) @mock.patch("cutie.print") def test_select_max_okay(self, mock_print): call_args = ["foo"] expected_calls = [ print_call("foo", "active-selected"), print_call(state="no_confirm_line"), PRINT_CALL_END, ] with InputContext(" ", readchar.key.ENTER): selected_indices = cutie.select_multiple(call_args, maximal_count=1) self.assertEqual(mock_print.call_args_list[-3:], expected_calls) self.assertEqual(selected_indices, [0]) @mock.patch("cutie.print") def test_select_min_too_few_hide_confirm(self, mock_print): """ This should prompt the user with an error message """ call_args = ["foo"] expected_call = ( ("Must select at least 1 options\x1b[K",), {"end": "", "flush": True}, ) with InputContext(readchar.key.ENTER): with self.assertRaises(MockException): cutie.select_multiple(call_args, minimal_count=1) self.assertEqual(mock_print.call_args_list[-1], expected_call) @mock.patch("cutie.print") def test_select_max_too_many_show_confirm(self, mock_print): """ This should prompt the user with an error message """ call_args = ["foo"] expected_call = ( ("\x1b[1;32m{{ confirm }}\x1b[0m Must select at most 0 options\x1b[K",), {"end": "", "flush": True}, ) with InputContext(readchar.key.DOWN, readchar.key.ENTER): with self.assertRaises(MockException): cutie.select_multiple( call_args, maximal_count=0, ticked_indices=[0], hide_confirm=False ) self.assertEqual(mock_print.call_args_list[-1], expected_call) class TestSelectMultipleMisc(unittest.TestCase): @mock.patch("cutie.print") def test_keyboard_interrupt(self, mock_print): call_args = ["foo", "bar"] with InputContext(readchar.key.CTRL_C): with self.assertRaises(KeyboardInterrupt): cutie.select_multiple(call_args)