From 040e9d5ec0102c5fd421b95a5e804275e8792ce6 Mon Sep 17 00:00:00 2001 From: Weiduhuo <1281586535@qq.com> Date: Sun, 29 Oct 2023 15:19:41 +0800 Subject: [PATCH] add questionary module from 'tmbo/questionary/pull/330' --- requirements.txt | 1 + utils/questionary/NOTICE | 51 ++ utils/questionary/README.md | 114 ++++ utils/questionary/questionary/__init__.py | 54 ++ utils/questionary/questionary/constants.py | 49 ++ utils/questionary/questionary/form.py | 122 ++++ utils/questionary/questionary/prompt.py | 236 +++++++ .../questionary/prompts/__init__.py | 29 + .../questionary/prompts/autocomplete.py | 214 +++++++ .../questionary/prompts/checkbox.py | 300 +++++++++ .../questionary/questionary/prompts/common.py | 579 ++++++++++++++++++ .../questionary/prompts/confirm.py | 133 ++++ .../questionary/prompts/password.py | 61 ++ utils/questionary/questionary/prompts/path.py | 243 ++++++++ .../prompts/press_any_key_to_continue.py | 61 ++ .../questionary/prompts/rawselect.py | 79 +++ .../questionary/questionary/prompts/select.py | 258 ++++++++ utils/questionary/questionary/prompts/text.py | 101 +++ utils/questionary/questionary/py.typed | 0 utils/questionary/questionary/question.py | 134 ++++ utils/questionary/questionary/styles.py | 16 + utils/questionary/questionary/utils.py | 78 +++ utils/questionary/questionary/version.py | 1 + 23 files changed, 2914 insertions(+) create mode 100644 utils/questionary/NOTICE create mode 100644 utils/questionary/README.md create mode 100644 utils/questionary/questionary/__init__.py create mode 100644 utils/questionary/questionary/constants.py create mode 100644 utils/questionary/questionary/form.py create mode 100644 utils/questionary/questionary/prompt.py create mode 100644 utils/questionary/questionary/prompts/__init__.py create mode 100644 utils/questionary/questionary/prompts/autocomplete.py create mode 100644 utils/questionary/questionary/prompts/checkbox.py create mode 100644 utils/questionary/questionary/prompts/common.py create mode 100644 utils/questionary/questionary/prompts/confirm.py create mode 100644 utils/questionary/questionary/prompts/password.py create mode 100644 utils/questionary/questionary/prompts/path.py create mode 100644 utils/questionary/questionary/prompts/press_any_key_to_continue.py create mode 100644 utils/questionary/questionary/prompts/rawselect.py create mode 100644 utils/questionary/questionary/prompts/select.py create mode 100644 utils/questionary/questionary/prompts/text.py create mode 100644 utils/questionary/questionary/py.typed create mode 100644 utils/questionary/questionary/question.py create mode 100644 utils/questionary/questionary/styles.py create mode 100644 utils/questionary/questionary/utils.py create mode 100644 utils/questionary/questionary/version.py diff --git a/requirements.txt b/requirements.txt index 9922074b..fa58ac5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ pluggy httpcore pydantic jsonschema +prompt-toolkit<=3.0.36,>=2.0 diff --git a/utils/questionary/NOTICE b/utils/questionary/NOTICE new file mode 100644 index 00000000..051758ad --- /dev/null +++ b/utils/questionary/NOTICE @@ -0,0 +1,51 @@ +Tom Bocklisch +Copyright 2019 Tom Bocklisch + +---- + +This product includes software from PyInquirer (https://github.com/CITGuru/PyInquirer), +under the MIT License. + +Copyright 2018 Oyetoke Toby and contributors + +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. + +---- + +This product includes software from whaaaaat (https://github.com/finklabs/whaaaaat), +under the MIT License. + +Copyright 2016 Fink Labs GmbH and inquirerpy contributors + +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 diff --git a/utils/questionary/README.md b/utils/questionary/README.md new file mode 100644 index 00000000..90d04d3c --- /dev/null +++ b/utils/questionary/README.md @@ -0,0 +1,114 @@ +# Questionary + +[![Version](https://img.shields.io/pypi/v/questionary.svg)](https://pypi.org/project/questionary/) +[![License](https://img.shields.io/pypi/l/questionary.svg)](#) +[![Continuous Integration](https://github.com/tmbo/questionary/workflows/Continuous%20Integration/badge.svg)](#) +[![Coverage](https://coveralls.io/repos/github/tmbo/questionary/badge.svg?branch=master)](https://coveralls.io/github/tmbo/questionary?branch=master) +[![Supported Python Versions](https://img.shields.io/pypi/pyversions/questionary.svg)](https://pypi.python.org/pypi/questionary) +[![Documentation](https://readthedocs.org/projects/questionary/badge/?version=latest)](https://questionary.readthedocs.io/en/latest/?badge=latest) + +✨ Questionary is a Python library for effortlessly building pretty command line interfaces ✨ + +* [Features](#features) +* [Installation](#installation) +* [Usage](#usage) +* [Documentation](#documentation) +* [Support](#support) + + +![Example](https://raw.githubusercontent.com/tmbo/questionary/master/docs/images/example.gif) + +```python3 +import questionary + +questionary.text("What's your first name").ask() +questionary.password("What's your secret?").ask() +questionary.confirm("Are you amazed?").ask() + +questionary.select( + "What do you want to do?", + choices=["Order a pizza", "Make a reservation", "Ask for opening hours"], +).ask() + +questionary.rawselect( + "What do you want to do?", + choices=["Order a pizza", "Make a reservation", "Ask for opening hours"], +).ask() + +questionary.checkbox( + "Select toppings", choices=["foo", "bar", "bazz"] +).ask() + +questionary.path("Path to the projects version file").ask() +``` + +Used and supported by + +[](https://github.com/RasaHQ/rasa) + +## Features + +Questionary supports the following input prompts: + + * [Text](https://questionary.readthedocs.io/en/stable/pages/types.html#text) + * [Password](https://questionary.readthedocs.io/en/stable/pages/types.html#password) + * [File Path](https://questionary.readthedocs.io/en/stable/pages/types.html#file-path) + * [Confirmation](https://questionary.readthedocs.io/en/stable/pages/types.html#confirmation) + * [Select](https://questionary.readthedocs.io/en/stable/pages/types.html#select) + * [Raw select](https://questionary.readthedocs.io/en/stable/pages/types.html#raw-select) + * [Checkbox](https://questionary.readthedocs.io/en/stable/pages/types.html#checkbox) + * [Autocomplete](https://questionary.readthedocs.io/en/stable/pages/types.html#autocomplete) + +There is also a helper to [print formatted text](https://questionary.readthedocs.io/en/stable/pages/types.html#printing-formatted-text) +for when you want to spice up your printed messages a bit. + +## Installation + +Use the package manager [pip](https://pip.pypa.io/en/stable/) to install Questionary: + +```bash +$ pip install questionary +✨🎂✨ +``` + +## Usage + +```python +import questionary + +questionary.select( + "What do you want to do?", + choices=[ + 'Order a pizza', + 'Make a reservation', + 'Ask for opening hours' + ]).ask() # returns value of selection +``` + +That's all it takes to create a prompt! Have a [look at the documentation](https://questionary.readthedocs.io/) +for some more examples. + +## Documentation + +Documentation for Questionary is available [here](https://questionary.readthedocs.io/). + +## Support + +Please [open an issue](https://github.com/tmbo/questionary/issues/new) +with enough information for us to reproduce your problem. +A [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) +would be very helpful. + +## Contributing + +Contributions are very much welcomed and appreciated. Head over to the documentation on [how to contribute](https://questionary.readthedocs.io/en/stable/pages/contributors.html#steps-for-submitting-code). + +## Authors and Acknowledgment + +Questionary is written and maintained by Tom Bocklisch and Kian Cross. + +It is based on the great work by [Oyetoke Toby](https://github.com/CITGuru/PyInquirer) +and [Mark Fink](https://github.com/finklabs/whaaaaat). + +## License +Licensed under the [MIT License](https://github.com/tmbo/questionary/blob/master/LICENSE). Copyright 2021 Tom Bocklisch. diff --git a/utils/questionary/questionary/__init__.py b/utils/questionary/questionary/__init__.py new file mode 100644 index 00000000..1d14551f --- /dev/null +++ b/utils/questionary/questionary/__init__.py @@ -0,0 +1,54 @@ +# noinspection PyUnresolvedReferences +from prompt_toolkit.styles import Style +from prompt_toolkit.validation import ValidationError +from prompt_toolkit.validation import Validator + +from .version import __version__ +from .form import Form +from .form import FormField +from .form import form +from .prompt import prompt +from .prompt import unsafe_prompt + +# import the shortcuts to create single question prompts +from .prompts.autocomplete import autocomplete +from .prompts.checkbox import checkbox +from .prompts.common import Choice +from .prompts.common import Separator +from .prompts.common import print_formatted_text as print +from .prompts.confirm import confirm +from .prompts.password import password +from .prompts.path import path +from .prompts.press_any_key_to_continue import press_any_key_to_continue +from .prompts.rawselect import rawselect +from .prompts.select import select +from .prompts.text import text +from .question import Question + +__all__ = [ + "__version__", + # question types + "autocomplete", + "checkbox", + "confirm", + "password", + "path", + "press_any_key_to_continue", + "rawselect", + "select", + "text", + # utility methods + "print", + "form", + "prompt", + "unsafe_prompt", + # commonly used classes + "Form", + "FormField", + "Question", + "Choice", + "Style", + "Separator", + "Validator", + "ValidationError", +] diff --git a/utils/questionary/questionary/constants.py b/utils/questionary/questionary/constants.py new file mode 100644 index 00000000..768c481f --- /dev/null +++ b/utils/questionary/questionary/constants.py @@ -0,0 +1,49 @@ +from . import Style + +# Value to display as an answer when "affirming" a confirmation question +YES = "Yes" + +# Value to display as an answer when "denying" a confirmation question +NO = "No" + +# Instruction text for a confirmation question (yes is default) +YES_OR_NO = "(Y/n)" + +# Instruction text for a confirmation question (no is default) +NO_OR_YES = "(y/N)" + +# Instruction for multiline input +INSTRUCTION_MULTILINE = "(Finish with 'Alt+Enter' or 'Esc then Enter')\n>" + +# Selection token used to indicate the selection cursor in a list +DEFAULT_SELECTED_POINTER = "»" + +# Item prefix to identify selected items in a checkbox list +INDICATOR_SELECTED = "●" + +# Item prefix to identify unselected items in a checkbox list +INDICATOR_UNSELECTED = "○" + +# Prefix displayed in front of questions +DEFAULT_QUESTION_PREFIX = "?" + +# Message shown when a user aborts a question prompt using CTRL-C +DEFAULT_KBI_MESSAGE = "Cancelled by user" + +# Default text shown when the input is invalid +INVALID_INPUT = "Invalid input" + +# Default message style +DEFAULT_STYLE = Style( + [ + ("qmark", "fg:#5f819d"), # token in front of the question + ("question", "bold"), # question text + ("answer", "fg:#FF9D00 bold"), # submitted answer text behind the question + ("pointer", ""), # pointer used in select and checkbox prompts + ("selected", ""), # style for a selected item of a checkbox + ("separator", ""), # separator in lists + ("instruction", ""), # user instructions for select, rawselect, checkbox + ("text", ""), # any other text + ("instruction", ""), # user instructions for select, rawselect, checkbox + ] +) diff --git a/utils/questionary/questionary/form.py b/utils/questionary/questionary/form.py new file mode 100644 index 00000000..b5d865f2 --- /dev/null +++ b/utils/questionary/questionary/form.py @@ -0,0 +1,122 @@ + +from typing import Any +from typing import Dict +from typing import NamedTuple +from typing import Sequence + +from .constants import DEFAULT_KBI_MESSAGE +from .question import Question + + +class FormField(NamedTuple): + """ + Represents a question within a form + + Args: + key: The name of the form field. + question: The question to ask in the form field. + """ + + key: str + question: Question + + +def form(**kwargs: Question) -> "Form": + """Create a form with multiple questions. + + The parameter name of a question will be the key for the answer in + the returned dict. + + Args: + kwargs: Questions to ask in the form. + """ + return Form(*(FormField(k, q) for k, q in kwargs.items())) + + +class Form: + """Multi question prompts. Questions are asked one after another. + + All the answers are returned as a dict with one entry per question. + + This class should not be invoked directly, instead use :func:`form`. + """ + + form_fields: Sequence[FormField] + + def __init__(self, *form_fields: FormField) -> None: + self.form_fields = form_fields + + def unsafe_ask(self, patch_stdout: bool = False) -> Dict[str, Any]: + """Ask the questions synchronously and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + The answers from the form. + """ + return {f.key: f.question.unsafe_ask(patch_stdout) for f in self.form_fields} + + async def unsafe_ask_async(self, patch_stdout: bool = False) -> Dict[str, Any]: + """Ask the questions using asyncio and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + The answers from the form. + """ + return { + f.key: await f.question.unsafe_ask_async(patch_stdout) + for f in self.form_fields + } + + def ask( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Dict[str, Any]: + """Ask the questions synchronously and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + The answers from the form. + """ + try: + return self.unsafe_ask(patch_stdout) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} + + async def ask_async( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Dict[str, Any]: + """Ask the questions using asyncio and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + The answers from the form. + """ + try: + return await self.unsafe_ask_async(patch_stdout) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} diff --git a/utils/questionary/questionary/prompt.py b/utils/questionary/questionary/prompt.py new file mode 100644 index 00000000..b4d18017 --- /dev/null +++ b/utils/questionary/questionary/prompt.py @@ -0,0 +1,236 @@ +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Mapping +from typing import Optional +from typing import Union + +from prompt_toolkit.output import ColorDepth + +from . import utils +from .constants import DEFAULT_KBI_MESSAGE +from .prompts import AVAILABLE_PROMPTS +from .prompts import prompt_by_name +from .prompts.common import print_formatted_text + + +class PromptParameterException(ValueError): + """Received a prompt with a missing parameter.""" + + def __init__(self, message: str, errors: Optional[BaseException] = None) -> None: + # Call the base class constructor with the parameters it needs + super().__init__(f"You must provide a `{message}` value", errors) + + +def prompt( + questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]], + answers: Optional[Mapping[str, Any]] = None, + patch_stdout: bool = False, + true_color: bool = False, + kbi_msg: str = DEFAULT_KBI_MESSAGE, + **kwargs: Any, +) -> Dict[str, Any]: + """Prompt the user for input on all the questions. + + Catches keyboard interrupts and prints a message. + + See :func:`unsafe_prompt` for possible question configurations. + + Args: + questions: A list of question configs representing questions to + ask. A question config may have the following options: + + * type - The type of question. + * name - An ID for the question (to identify it in the answers :obj:`dict`). + + * when - Callable to conditionally show the question. This function + takes a :obj:`dict` representing the current answers. + + * filter - Function that the answer is passed to. The return value of this + function is saved as the answer. + + Additional options correspond to the parameter names for + particular question types. + + answers: Default answers. + + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + true_color: Use true color output. + + color_depth: Color depth to use. If ``true_color`` is set to true then this + value is ignored. + + type: Default ``type`` value to use in question config. + filter: Default ``filter`` value to use in question config. + name: Default ``name`` value to use in question config. + when: Default ``when`` value to use in question config. + default: Default ``default`` value to use in question config. + kwargs: Additional options passed to every question. + + Returns: + Dictionary of question answers. + """ + + try: + return unsafe_prompt(questions, answers, patch_stdout, true_color, **kwargs) + except KeyboardInterrupt: + print("") + print(kbi_msg) + print("") + return {} + + +def unsafe_prompt( + questions: Union[Dict[str, Any], Iterable[Mapping[str, Any]]], + answers: Optional[Mapping[str, Any]] = None, + patch_stdout: bool = False, + true_color: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + """Prompt the user for input on all the questions. + + Won't catch keyboard interrupts. + + Args: + questions: A list of question configs representing questions to + ask. A question config may have the following options: + + * type - The type of question. + * name - An ID for the question (to identify it in the answers :obj:`dict`). + + * when - Callable to conditionally show the question. This function + takes a :obj:`dict` representing the current answers. + + * filter - Function that the answer is passed to. The return value of this + function is saved as the answer. + + Additional options correspond to the parameter names for + particular question types. + + answers: Default answers. + + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + true_color: Use true color output. + + color_depth: Color depth to use. If ``true_color`` is set to true then this + value is ignored. + + type: Default ``type`` value to use in question config. + filter: Default ``filter`` value to use in question config. + name: Default ``name`` value to use in question config. + when: Default ``when`` value to use in question config. + default: Default ``default`` value to use in question config. + kwargs: Additional options passed to every question. + + Returns: + Dictionary of question answers. + + Raises: + KeyboardInterrupt: raised on keyboard interrupt + """ + + if isinstance(questions, dict): + questions = [questions] + + answers = dict(answers or {}) + + for question_config in questions: + question_config = dict(question_config) + # import the question + if "type" not in question_config: + raise PromptParameterException("type") + # every type except 'print' needs a name + if "name" not in question_config and question_config["type"] != "print": + raise PromptParameterException("name") + + _kwargs = kwargs.copy() + _kwargs.update(question_config) + + _type = _kwargs.pop("type") + _filter = _kwargs.pop("filter", None) + name = _kwargs.pop("name", None) if _type == "print" else _kwargs.pop("name") + when = _kwargs.pop("when", None) + + if true_color: + _kwargs["color_depth"] = ColorDepth.TRUE_COLOR + + if when: + # at least a little sanity check! + if callable(question_config["when"]): + try: + if not question_config["when"](answers): + continue + except Exception as exception: + raise ValueError( + f"Problem in 'when' check of " f"{name} question: {exception}" + ) from exception + else: + raise ValueError( + "'when' needs to be function that accepts a dict argument" + ) + + # handle 'print' type + if _type == "print": + try: + message = _kwargs.pop("message") + except KeyError as e: + raise PromptParameterException("message") from e + + # questions can take 'input' arg but print_formatted_text does not + # Remove 'input', if present, to avoid breaking during tests + _kwargs.pop("input", None) + + print_formatted_text(message, **_kwargs) + if name: + answers[name] = None + continue + + choices = question_config.get("choices") + if choices is not None and callable(choices): + calculated_choices = choices(answers) + question_config["choices"] = calculated_choices + kwargs["choices"] = calculated_choices + + if _filter: + # at least a little sanity check! + if not callable(_filter): + raise ValueError( + "'filter' needs to be function that accepts an argument" + ) + + if callable(question_config.get("default")): + _kwargs["default"] = question_config["default"](answers) + + create_question_func = prompt_by_name(_type) + + if not create_question_func: + raise ValueError( + f"No question type '{_type}' found. " + f"Known question types are {', '.join(AVAILABLE_PROMPTS)}." + ) + + missing_args = list(utils.missing_arguments(create_question_func, _kwargs)) + if missing_args: + raise PromptParameterException(missing_args[0]) + + question = create_question_func(**_kwargs) + + answer = question.unsafe_ask(patch_stdout) + + if answer is not None: + if _filter: + try: + answer = _filter(answer) + except Exception as exception: + raise ValueError( + f"Problem processing 'filter' of {name} " + f"question: {exception}" + ) from exception + answers[name] = answer + + return answers diff --git a/utils/questionary/questionary/prompts/__init__.py b/utils/questionary/questionary/prompts/__init__.py new file mode 100644 index 00000000..ee7b8f7f --- /dev/null +++ b/utils/questionary/questionary/prompts/__init__.py @@ -0,0 +1,29 @@ +from . import autocomplete +from . import checkbox +from . import confirm +from . import password +from . import path +from . import press_any_key_to_continue +from . import rawselect +from . import select +from . import text + +AVAILABLE_PROMPTS = { + "autocomplete": autocomplete.autocomplete, + "confirm": confirm.confirm, + "text": text.text, + "select": select.select, + "rawselect": rawselect.rawselect, + "password": password.password, + "checkbox": checkbox.checkbox, + "path": path.path, + "press_any_key_to_continue": press_any_key_to_continue.press_any_key_to_continue, + # backwards compatible names + "list": select.select, + "rawlist": rawselect.rawselect, + "input": text.text, +} + + +def prompt_by_name(name): + return AVAILABLE_PROMPTS.get(name) diff --git a/utils/questionary/questionary/prompts/autocomplete.py b/utils/questionary/questionary/prompts/autocomplete.py new file mode 100644 index 00000000..79d78baf --- /dev/null +++ b/utils/questionary/questionary/prompts/autocomplete.py @@ -0,0 +1,214 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.completion import Completer +from prompt_toolkit.completion import Completion +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import CompleteStyle +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +class WordCompleter(Completer): + choices_source: Union[List[str], Callable[[], List[str]]] + ignore_case: bool + meta_information: Dict[str, Any] + match_middle: bool + + def __init__( + self, + choices: Union[List[str], Callable[[], List[str]]], + ignore_case: bool = True, + meta_information: Optional[Dict[str, Any]] = None, + match_middle: bool = True, + ) -> None: + self.choices_source = choices + self.ignore_case = ignore_case + self.meta_information = meta_information or {} + self.match_middle = match_middle + + def _choices(self) -> Iterable[str]: + return ( + self.choices_source() + if callable(self.choices_source) + else self.choices_source + ) + + def _choice_matches(self, word_before_cursor: str, choice: str) -> int: + """Match index if found, -1 if not.""" + + if self.ignore_case: + choice = choice.lower() + + if self.match_middle: + return choice.find(word_before_cursor) + elif choice.startswith(word_before_cursor): + return 0 + else: + return -1 + + @staticmethod + def _display_for_choice(choice: str, index: int, word_before_cursor: str) -> HTML: + return HTML("{}{}{}").format( + choice[:index], + choice[index : index + len(word_before_cursor)], # noqa: E203 + choice[index + len(word_before_cursor) : len(choice)], # noqa: E203 + ) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + choices = self._choices() + + # Get word/text before cursor. + word_before_cursor = document.text_before_cursor + + if self.ignore_case: + word_before_cursor = word_before_cursor.lower() + + for choice in choices: + index = self._choice_matches(word_before_cursor, choice) + if index == -1: + # didn't find a match + continue + + display_meta = self.meta_information.get(choice, "") + display = self._display_for_choice(choice, index, word_before_cursor) + + yield Completion( + choice, + start_position=-len(choice), + display=display.formatted_text, + display_meta=display_meta, + style="class:answer", + selected_style="class:selected", + ) + + +def autocomplete( + message: str, + choices: List[str], + default: str = "", + qmark: str = DEFAULT_QUESTION_PREFIX, + completer: Optional[Completer] = None, + meta_information: Optional[Dict[str, Any]] = None, + ignore_case: bool = True, + match_middle: bool = True, + complete_style: CompleteStyle = CompleteStyle.COLUMN, + validate: Any = None, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """Prompt the user to enter a message with autocomplete help. + + Example: + >>> import questionary + >>> questionary.autocomplete( + ... 'Choose ant specie', + ... choices=[ + ... 'Camponotus pennsylvanicus', + ... 'Linepithema humile', + ... 'Eciton burchellii', + ... "Atta colombica", + ... 'Polyergus lucidus', + ... 'Polyergus rufescens', + ... ]).ask() + ? Choose ant specie Atta colombica + 'Atta colombica' + + .. image:: ../images/autocomplete.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this contains items as strings + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?`` + + completer: A prompt_toolkit :class:`prompt_toolkit.completion.Completion` + implementation. If not set, a questionary completer implementation + will be used. + + meta_information: A dictionary with information/anything about choices. + + ignore_case: If true autocomplete would ignore case. + + match_middle: If true autocomplete would search in every string position + not only in string begin. + + complete_style: How autocomplete menu would be shown, it could be ``COLUMN`` + ``MULTI_COLUMN`` or ``READLINE_LIKE`` from + :class:`prompt_toolkit.shortcuts.CompleteStyle`. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + return [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + def get_meta_style(meta: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if meta: + for key in meta: + meta[key] = HTML("{}").format(meta[key]) + + return meta + + validator = build_validator(validate) + + if completer is None: + if not choices: + raise ValueError("No choices is given, you should use Text question.") + # use the default completer + completer = WordCompleter( + choices, + ignore_case=ignore_case, + meta_information=get_meta_style(meta_information), + match_middle=match_middle, + ) + + p: PromptSession = PromptSession( + get_prompt_tokens, + lexer=SimpleLexer("class:answer"), + style=merged_style, + completer=completer, + validator=validator, + complete_style=complete_style, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/prompts/checkbox.py b/utils/questionary/questionary/prompts/checkbox.py new file mode 100644 index 00000000..35db6ff0 --- /dev/null +++ b/utils/questionary/questionary/prompts/checkbox.py @@ -0,0 +1,300 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import Union + +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from .. import utils +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..constants import INVALID_INPUT +from ..prompts import common +from ..prompts.common import Choice +from ..prompts.common import InquirerControl +from ..prompts.common import Separator +from ..question import Question +from ..styles import merge_styles_default + + +def checkbox( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[str] = None, + validate: Callable[[List[str]], Union[bool, str]] = lambda a: True, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, + use_arrow_keys: bool = True, + use_jk_keys: bool = True, + use_emacs_keys: bool = True, + instruction: Optional[str] = None, + show_description: bool = True, + **kwargs: Any, +) -> Question: + """Ask the user to select from a list of items. + + This is a multiselect, the user can choose one, none or many of the + items. + + Example: + >>> import questionary + >>> questionary.checkbox( + ... 'Select toppings', + ... choices=[ + ... "Cheese", + ... "Tomato", + ... "Pineapple", + ... ]).ask() + ? Select toppings done (2 selections) + ['Cheese', 'Pineapple'] + + .. image:: ../images/checkbox.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: Default return value (single value). If you want to preselect + multiple items, use ``Choice("foo", checked=True)`` instead. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This should be a function accepting the input and + returning a boolean. Alternatively, the return value + may be a string (indicating failure), which contains + the error message to be displayed. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + initial_choice: A value corresponding to a selectable item in the choices, + to initially set the pointer position to. + + use_arrow_keys: Allow the user to select items from the list using + arrow keys. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. + + use_emacs_keys: Allow the user to select items from the list using + `Ctrl+N` (down) and `Ctrl+P` (up) keys. + instruction: A message describing how to navigate the menu. + + show_description: Display description of current selection if available. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + + if not (use_arrow_keys or use_jk_keys or use_emacs_keys): + raise ValueError( + "Some option to move the selection is required. Arrow keys or j/k or " + "Emacs keys." + ) + + merged_style = merge_styles_default( + [ + # Disable the default inverted colours bottom-toolbar behaviour (for + # the error message). However it can be re-enabled with a custom + # style. + Style([("bottom-toolbar", "noreverse")]), + style, + ] + ) + + if not callable(validate): + raise ValueError("validate must be callable") + + ic = InquirerControl( + choices, + default, + pointer=pointer, + initial_choice=initial_choice, + show_description=show_description, + ) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + tokens = [] + + tokens.append(("class:qmark", qmark)) + tokens.append(("class:question", " {} ".format(message))) + + if ic.is_answered: + nbr_selected = len(ic.selected_options) + if nbr_selected == 0: + tokens.append(("class:answer", "done")) + elif nbr_selected == 1: + if isinstance(ic.get_selected_values()[0].title, list): + ts = ic.get_selected_values()[0].title + tokens.append( + ( + "class:answer", + "".join([token[1] for token in ts]), # type:ignore + ) + ) + else: + tokens.append( + ( + "class:answer", + "[{}]".format(ic.get_selected_values()[0].title), + ) + ) + else: + tokens.append( + ("class:answer", "done ({} selections)".format(nbr_selected)) + ) + else: + if instruction is not None: + tokens.append(("class:instruction", instruction)) + else: + tokens.append( + ( + "class:instruction", + "(Use arrow keys to move, " + " to select, " + " to toggle, " + " to invert)", + ) + ) + return tokens + + def get_selected_values() -> List[Any]: + return [c.value for c in ic.get_selected_values()] + + def perform_validation(selected_values: List[str]) -> bool: + verdict = validate(selected_values) + valid = verdict is True + + if not valid: + if verdict is False: + error_text = INVALID_INPUT + else: + error_text = str(verdict) + + error_message = FormattedText([("class:validation-toolbar", error_text)]) + + ic.error_message = ( + error_message if not valid and ic.submission_attempted else None # type: ignore[assignment] + ) + + return valid + + layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @bindings.add(" ", eager=True) + def toggle(_event): + pointed_choice = ic.get_pointed_at().value + if pointed_choice in ic.selected_options: + ic.selected_options.remove(pointed_choice) + else: + ic.selected_options.append(pointed_choice) + + perform_validation(get_selected_values()) + + @bindings.add("i", eager=True) + def invert(_event): + inverted_selection = [ + c.value + for c in ic.choices + if not isinstance(c, Separator) + and c.value not in ic.selected_options + and not c.disabled + ] + ic.selected_options = inverted_selection + + perform_validation(get_selected_values()) + + @bindings.add("a", eager=True) + def all(_event): + all_selected = True # all choices have been selected + for c in ic.choices: + if ( + not isinstance(c, Separator) + and c.value not in ic.selected_options + and not c.disabled + ): + # add missing ones + ic.selected_options.append(c.value) + all_selected = False + if all_selected: + ic.selected_options = [] + + perform_validation(get_selected_values()) + + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): + ic.select_next() + + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): + ic.select_previous() + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + + if use_emacs_keys: + bindings.add(Keys.ControlN, eager=True)(move_cursor_down) + bindings.add(Keys.ControlP, eager=True)(move_cursor_up) + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + selected_values = get_selected_values() + ic.submission_attempted = True + + if perform_validation(selected_values): + ic.is_answered = True + event.app.exit(result=selected_values) + + @bindings.add(Keys.Any) + def other(_event): + """Disallow inserting other text.""" + + return Question( + Application( + layout=layout, + key_bindings=bindings, + style=merged_style, + **utils.used_kwargs(kwargs, Application.__init__), + ) + ) diff --git a/utils/questionary/questionary/prompts/common.py b/utils/questionary/questionary/prompts/common.py new file mode 100644 index 00000000..634136d0 --- /dev/null +++ b/utils/questionary/questionary/prompts/common.py @@ -0,0 +1,579 @@ +import inspect +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple +from typing import Union + +from prompt_toolkit import PromptSession +from prompt_toolkit.filters import Always +from prompt_toolkit.filters import Condition +from prompt_toolkit.filters import IsDone +from prompt_toolkit.layout import ConditionalContainer +from prompt_toolkit.layout import FormattedTextControl +from prompt_toolkit.layout import HSplit +from prompt_toolkit.layout import Layout +from prompt_toolkit.layout import Window +from prompt_toolkit.styles import Style +from prompt_toolkit.validation import ValidationError +from prompt_toolkit.validation import Validator + +from ..constants import DEFAULT_SELECTED_POINTER +from ..constants import DEFAULT_STYLE +from ..constants import INDICATOR_SELECTED +from ..constants import INDICATOR_UNSELECTED +from ..constants import INVALID_INPUT + +# This is a cut-down version of `prompt_toolkit.formatted_text.AnyFormattedText` +# which does not exist in v2 of prompt_toolkit +FormattedText = Union[ + str, + List[Tuple[str, str]], + List[Tuple[str, str, Callable[[Any], None]]], + None, +] + + +class Choice: + """One choice in a :meth:`select`, :meth:`rawselect` or :meth:`checkbox`. + + Args: + title: Text shown in the selection list. + + value: Value returned, when the choice is selected. If this argument + is `None` or unset, then the value of `title` is used. + + disabled: If set, the choice can not be selected by the user. The + provided text is used to explain, why the selection is + disabled. + + checked: Preselect this choice when displaying the options. + + shortcut_key: Key shortcut used to select this item. + + description: Optional description of the item that can be displayed. + """ + + title: FormattedText + """Display string for the choice""" + + value: Optional[Any] + """Value of the choice""" + + disabled: Optional[str] + """Whether the choice can be selected""" + + checked: Optional[bool] + """Whether the choice is initially selected""" + + shortcut_key: Optional[str] + """A shortcut key for the choice""" + + description: Optional[str] + """Choice description""" + + def __init__( + self, + title: FormattedText, + value: Optional[Any] = None, + disabled: Optional[str] = None, + checked: Optional[bool] = False, + shortcut_key: Optional[Union[str, bool]] = True, + description: Optional[str] = None, + ) -> None: + self.disabled = disabled + self.title = title + self.checked = checked if checked is not None else False + self.description = description + + if value is not None: + self.value = value + elif isinstance(title, list): + self.value = "".join([token[1] for token in title]) + else: + self.value = title + + if shortcut_key is not None: + if isinstance(shortcut_key, bool): + self.auto_shortcut = shortcut_key + self.shortcut_key = None + else: + self.shortcut_key = str(shortcut_key) + self.auto_shortcut = False + else: + self.shortcut_key = None + self.auto_shortcut = True + + @staticmethod + def build(c: Union[str, "Choice", Dict[str, Any]]) -> "Choice": + """Create a choice object from different representations. + + Args: + c: Either a :obj:`str`, :class:`Choice` or :obj:`dict` with + ``name``, ``value``, ``disabled``, ``checked`` and + ``key`` properties. + + Returns: + An instance of the :class:`Choice` object. + """ + + if isinstance(c, Choice): + return c + elif isinstance(c, str): + return Choice(c, c) + else: + return Choice( + c.get("name"), + c.get("value"), + c.get("disabled", None), + c.get("checked"), + c.get("key"), + c.get("description", None), + ) + + def get_shortcut_title(self): + if self.shortcut_key is None: + return "-) " + else: + return "{}) ".format(self.shortcut_key) + + +class Separator(Choice): + """Used to space/separate choices group.""" + + default_separator: str = "-" * 15 + """The default separator used if none is specified""" + + line: str + """The string being used as a separator""" + + def __init__(self, line: Optional[str] = None) -> None: + """Create a separator in a list. + + Args: + line: Text to be displayed in the list, by default uses ``---``. + """ + + self.line = line or self.default_separator + super().__init__(self.line, None, "-") + + +class InquirerControl(FormattedTextControl): + SHORTCUT_KEYS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + ] + + choices: List[Choice] + default: Optional[Union[str, Choice, Dict[str, Any]]] + selected_options: List[Any] + use_indicator: bool + use_shortcuts: bool + use_arrow_keys: bool + pointer: Optional[str] + pointed_at: int + is_answered: bool + show_description: bool + + def __init__( + self, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[Union[str, Choice, Dict[str, Any]]] = None, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + use_indicator: bool = True, + use_shortcuts: bool = False, + show_selected: bool = False, + show_description: bool = True, + use_arrow_keys: bool = True, + initial_choice: Optional[Union[str, Choice, Dict[str, Any]]] = None, + **kwargs: Any, + ): + self.use_indicator = use_indicator + self.use_shortcuts = use_shortcuts + self.show_selected = show_selected + self.show_description = show_description + self.use_arrow_keys = use_arrow_keys + self.default = default + self.pointer = pointer + + if isinstance(default, Choice): + default = default.value + + choices_values = [ + choice.value for choice in choices if isinstance(choice, Choice) + ] + + if ( + default is not None + and default not in choices + and default not in choices_values + ): + raise ValueError( + f"Invalid `default` value passed. The value (`{default}`) " + f"does not exist in the set of choices. Please make sure the " + f"default value is one of the available choices." + ) + + if initial_choice is None: + pointed_at = None + elif initial_choice in choices: + pointed_at = choices.index(initial_choice) + elif initial_choice in choices_values: + for k, choice in enumerate(choices): + if isinstance(choice, Choice): + if choice.value == initial_choice: + pointed_at = k + break + + else: + raise ValueError( + f"Invalid `initial_choice` value passed. The value " + f"(`{initial_choice}`) does not exist in " + f"the set of choices. Please make sure the initial value is " + f"one of the available choices." + ) + + self.is_answered = False + self.choices = [] + self.submission_attempted = False + self.error_message = None + self.selected_options = [] + + self._init_choices(choices, pointed_at) + self._assign_shortcut_keys() + + super().__init__(self._get_choice_tokens, **kwargs) + + if not self.is_selection_valid(): + raise ValueError( + f"Invalid 'initial_choice' value ('{initial_choice}'). " + f"It must be a selectable value." + ) + + def _is_selected(self, choice: Choice): + if isinstance(self.default, Choice): + compare_default = self.default == choice + else: + compare_default = self.default == choice.value + return choice.checked or compare_default and self.default is not None + + def _assign_shortcut_keys(self): + available_shortcuts = self.SHORTCUT_KEYS[:] + + # first, make sure we do not double assign a shortcut + for c in self.choices: + if c.shortcut_key is not None: + if c.shortcut_key in available_shortcuts: + available_shortcuts.remove(c.shortcut_key) + else: + raise ValueError( + "Invalid shortcut '{}'" + "for choice '{}'. Shortcuts " + "should be single characters or numbers. " + "Make sure that all your shortcuts are " + "unique.".format(c.shortcut_key, c.title) + ) + + shortcut_idx = 0 + for c in self.choices: + if c.auto_shortcut and not c.disabled: + c.shortcut_key = available_shortcuts[shortcut_idx] + shortcut_idx += 1 + + if shortcut_idx == len(available_shortcuts): + break # fail gracefully if we run out of shortcuts + + def _init_choices( + self, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + pointed_at: Optional[int], + ): + # helper to convert from question format to internal format + self.choices = [] + + if pointed_at is not None: + self.pointed_at = pointed_at + + for i, c in enumerate(choices): + choice = Choice.build(c) + + if self._is_selected(choice): + self.selected_options.append(choice.value) + + if pointed_at is None and not choice.disabled: + # find the first (available) choice + self.pointed_at = pointed_at = i + + self.choices.append(choice) + + @property + def choice_count(self) -> int: + return len(self.choices) + + def _get_choice_tokens(self): + tokens = [] + + def append(index: int, choice: Choice): + # use value to check if option has been selected + selected = choice.value in self.selected_options + + if index == self.pointed_at: + if self.pointer is not None: + tokens.append(("class:pointer", " {} ".format(self.pointer))) + else: + tokens.append(("class:text", " " * 3)) + + tokens.append(("[SetCursorPosition]", "")) + else: + pointer_length = len(self.pointer) if self.pointer is not None else 1 + tokens.append(("class:text", " " * (2 + pointer_length))) + + if isinstance(choice, Separator): + tokens.append(("class:separator", "{}".format(choice.title))) + elif choice.disabled: # disabled + if isinstance(choice.title, list): + tokens.append( + ("class:selected" if selected else "class:disabled", "- ") + ) + tokens.extend(choice.title) + else: + tokens.append( + ( + "class:selected" if selected else "class:disabled", + "- {}".format(choice.title), + ) + ) + + tokens.append( + ( + "class:selected" if selected else "class:disabled", + "{}".format( + "" + if isinstance(choice.disabled, bool) + else " ({})".format(choice.disabled) + ), + ) + ) + else: + shortcut = choice.get_shortcut_title() if self.use_shortcuts else "" + + if selected: + if self.use_indicator: + indicator = INDICATOR_SELECTED + " " + else: + indicator = "" + + tokens.append(("class:selected", "{}".format(indicator))) + else: + if self.use_indicator: + indicator = INDICATOR_UNSELECTED + " " + else: + indicator = "" + + tokens.append(("class:text", "{}".format(indicator))) + + if isinstance(choice.title, list): + tokens.extend(choice.title) + elif selected: + tokens.append( + ("class:selected", "{}{}".format(shortcut, choice.title)) + ) + elif index == self.pointed_at: + tokens.append( + ("class:highlighted", "{}{}".format(shortcut, choice.title)) + ) + else: + tokens.append(("class:text", "{}{}".format(shortcut, choice.title))) + + tokens.append(("", "\n")) + + # prepare the select choices + for i, c in enumerate(self.choices): + append(i, c) + + if self.show_selected: + current = self.get_pointed_at() + + answer = current.get_shortcut_title() if self.use_shortcuts else "" + + answer += ( + current.title if isinstance(current.title, str) else current.title[0][1] + ) + + tokens.append(("class:text", " Answer: {}".format(answer))) + + if self.show_description: + current = self.get_pointed_at() + + description = current.description + + if description is not None: + tokens.append(("class:text", " Description: {}".format(description))) + else: + tokens.pop() # Remove last newline. + return tokens + + def is_selection_a_separator(self) -> bool: + selected = self.choices[self.pointed_at] + return isinstance(selected, Separator) + + def is_selection_disabled(self) -> Optional[str]: + return self.choices[self.pointed_at].disabled + + def is_selection_valid(self) -> bool: + return not self.is_selection_disabled() and not self.is_selection_a_separator() + + def select_previous(self) -> None: + self.pointed_at = (self.pointed_at - 1) % self.choice_count + + def select_next(self) -> None: + self.pointed_at = (self.pointed_at + 1) % self.choice_count + + def get_pointed_at(self) -> Choice: + return self.choices[self.pointed_at] + + def get_selected_values(self) -> List[Choice]: + # get values not labels + return [ + c + for c in self.choices + if (not isinstance(c, Separator) and c.value in self.selected_options) + ] + + +def build_validator(validate: Any) -> Optional[Validator]: + if validate: + if inspect.isclass(validate) and issubclass(validate, Validator): + return validate() + elif isinstance(validate, Validator): + return validate + elif callable(validate): + + class _InputValidator(Validator): + def validate(self, document): + verdict = validate(document.text) + if verdict is not True: + if verdict is False: + verdict = INVALID_INPUT + raise ValidationError( + message=verdict, cursor_position=len(document.text) + ) + + return _InputValidator() + return None + + +def _fix_unecessary_blank_lines(ps: PromptSession) -> None: + """This is a fix for additional empty lines added by prompt toolkit. + + This assumes the layout of the default session doesn't change, if it + does, this needs an update.""" + + default_container = ps.layout.container + + default_buffer_window = ( + default_container.get_children()[0].content.get_children()[1].content # type: ignore[attr-defined] + ) + + assert isinstance(default_buffer_window, Window) + # this forces the main window to stay as small as possible, avoiding + # empty lines in selections + default_buffer_window.dont_extend_height = Always() + default_buffer_window.always_hide_cursor = Always() + + +def create_inquirer_layout( + ic: InquirerControl, + get_prompt_tokens: Callable[[], List[Tuple[str, str]]], + **kwargs: Any, +) -> Layout: + """Create a layout combining question and inquirer selection.""" + + ps: PromptSession = PromptSession( + get_prompt_tokens, reserve_space_for_menu=0, **kwargs + ) + _fix_unecessary_blank_lines(ps) + + validation_prompt: PromptSession = PromptSession( + bottom_toolbar=lambda: ic.error_message, **kwargs + ) + + return Layout( + HSplit( + [ + ps.layout.container, + ConditionalContainer(Window(ic), filter=~IsDone()), + ConditionalContainer( + validation_prompt.layout.container, + filter=Condition(lambda: ic.error_message is not None), + ), + ] + ) + ) + + +def print_formatted_text(text: str, style: Optional[str] = None, **kwargs: Any) -> None: + """Print formatted text. + + Sometimes you want to spice up your printed messages a bit, + :meth:`questionary.print` is a helper to do just that. + + Example: + + >>> import questionary + >>> questionary.print("Hello World 🦄", style="bold italic fg:darkred") + Hello World 🦄 + + .. image:: ../images/print.gif + + Args: + text: Text to be printed. + style: Style used for printing. The style argument uses the + prompt :ref:`toolkit style strings `. + """ + from prompt_toolkit import print_formatted_text as pt_print + from prompt_toolkit.formatted_text import FormattedText as FText + + if style is not None: + text_style = Style([("text", style)]) + else: + text_style = DEFAULT_STYLE + + pt_print(FText([("class:text", text)]), style=text_style, **kwargs) diff --git a/utils/questionary/questionary/prompts/confirm.py b/utils/questionary/questionary/prompts/confirm.py new file mode 100644 index 00000000..1fb2c52b --- /dev/null +++ b/utils/questionary/questionary/prompts/confirm.py @@ -0,0 +1,133 @@ +from typing import Any +from typing import Optional + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import NO +from ..constants import NO_OR_YES +from ..constants import YES +from ..constants import YES_OR_NO +from ..question import Question +from ..styles import merge_styles_default + + +def confirm( + message: str, + default: bool = True, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + auto_enter: bool = True, + instruction: Optional[str] = None, + **kwargs: Any, +) -> Question: + """A yes or no question. The user can either confirm or deny. + + This question type can be used to prompt the user for a confirmation + of a yes-or-no question. If the user just hits enter, the default + value will be returned. + + Example: + >>> import questionary + >>> questionary.confirm("Are you amazed?").ask() + ? Are you amazed? Yes + True + + .. image:: ../images/confirm.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + auto_enter: If set to `False`, the user needs to press the 'enter' key to + accept their answer. If set to `True`, a valid input will be + accepted without the need to press 'Enter'. + + instruction: A message describing how to proceed through the + confirmation prompt. + Returns: + :class:`Question`: Question instance, ready to be prompted (using `.ask()`). + """ + merged_style = merge_styles_default([style]) + + status = {"answer": None, "complete": False} + + def get_prompt_tokens(): + tokens = [] + + tokens.append(("class:qmark", qmark)) + tokens.append(("class:question", " {} ".format(message))) + + if instruction is not None: + tokens.append(("class:instruction", instruction)) + elif not status["complete"]: + _instruction = YES_OR_NO if default else NO_OR_YES + tokens.append(("class:instruction", "{} ".format(_instruction))) + + if status["answer"] is not None: + answer = YES if status["answer"] else NO + tokens.append(("class:answer", answer)) + + return to_formatted_text(tokens) + + def exit_with_result(event): + status["complete"] = True + event.app.exit(result=status["answer"]) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @bindings.add("n") + @bindings.add("N") + def key_n(event): + status["answer"] = False + if auto_enter: + exit_with_result(event) + + @bindings.add("y") + @bindings.add("Y") + def key_y(event): + status["answer"] = True + if auto_enter: + exit_with_result(event) + + @bindings.add(Keys.ControlH) + def key_backspace(event): + status["answer"] = None + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + if status["answer"] is None: + status["answer"] = default + + exit_with_result(event) + + @bindings.add(Keys.Any) + def other(event): + """Disallow inserting other text.""" + + return Question( + PromptSession( + get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs + ).app + ) diff --git a/utils/questionary/questionary/prompts/password.py b/utils/questionary/questionary/prompts/password.py new file mode 100644 index 00000000..35ae893a --- /dev/null +++ b/utils/questionary/questionary/prompts/password.py @@ -0,0 +1,61 @@ +from typing import Any +from typing import Optional + +from .. import Style +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts import text +from ..question import Question + + +def password( + message: str, + default: str = "", + validate: Any = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """A text input where a user can enter a secret which won't be displayed on the CLI. + + This question type can be used to prompt the user for information + that should not be shown in the command line. The typed text will be + replaced with ``*``. + + Example: + >>> import questionary + >>> questionary.password("What's your secret?").ask() + ? What's your secret? ******** + 'secret42' + + .. image:: ../images/password.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + + return text.text( + message, default, validate, qmark, style, is_password=True, **kwargs + ) diff --git a/utils/questionary/questionary/prompts/path.py b/utils/questionary/questionary/prompts/path.py new file mode 100644 index 00000000..e43bd0fd --- /dev/null +++ b/utils/questionary/questionary/prompts/path.py @@ -0,0 +1,243 @@ +import os +from typing import Any +from typing import Callable +from typing import Iterable +from typing import List +from typing import Optional +from typing import Tuple + +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.completion import Completion +from prompt_toolkit.completion import PathCompleter +from prompt_toolkit.completion.base import Completer +from prompt_toolkit.document import Document +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import CompleteStyle +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +class GreatUXPathCompleter(PathCompleter): + """Wraps :class:`prompt_toolkit.completion.PathCompleter`. + + Makes sure completions for directories end with a path separator. Also make sure + the right path separator is used. Checks if `get_paths` returns list of existing + directories. + """ + + def __init__( + self, + only_directories: bool = False, + get_paths: Optional[Callable[[], List[str]]] = None, + file_filter: Optional[Callable[[str], bool]] = None, + min_input_len: int = 0, + expanduser: bool = False, + ) -> None: + """Adds validation of 'get_paths' to :class:`prompt_toolkit.completion.PathCompleter`. + + Args: + only_directories (bool): If True, only directories will be + returned, but no files. Defaults to False. + get_paths (Callable[[], List[str]], optional): Callable which + returns a list of directories to look into when the user enters a + relative path. If None, set to (lambda: ["."]). Defaults to None. + file_filter (Callable[[str], bool], optional): Callable which + takes a filename and returns whether this file should show up in the + completion. ``None`` when no filtering has to be done. Defaults to None. + min_input_len (int): Don't do autocompletion when the input string + is shorter. Defaults to 0. + expanduser (bool): If True, tilde (~) is expanded. Defaults to + False. + + Raises: + ValueError: If any of the by `get_paths` returned directories does not + exist. + """ + # if get_paths is None, make it return the current working dir + get_paths = get_paths or (lambda: ["."]) + # validation of get_paths + for current_path in get_paths(): + if not os.path.isdir(current_path): + raise ( + ValueError( + "\n Completer for file paths 'get_paths' must return only existing directories, but" + f" '{current_path}' does not exist." + ) + ) + # call PathCompleter __init__ + super().__init__( + only_directories=only_directories, + get_paths=get_paths, + file_filter=file_filter, + min_input_len=min_input_len, + expanduser=expanduser, + ) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + """Get completions. + + Wraps :class:`prompt_toolkit.completion.PathCompleter`. Makes sure completions + for directories end with a path separator. Also make sure the right path + separator is used. + """ + completions = super(GreatUXPathCompleter, self).get_completions( + document, complete_event + ) + + for completion in completions: + # check if the display value ends with a path separator. + # first check if display is properly set + styled_display = completion.display[0] + # styled display is a formatted text (a tuple of the text and its style) + # second tuple entry is the text + if styled_display[1][-1] == "/": + # replace separator with the OS specific one + display_text = styled_display[1][:-1] + os.path.sep + # update the styled display with the modified text + completion.display[0] = (styled_display[0], display_text) + # append the separator to the text as well - unclear why the normal + # path completer omits it from the text. this improves UX for the + # user, as they don't need to type the separator after auto-completing + # a directory + completion.text += os.path.sep + yield completion + + +def path( + message: str, + default: str = "", + qmark: str = DEFAULT_QUESTION_PREFIX, + validate: Any = None, + completer: Optional[Completer] = None, + style: Optional[Style] = None, + only_directories: bool = False, + get_paths: Optional[Callable[[], List[str]]] = None, + file_filter: Optional[Callable[[str], bool]] = None, + complete_style: CompleteStyle = CompleteStyle.MULTI_COLUMN, + **kwargs: Any, +) -> Question: + """A text input for a file or directory path with autocompletion enabled. + + Example: + >>> import questionary + >>> questionary.path( + >>> "What's the path to the projects version file?" + >>> ).ask() + ? What's the path to the projects version file? ./pyproject.toml + './pyproject.toml' + + .. image:: ../images/path.gif + + This is just a really basic example, the prompt can be customized using the + parameters. + + Args: + message: Question text. + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + complete_style: How autocomplete menu would be shown, it could be ``COLUMN`` + ``MULTI_COLUMN`` or ``READLINE_LIKE`` from + :class:`prompt_toolkit.shortcuts.CompleteStyle`. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + completer: A custom completer to use in the prompt. For more information, + see `this `_. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + only_directories: Only show directories in auto completion. This option + does not do anything if a custom ``completer`` is + passed. + + get_paths: Set a callable to generate paths to traverse for suggestions. This option + does not do anything if a custom ``completer`` is + passed. + + file_filter: Optional callable to filter suggested paths. Only paths + where the passed callable evaluates to ``True`` will show up in + the suggested paths. This does not validate the typed path, e.g. + it is still possible for the user to enter a path manually, even + though this filter evaluates to ``False``. If in addition to + filtering suggestions you also want to validate the result, use + ``validate`` in combination with the ``file_filter``. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ # noqa: W505, E501 + merged_style = merge_styles_default([style]) + + def get_prompt_tokens() -> List[Tuple[str, str]]: + return [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + validator = build_validator(validate) + + completer = completer or GreatUXPathCompleter( + get_paths=get_paths, + only_directories=only_directories, + file_filter=file_filter, + expanduser=True, + ) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event: KeyPressEvent): + if event.current_buffer.complete_state is not None: + event.current_buffer.complete_state = None + elif event.app.current_buffer.validate(set_cursor=True): + # When the validation succeeded, accept the input. + result_path = event.app.current_buffer.document.text + if result_path.endswith(os.path.sep): + result_path = result_path[:-1] + + event.app.exit(result=result_path) + event.app.current_buffer.append_to_history() + + @bindings.add(os.path.sep, eager=True) + def next_segment(event: KeyPressEvent): + b = event.app.current_buffer + + if b.complete_state: + b.complete_state = None + + current_path = b.document.text + if not current_path.endswith(os.path.sep): + b.insert_text(os.path.sep) + + b.start_completion(select_first=False) + + p: PromptSession = PromptSession( + get_prompt_tokens, + lexer=SimpleLexer("class:answer"), + style=merged_style, + completer=completer, + validator=validator, + complete_style=complete_style, + key_bindings=bindings, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/prompts/press_any_key_to_continue.py b/utils/questionary/questionary/prompts/press_any_key_to_continue.py new file mode 100644 index 00000000..402eaeaf --- /dev/null +++ b/utils/questionary/questionary/prompts/press_any_key_to_continue.py @@ -0,0 +1,61 @@ +from typing import Any +from typing import Optional + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from ..question import Question +from ..styles import merge_styles_default + + +def press_any_key_to_continue( + message: Optional[str] = None, + style: Optional[Style] = None, + **kwargs: Any, +): + """Wait until user presses any key to continue. + + Example: + >>> import questionary + >>> questionary.press_any_key_to_continue().ask() + Press any key to continue... + '' + + Args: + message: Question text. Defaults to ``"Press any key to continue..."`` + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + + if message is None: + message = "Press any key to continue..." + + def get_prompt_tokens(): + tokens = [] + + tokens.append(("class:question", f" {message} ")) + + return to_formatted_text(tokens) + + def exit_with_result(event): + event.app.exit(result=None) + + bindings = KeyBindings() + + @bindings.add(Keys.Any) + def any_key(event): + exit_with_result(event) + + return Question( + PromptSession( + get_prompt_tokens, key_bindings=bindings, style=merged_style, **kwargs + ).app + ) diff --git a/utils/questionary/questionary/prompts/rawselect.py b/utils/questionary/questionary/prompts/rawselect.py new file mode 100644 index 00000000..3fe74573 --- /dev/null +++ b/utils/questionary/questionary/prompts/rawselect.py @@ -0,0 +1,79 @@ +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence +from typing import Union + +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..prompts import select +from ..prompts.common import Choice +from ..question import Question + + +def rawselect( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[str] = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + **kwargs: Any, +) -> Question: + """Ask the user to select one item from a list of choices using shortcuts. + + The user can only select one option. + + Example: + >>> import questionary + >>> questionary.rawselect( + ... "What do you want to do?", + ... choices=[ + ... "Order a pizza", + ... "Make a reservation", + ... "Ask for opening hours" + ... ]).ask() + ? What do you want to do? Order a pizza + 'Order a pizza' + + .. image:: ../images/rawselect.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: Default return value (single value). + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + return select.select( + message, + choices, + default, + qmark, + pointer, + style, + use_shortcuts=True, + use_arrow_keys=False, + **kwargs, + ) diff --git a/utils/questionary/questionary/prompts/select.py b/utils/questionary/questionary/prompts/select.py new file mode 100644 index 00000000..6f3ef8a6 --- /dev/null +++ b/utils/questionary/questionary/prompts/select.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- + +from typing import Any +from typing import Dict +from typing import Optional +from typing import Sequence +from typing import Union + +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.keys import Keys +from prompt_toolkit.styles import Style + +from .. import utils +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import DEFAULT_SELECTED_POINTER +from ..prompts import common +from ..prompts.common import Choice +from ..prompts.common import InquirerControl +from ..prompts.common import Separator +from ..question import Question +from ..styles import merge_styles_default + + +def select( + message: str, + choices: Sequence[Union[str, Choice, Dict[str, Any]]], + default: Optional[Union[str, Choice, Dict[str, Any]]] = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + pointer: Optional[str] = DEFAULT_SELECTED_POINTER, + style: Optional[Style] = None, + use_shortcuts: bool = False, + use_arrow_keys: bool = True, + use_indicator: bool = False, + use_jk_keys: bool = True, + use_emacs_keys: bool = True, + show_selected: bool = False, + show_description: bool = True, + instruction: Optional[str] = None, + **kwargs: Any, +) -> Question: + """A list of items to select **one** option from. + + The user can pick one option and confirm it (if you want to allow + the user to select multiple options, use :meth:`questionary.checkbox` instead). + + Example: + >>> import questionary + >>> questionary.select( + ... "What do you want to do?", + ... choices=[ + ... "Order a pizza", + ... "Make a reservation", + ... "Ask for opening hours" + ... ]).ask() + ? What do you want to do? Order a pizza + 'Order a pizza' + + .. image:: ../images/select.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + + Args: + message: Question text + + choices: Items shown in the selection, this can contain :class:`Choice` or + or :class:`Separator` objects or simple items as strings. Passing + :class:`Choice` objects, allows you to configure the item more + (e.g. preselecting it or disabling it). + + default: A value corresponding to a selectable item in the choices, + to initially set the pointer position to. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + pointer: Pointer symbol in front of the currently highlighted element. + By default this is a ``»``. + Use ``None`` to disable it. + + instruction: A hint on how to navigate the menu. + It's ``(Use shortcuts)`` if only ``use_shortcuts`` is set + to True, ``(Use arrow keys or shortcuts)`` if ``use_arrow_keys`` + & ``use_shortcuts`` are set and ``(Use arrow keys)`` by default. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + use_indicator: Flag to enable the small indicator in front of the + list highlighting the current location of the selection + cursor. + + use_shortcuts: Allow the user to select items from the list using + shortcuts. The shortcuts will be displayed in front of + the list items. Arrow keys, j/k keys and shortcuts are + not mutually exclusive. + + use_arrow_keys: Allow the user to select items from the list using + arrow keys. Arrow keys, j/k keys and shortcuts are not + mutually exclusive. + + use_jk_keys: Allow the user to select items from the list using + `j` (down) and `k` (up) keys. Arrow keys, j/k keys and + shortcuts are not mutually exclusive. + + use_emacs_keys: Allow the user to select items from the list using + `Ctrl+N` (down) and `Ctrl+P` (up) keys. Arrow keys, j/k keys, + emacs keys and shortcuts are not mutually exclusive. + + show_selected: Display current selection choice at the bottom of list. + + show_description: Display description of current selection if available. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + if not (use_arrow_keys or use_shortcuts or use_jk_keys or use_emacs_keys): + raise ValueError( + ( + "Some option to move the selection is required. " + "Arrow keys, j/k keys, emacs keys, or shortcuts." + ) + ) + + if use_shortcuts and use_jk_keys: + if any(getattr(c, "shortcut_key", "") in ["j", "k"] for c in choices): + raise ValueError( + "A choice is trying to register j/k as a " + "shortcut key when they are in use as arrow keys " + "disable one or the other." + ) + + if choices is None or len(choices) == 0: + raise ValueError("A list of choices needs to be provided.") + + if use_shortcuts and len(choices) > len(InquirerControl.SHORTCUT_KEYS): + raise ValueError( + "A list with shortcuts supports a maximum of {} " + "choices as this is the maximum number " + "of keyboard shortcuts that are available. You" + "provided {} choices!" + "".format(len(InquirerControl.SHORTCUT_KEYS), len(choices)) + ) + + merged_style = merge_styles_default([style]) + + ic = InquirerControl( + choices, + default, + pointer=pointer, + use_indicator=use_indicator, + use_shortcuts=use_shortcuts, + show_selected=show_selected, + show_description=show_description, + use_arrow_keys=use_arrow_keys, + initial_choice=default, + ) + + def get_prompt_tokens(): + # noinspection PyListCreation + tokens = [("class:qmark", qmark), ("class:question", " {} ".format(message))] + + if ic.is_answered: + if isinstance(ic.get_pointed_at().title, list): + tokens.append( + ( + "class:answer", + "".join([token[1] for token in ic.get_pointed_at().title]), + ) + ) + else: + tokens.append(("class:answer", ic.get_pointed_at().title)) + else: + if instruction: + tokens.append(("class:instruction", instruction)) + else: + if use_shortcuts and use_arrow_keys: + instruction_msg = "(Use shortcuts or arrow keys)" + elif use_shortcuts and not use_arrow_keys: + instruction_msg = "(Use shortcuts)" + else: + instruction_msg = "(Use arrow keys)" + tokens.append(("class:instruction", instruction_msg)) + + return tokens + + layout = common.create_inquirer_layout(ic, get_prompt_tokens, **kwargs) + + bindings = KeyBindings() + + @bindings.add(Keys.ControlQ, eager=True) + @bindings.add(Keys.ControlC, eager=True) + def _(event): + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + if use_shortcuts: + # add key bindings for choices + for i, c in enumerate(ic.choices): + if c.shortcut_key is None and not c.disabled and not use_arrow_keys: + raise RuntimeError( + "{} does not have a shortcut and arrow keys " + "for movement are disabled. " + "This choice is not reachable.".format(c.title) + ) + if isinstance(c, Separator) or c.shortcut_key is None: + continue + + # noinspection PyShadowingNames + def _reg_binding(i, keys): + # trick out late evaluation with a "function factory": + # https://stackoverflow.com/a/3431699 + @bindings.add(keys, eager=True) + def select_choice(event): + ic.pointed_at = i + + _reg_binding(i, c.shortcut_key) + + def move_cursor_down(event): + ic.select_next() + while not ic.is_selection_valid(): + ic.select_next() + + def move_cursor_up(event): + ic.select_previous() + while not ic.is_selection_valid(): + ic.select_previous() + + if use_arrow_keys: + bindings.add(Keys.Down, eager=True)(move_cursor_down) + bindings.add(Keys.Up, eager=True)(move_cursor_up) + + if use_jk_keys: + bindings.add("j", eager=True)(move_cursor_down) + bindings.add("k", eager=True)(move_cursor_up) + + if use_emacs_keys: + bindings.add(Keys.ControlN, eager=True)(move_cursor_down) + bindings.add(Keys.ControlP, eager=True)(move_cursor_up) + + @bindings.add(Keys.ControlM, eager=True) + def set_answer(event): + ic.is_answered = True + event.app.exit(result=ic.get_pointed_at().value) + + @bindings.add(Keys.Any) + def other(event): + """Disallow inserting other text.""" + + return Question( + Application( + layout=layout, + key_bindings=bindings, + style=merged_style, + **utils.used_kwargs(kwargs, Application.__init__), + ) + ) diff --git a/utils/questionary/questionary/prompts/text.py b/utils/questionary/questionary/prompts/text.py new file mode 100644 index 00000000..0812104c --- /dev/null +++ b/utils/questionary/questionary/prompts/text.py @@ -0,0 +1,101 @@ +from typing import Any +from typing import List +from typing import Optional +from typing import Tuple + +from prompt_toolkit.document import Document +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.shortcuts.prompt import PromptSession +from prompt_toolkit.styles import Style + +from ..constants import DEFAULT_QUESTION_PREFIX +from ..constants import INSTRUCTION_MULTILINE +from ..prompts.common import build_validator +from ..question import Question +from ..styles import merge_styles_default + + +def text( + message: str, + default: str = "", + validate: Any = None, + qmark: str = DEFAULT_QUESTION_PREFIX, + style: Optional[Style] = None, + multiline: bool = False, + instruction: Optional[str] = None, + lexer: Optional[Lexer] = None, + **kwargs: Any, +) -> Question: + """Prompt the user to enter a free text message. + + This question type can be used to prompt the user for some text input. + + Example: + >>> import questionary + >>> questionary.text("What's your first name?").ask() + ? What's your first name? Tom + 'Tom' + + .. image:: ../images/text.gif + + This is just a really basic example, the prompt can be customised using the + parameters. + + Args: + message: Question text. + + default: Default value will be returned if the user just hits + enter. + + validate: Require the entered value to pass a validation. The + value can not be submitted until the validator accepts + it (e.g. to check minimum password length). + + This can either be a function accepting the input and + returning a boolean, or an class reference to a + subclass of the prompt toolkit Validator class. + + qmark: Question prefix displayed in front of the question. + By default this is a ``?``. + + style: A custom color and style for the question parts. You can + configure colors as well as font types for different elements. + + multiline: If ``True``, multiline input will be enabled. + + instruction: Write instructions for the user if needed. If ``None`` + and ``multiline=True``, some instructions will appear. + + lexer: Supply a valid lexer to style the answer. Leave empty to + use a simple one by default. + + kwargs: Additional arguments, they will be passed to prompt toolkit. + + Returns: + :class:`Question`: Question instance, ready to be prompted (using ``.ask()``). + """ + merged_style = merge_styles_default([style]) + lexer = lexer or SimpleLexer("class:answer") + validator = build_validator(validate) + + if instruction is None and multiline: + instruction = INSTRUCTION_MULTILINE + + def get_prompt_tokens() -> List[Tuple[str, str]]: + result = [("class:qmark", qmark), ("class:question", " {} ".format(message))] + if instruction: + result.append(("class:instruction", " {} ".format(instruction))) + return result + + p: PromptSession = PromptSession( + get_prompt_tokens, + style=merged_style, + validator=validator, + lexer=lexer, + multiline=multiline, + **kwargs, + ) + p.default_buffer.reset(Document(default)) + + return Question(p.app) diff --git a/utils/questionary/questionary/py.typed b/utils/questionary/questionary/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/utils/questionary/questionary/question.py b/utils/questionary/questionary/question.py new file mode 100644 index 00000000..28b4a12f --- /dev/null +++ b/utils/questionary/questionary/question.py @@ -0,0 +1,134 @@ +import sys +from typing import Any + +import prompt_toolkit.patch_stdout +from prompt_toolkit import Application + +from . import utils +from .constants import DEFAULT_KBI_MESSAGE + + +class Question: + """A question to be prompted. + + This is an internal class. Questions should be created using the + predefined questions (e.g. text or password).""" + + application: "Application[Any]" + should_skip_question: bool + default: Any + + def __init__(self, application: "Application[Any]") -> None: + self.application = application + self.should_skip_question = False + self.default = None + + async def ask_async( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Any: + """Ask the question using asyncio and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + `Any`: The answer from the question. + """ + + try: + sys.stdout.flush() + return await self.unsafe_ask_async(patch_stdout) + except KeyboardInterrupt: + print("\n{}\n".format(kbi_msg)) + return None + + def ask( + self, patch_stdout: bool = False, kbi_msg: str = DEFAULT_KBI_MESSAGE + ) -> Any: + """Ask the question synchronously and return user response. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + kbi_msg: The message to be printed on a keyboard interrupt. + + Returns: + `Any`: The answer from the question. + """ + + try: + return self.unsafe_ask(patch_stdout) + except KeyboardInterrupt: + print("\n{}\n".format(kbi_msg)) + return None + + def unsafe_ask(self, patch_stdout: bool = False) -> Any: + """Ask the question synchronously and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + `Any`: The answer from the question. + """ + + if self.should_skip_question: + return self.default + + if patch_stdout: + with prompt_toolkit.patch_stdout.patch_stdout(): + return self.application.run() + else: + return self.application.run() + + def skip_if(self, condition: bool, default: Any = None) -> "Question": + """Skip the question if flag is set and return the default instead. + + Args: + condition: A conditional boolean value. + default: The default value to return. + + Returns: + :class:`Question`: `self`. + """ + + self.should_skip_question = condition + self.default = default + return self + + async def unsafe_ask_async(self, patch_stdout: bool = False) -> Any: + """Ask the question using asyncio and return user response. + + Does not catch keyboard interrupts. + + Args: + patch_stdout: Ensure that the prompt renders correctly if other threads + are printing to stdout. + + Returns: + `Any`: The answer from the question. + """ + + if self.should_skip_question: + return self.default + + if not utils.ACTIVATED_ASYNC_MODE: + await utils.activate_prompt_toolkit_async_mode() + + if patch_stdout: + with prompt_toolkit.patch_stdout.patch_stdout(): + r = self.application.run_async() + else: + r = self.application.run_async() + + if utils.is_prompt_toolkit_3(): + return await r + else: + return await r.to_asyncio_future() # type: ignore[attr-defined] diff --git a/utils/questionary/questionary/styles.py b/utils/questionary/questionary/styles.py new file mode 100644 index 00000000..52adb101 --- /dev/null +++ b/utils/questionary/questionary/styles.py @@ -0,0 +1,16 @@ + +from typing import List +from typing import Optional + +import prompt_toolkit.styles + +from .constants import DEFAULT_STYLE + + +def merge_styles_default(styles: List[Optional[prompt_toolkit.styles.Style]]): + """Merge a list of styles with the Questionary default style.""" + filtered_styles: list[prompt_toolkit.styles.BaseStyle] = [DEFAULT_STYLE] + # prompt_toolkit's merge_styles works with ``None`` elements, but it's + # type-hints says it doesn't. + filtered_styles.extend([s for s in styles if s is not None]) + return prompt_toolkit.styles.merge_styles(filtered_styles) diff --git a/utils/questionary/questionary/utils.py b/utils/questionary/questionary/utils.py new file mode 100644 index 00000000..d4fb9419 --- /dev/null +++ b/utils/questionary/questionary/utils.py @@ -0,0 +1,78 @@ +import inspect +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Set + +ACTIVATED_ASYNC_MODE = False + + +def is_prompt_toolkit_3() -> bool: + from prompt_toolkit import __version__ as ptk_version + + return ptk_version.startswith("3.") + + +def default_values_of(func: Callable[..., Any]) -> List[str]: + """Return all parameter names of ``func`` with a default value.""" + + signature = inspect.signature(func) + return [ + k + for k, v in signature.parameters.items() + if v.default is not inspect.Parameter.empty + or v.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + ] + + +def arguments_of(func: Callable[..., Any]) -> List[str]: + """Return the parameter names of the function ``func``.""" + + return list(inspect.signature(func).parameters.keys()) + + +def used_kwargs(kwargs: Dict[str, Any], func: Callable[..., Any]) -> Dict[str, Any]: + """Returns only the kwargs which can be used by a function. + + Args: + kwargs: All available kwargs. + func: The function which should be called. + + Returns: + Subset of kwargs which are accepted by ``func``. + """ + + possible_arguments = arguments_of(func) + + return {k: v for k, v in kwargs.items() if k in possible_arguments} + + +def required_arguments(func: Callable[..., Any]) -> List[str]: + """Return all arguments of a function that do not have a default value.""" + defaults = default_values_of(func) + args = arguments_of(func) + + if defaults: + args = args[: -len(defaults)] + return args # all args without default values + + +def missing_arguments(func: Callable[..., Any], argdict: Dict[str, Any]) -> Set[str]: + """Return all arguments that are missing to call func.""" + return set(required_arguments(func)) - set(argdict.keys()) + + +async def activate_prompt_toolkit_async_mode() -> None: + """Configure prompt toolkit to use the asyncio event loop. + + Needs to be async, so we use the right event loop in py 3.5""" + global ACTIVATED_ASYNC_MODE + + if not is_prompt_toolkit_3(): + # Tell prompt_toolkit to use asyncio for the event loop. + import prompt_toolkit as pt + + pt.eventloop.use_asyncio_event_loop() # type: ignore[attr-defined] + + ACTIVATED_ASYNC_MODE = True diff --git a/utils/questionary/questionary/version.py b/utils/questionary/questionary/version.py new file mode 100644 index 00000000..cf52fbca --- /dev/null +++ b/utils/questionary/questionary/version.py @@ -0,0 +1 @@ +__version__ = "2.0.2-beta"