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 questionary.constants import DEFAULT_SELECTED_POINTER from questionary.constants import DEFAULT_STYLE from questionary.constants import INDICATOR_SELECTED from questionary.constants import INDICATOR_UNSELECTED from questionary.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. """ 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""" def __init__( self, title: FormattedText, value: Optional[Any] = None, disabled: Optional[str] = None, checked: Optional[bool] = False, shortcut_key: Optional[Union[str, bool]] = True, ) -> None: self.disabled = disabled self.title = title self.checked = checked if checked is not None else False 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"), ) 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 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, 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.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))) 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)