meerschaum.utils.prompt

Functions for interacting with the user.

  1#! /usr/bin/env python
  2# -*- coding: utf-8 -*-
  3# vim:fenc=utf-8
  4
  5"""
  6Functions for interacting with the user.
  7"""
  8
  9from __future__ import annotations
 10
 11import os
 12import meerschaum as mrsm
 13from meerschaum.utils.typing import Any, Union, Optional, Tuple, List
 14
 15
 16def prompt(
 17    question: str,
 18    icon: bool = True,
 19    default: Union[str, Tuple[str, str], None] = None,
 20    default_editable: Optional[str] = None,
 21    detect_password: bool = True,
 22    is_password: bool = False,
 23    wrap_lines: bool = True,
 24    noask: bool = False,
 25    **kw: Any
 26) -> str:
 27    """
 28    Ask the user a question and return the answer.
 29    Wrapper around `prompt_toolkit.prompt()` with modified behavior.
 30    For example, an empty string returns default instead of printing it for the user to delete
 31    (`prompt_toolkit` behavior).
 32
 33    Parameters
 34    ----------
 35    question: str
 36        The question to print to the user.
 37
 38    icon: bool, default True
 39        If True, prepend the configured icon.
 40
 41    default: Union[str, Tuple[str, str], None], default None
 42        If the response is '', return the default value.
 43
 44    default_editable: Optional[str], default None
 45        If provided, auto-type this user-editable string in the prompt.
 46
 47    detect_password: bool, default True
 48        If `True`, set the input method to a censored password box if the word `password`
 49        appears in the question.
 50
 51    is_password: default False
 52        If `True`, set the input method to a censored password box.
 53        May be overridden by `detect_password` unless `detect_password` is set to `False`.
 54
 55    wrap_lines: bool, default True
 56        If `True`, wrap the text across multiple lines.
 57        Flag is passed onto `prompt_toolkit`.
 58
 59    noask: bool, default False
 60        If `True`, only print the question and return the default answer.
 61
 62    Returns
 63    -------
 64    A `str` of the input provided by the user.
 65
 66    """
 67    from meerschaum.utils.packages import attempt_import
 68    from meerschaum.utils.formatting import ANSI, CHARSET, highlight_pipes, fill_ansi
 69    from meerschaum.config import get_config
 70    from meerschaum.utils.misc import filter_keywords
 71    from meerschaum.utils.daemon import running_in_daemon
 72    noask = check_noask(noask)
 73    if not noask:
 74        prompt_toolkit = attempt_import('prompt_toolkit')
 75    question_config = get_config('formatting', 'question', patch=True)
 76
 77    ### if a default is provided, append it to the question.
 78    default_answer = default
 79    if default is not None:
 80        question += " (default: "
 81        if isinstance(default, tuple) and len(default) > 1:
 82            question += f"{default[0]} [{default[1]}]"
 83            default_answer = default[0]
 84        else:
 85            question += f"{default}"
 86        question += ")"
 87
 88    ### detect password
 89    if (detect_password and 'password' in question.lower()) or is_password:
 90        kw['is_password'] = True
 91
 92    ### Add the icon and only color the first line.
 93    lines = question.split('\n')
 94    first_line = lines[0]
 95    other_lines = '' if len(lines) <= 1 else '\n'.join(lines[1:])
 96
 97    if ANSI:
 98        first_line = fill_ansi(highlight_pipes(first_line), **question_config['ansi']['rich'])
 99        other_lines = highlight_pipes(other_lines)
100
101    _icon = question_config[CHARSET]['icon']
102    question = (' ' + _icon + ' ') if icon and len(_icon) > 0 else ''
103    question += first_line
104    if len(other_lines) > 0:
105        question += '\n' + other_lines
106    question += ' '
107
108    if not running_in_daemon():
109        answer = (
110            prompt_toolkit.prompt(
111                prompt_toolkit.formatted_text.ANSI(question),
112                wrap_lines=wrap_lines,
113                default=default_editable or '',
114                **filter_keywords(prompt_toolkit.prompt, **kw)
115            ) if not noask else ''
116        )
117    else:
118        print(question, end='\n', flush=True)
119        try:
120            answer = input() if not noask else ''
121        except EOFError:
122            answer = ''
123    if noask:
124        print(question)
125    if answer == '' and default is not None:
126        return default_answer
127    return answer
128
129
130def yes_no(
131    question: str = '',
132    options: Tuple[str, str] = ('y', 'n'),
133    default: str = 'y',
134    wrappers: Tuple[str, str] = ('[', ']'),
135    icon: bool = True,
136    yes: bool = False,
137    noask: bool = False,
138    **kw : Any
139) -> bool:
140    """
141    Print a question and prompt the user with a yes / no input.
142    Returns `True` for `'yes'`, False for `'no'`.
143
144    Parameters
145    ----------
146    question: str, default ''
147        The question to print to the user.
148
149    options: Tuple[str, str], default ('y', 'n')
150        The `y/n` options. The first is considered `True`, and all options must be lower case.
151
152    default: str, default y
153        The default option. Is represented with a capital to distinguish that it's the default.
154
155    wrappers: Tuple[str, str], default ('[', ']')
156        Text to print around the '[y/n]' options.
157
158    icon: bool, default True
159        If True, prepend the configured question icon.
160
161    Returns
162    -------
163    A bool indicating the user's choice.
164
165    Examples
166    --------
167    ```python-repl
168    >>> yes_no("Do you like me?", default='y')
169     ❓ Do you like me? [Y/n]
170    True
171    >>> yes_no("Cats or dogs?", options=('cats', 'dogs'))
172     ❓ Cats or dogs? [cats/dogs]
173     Please enter a valid response.
174     ❓ Cats or dogs? [cats/dogs] dogs
175    False
176    ```
177    """
178    from meerschaum.utils.warnings import error, warn
179
180    default = options[0] if yes else default
181    noask = yes or check_noask(noask)
182
183    ending = f" {wrappers[0]}" + "/".join(
184        [
185            o.upper() if o.lower() == default.lower()
186            else o.lower() for o in options
187        ]
188    ) + f"{wrappers[1]}"
189    while True:
190        try:
191            answer = prompt(question + ending, icon=icon, detect_password=False, noask=noask)
192            success = True
193        except KeyboardInterrupt:
194            success = False
195        
196        if not success:
197            error("Error getting response. Aborting...", stack=False)
198        if answer == "":
199            answer = default
200
201        if answer.lower() in options:
202            break
203        warn('Please enter a valid reponse.', stack=False)
204    
205    return answer.lower() == options[0].lower()
206
207
208def choose(
209    question: str,
210    choices: List[Union[str, Tuple[str, str]]],
211    default: Union[str, List[str], None] = None,
212    numeric: bool = True,
213    multiple: bool = False,
214    as_indices: bool = False,
215    delimiter: str = ',',
216    icon: bool = True,
217    warn: bool = True,
218    noask: bool = False,
219    **kw
220) -> Union[str, Tuple[str], None]:
221    """
222    Present a list of options and return the user's choice.
223
224    Parameters
225    ----------
226    question: str
227        The question to be printed.
228
229    choices: List[Union[str, Tuple[str, str]]
230        A list of options.
231        If an option is a tuple of two strings, the first string is treated as the index
232        and not displayed. In this case, set `as_indices` to `True` to return the index.
233
234    default: Union[str, List[str], None], default None
235        If the user declines to enter a choice, return this value.
236
237    numeric: bool, default True
238        If `True`, number the items in the list and ask for a number as input.
239        If `False`, require the user to type the complete string.
240
241    multiple: bool, default False
242        If `True`, allow the user to choose multiple answers separated by `delimiter`.
243
244    as_indices: bool, default False
245        If `True`, return the indices for the choices.
246        If a choice is a tuple of two strings, the first is assumed to be the index.
247        Otherwise the index in the list is returned.
248
249    delimiter: str, default ','
250        If `multiple`, separate answers by this string. Raise a warning if this string is contained
251        in any of the choices.
252
253    icon: bool, default True
254        If `True`, include the question icon.
255
256    warn: bool, default True
257        If `True`, raise warnings when invalid input is entered.
258
259    noask: bool, default False
260        If `True`, skip printing the question and return the default value.
261
262    Returns
263    -------
264    A string for a single answer or a tuple of strings if `multiple` is `True`.
265
266    """
267    from meerschaum.utils.warnings import warn as _warn
268    from meerschaum.utils.packages import attempt_import
269    from meerschaum.utils.misc import print_options
270    noask = check_noask(noask)
271
272    ### Handle empty choices.
273    if len(choices) == 0:
274        _warn(f"No available choices. Returning default value '{default}'.", stacklevel=3)
275        return default
276
277    ### If the default case is to include multiple answers, allow for multiple inputs.
278    if isinstance(default, list):
279        multiple = True
280
281    choices_indices = {}
282    for i, c in enumerate(choices):
283        if isinstance(c, tuple):
284            i, c = c
285        choices_indices[i] = c
286
287    def _enforce_default(d):
288        if d is not None and d not in choices and d not in choices_indices and warn:
289            _warn(
290                f"Default choice '{default}' is not contained in the choices {choices}. "
291                + "Setting numeric = False.",
292                stacklevel = 3
293            )
294            return False
295        return True
296
297    ### Throw a warning if the default isn't a choice.
298    for d in (default if isinstance(default, list) else [default]):
299        if not _enforce_default(d):
300            numeric = False
301            break
302
303    _default = default
304    _choices = list(choices_indices.values())
305    if multiple:
306        question += f"\n    Enter your choices, separated by '{delimiter}'.\n"
307
308    altered_choices = {}
309    altered_indices = {}
310    altered_default_indices = {}
311    delim_replacement = '_' if delimiter != '_' else '-'
312    can_strip_start_spaces, can_strip_end_spaces = True, True
313    for i, c in choices_indices.items():
314        if isinstance(c, tuple):
315            key, c = c
316        if can_strip_start_spaces and c.startswith(' '):
317            can_strip_start_spaces = False
318        if can_strip_end_spaces and c.endswith(' '):
319            can_strip_end_spaces = False
320
321    if multiple:
322        ### Check if the defaults have the delimiter.
323        for i, d in enumerate(default if isinstance(default, list) else [default]):
324            if d is None or delimiter not in d:
325                continue
326            new_d = d.replace(delimiter, delim_replacement)
327            altered_choices[new_d] = d
328            altered_default_indices[i] = new_d
329        for i, new_d in altered_default_indices.items():
330            if not isinstance(default, list):
331                default = new_d
332                break
333            default[i] = new_d
334
335        ### Check if the choices have the delimiter.
336        for i, c in choices_indices.items():
337            if delimiter in c and not numeric and warn:
338                _warn(
339                    f"The delimiter '{delimiter}' is contained within choice '{c}'.\n"
340                    + f"Replacing the string '{delimiter}' with '{delim_replacement}' in "
341                    + "the choice for correctly parsing input (will be replaced upon returning the prompt).",
342                    stacklevel = 3,
343                )
344                new_c = c.replace(delimiter, delim_replacement)
345                altered_choices[new_c] = c
346                altered_indices[i] = new_c
347        for i, new_c in altered_indices.items():
348            choices_indices[i] = new_c
349        default = delimiter.join(default) if isinstance(default, list) else default
350
351    question_options = []
352    if numeric:
353        _choices = [str(i + 1) for i, c in enumerate(choices)]
354        _default = ''
355        if default is not None:
356            for d in (default.split(delimiter) if multiple else [default]):
357                if d not in choices and d in choices_indices:
358                    d_index = d
359                    d_value = choices_indices[d]
360                    for _i, _option in enumerate(choices):
361                        if (
362                            isinstance(_option, tuple) and (
363                                _option[1] == d_value
364                                or
365                                _option[0] == d_index
366                            )
367                        ) or d_index == _i:
368                            d = _option
369
370                _d = str(choices.index(d) + 1)
371                _default += _d + delimiter
372        _default = _default[:-1 * len(delimiter)]
373        #  question += '\n'
374        choices_digits = len(str(len(choices)))
375        for i, c in enumerate(choices_indices.values()):
376            question_options.append(
377                f"  {i + 1}. "
378                + (" " * (choices_digits - len(str(i + 1))))
379                + f"{c}\n"
380            )
381        default_tuple = (_default, default) if default is not None else None
382    else:
383        default_tuple = default
384        #  question += '\n'
385        for c in choices_indices.values():
386            question_options.append(f"{c}\n")
387
388    if 'completer' not in kw:
389        WordCompleter = attempt_import('prompt_toolkit.completion').WordCompleter
390        kw['completer'] = WordCompleter(choices_indices.values(), sentence=True)
391
392    valid = False
393    while not valid:
394        print_options(question_options, header='')
395        answer = prompt(
396            question,
397            icon = icon,
398            default = default_tuple,
399            noask = noask,
400            **kw
401        )
402        ### Split along the delimiter.
403        _answers = [answer] if not multiple else [a for a in answer.split(delimiter)]
404
405        ### Remove trailing spaces if possible.
406        _answers = [(_a.rstrip(' ') if can_strip_end_spaces else _a) for _a in _answers]
407
408        ### Remove leading spaces if possible.
409        _answers = [(_a.lstrip(' ') if can_strip_start_spaces else _a) for _a in _answers]
410
411        ### Remove empty strings.
412        _answers = [_a for _a in _answers if _a]
413
414        if multiple and len(_answers) == 0:
415            _answers = default_tuple if isinstance(default_tuple, list) else [default_tuple]
416        answers = [altered_choices.get(a, a) for a in _answers]
417
418        valid = (len(answers) > 1 or not (len(answers) == 1 and answers[0] is None))
419        for a in answers:
420            if (
421                a not in {_original for _new, _original in altered_choices.items()}
422                and a not in _choices
423                and a != default
424                and not noask
425            ):
426                valid = False
427                break
428        if valid:
429            break
430        if warn:
431            _warn("Please pick a valid choice.", stack=False)
432
433    if not multiple:
434        if not numeric:
435            return answer
436        try:
437            _answer = choices[int(answer) - 1]
438            if as_indices and isinstance(_answer, tuple):
439                return _answer[0]
440            return _answer
441        except Exception:
442            _warn(f"Could not cast answer '{answer}' to an integer.", stacklevel=3)
443
444    if not numeric:
445        return answers
446
447    _answers = []
448    for a in answers:
449        try:
450            _answer = choices[int(a) - 1]
451            _answer_to_return = altered_choices.get(_answer, _answer)
452            if isinstance(_answer_to_return, tuple) and as_indices:
453                _answer_to_return = _answer_to_return[0]
454            _answers.append(_answer_to_return)
455        except Exception:
456            _warn(f"Could not cast answer '{a}' to an integer.", stacklevel=3)
457    return _answers
458
459
460def get_password(
461    username: Optional[str] = None,
462    minimum_length: Optional[int] = None,
463    confirm: bool = True,
464    **kw: Any
465) -> str:
466    """
467    Prompt the user for a password.
468
469    Parameters
470    ----------
471    username: Optional[str], default None
472        If provided, print the username when asking for a password.
473
474    minimum_length: Optional[int], default None
475        If provided, enforce a password of at least this length.
476
477    confirm: bool, default True
478        If `True`, prompt the user for a password twice.
479
480    Returns
481    -------
482    The password string (censored from terminal output when typing).
483
484    Examples
485    --------
486    ```python-repl
487    >>> get_password()
488     ❓ Password: *******
489     ❓ Confirm password: *******
490    'hunter2'
491    ```
492
493    """
494    from meerschaum.utils.warnings import warn
495    while True:
496        password = prompt(
497            "Password" + (f" for user '{username}':" if username is not None else ":"),
498            is_password=True,
499            **kw
500        )
501        if minimum_length is not None and len(password) < minimum_length:
502            warn(
503                "Password is too short. " +
504                f"Please enter a password that is at least {minimum_length} characters.",
505                stack=False
506            )
507            continue
508
509        if not confirm:
510            return password
511
512        _password = prompt(
513            "Confirm password" + (f" for user '{username}':" if username is not None else ":"),
514            is_password=True,
515            **kw
516        )
517        if password != _password:
518            warn("Passwords do not match! Please try again.", stack=False)
519            continue
520        else:
521            return password
522
523
524def get_email(username: Optional[str] = None, allow_omit: bool = True, **kw: Any) -> str:
525    """
526    Prompt the user for an email and enforce that it's valid.
527
528    Parameters
529    ----------
530    username: Optional[str], default None
531        If provided, print the username in the prompt.
532
533    allow_omit: bool, default True
534        If `True`, allow the user to omit the email.
535
536    Returns
537    -------
538    The provided email string.
539
540    Examples
541    --------
542    ```python-repl
543    >>> get_email()
544     ❓ Email (empty to omit): foo@foo
545     Invalid email! Please try again.
546     ❓ Email (empty to omit): foo@foo.com
547    'foo@foo.com'
548    ```
549    """
550    from meerschaum.utils.warnings import warn
551    from meerschaum.utils.misc import is_valid_email
552    while True:
553        email = prompt(
554            "Email" + (f" for user '{username}'" if username is not None else "") +
555            (" (empty to omit):" if allow_omit else ": "),
556            **kw
557        )
558        if (allow_omit and email == '') or is_valid_email(email):
559            return email
560        warn("Invalid email! Please try again.", stack=False)
561
562
563def check_noask(noask: bool = False) -> bool:
564    """
565    Flip `noask` to `True` if `MRSM_NOASK` is set.
566    """
567    from meerschaum.config.static import STATIC_CONFIG
568    NOASK = STATIC_CONFIG['environment']['noask']
569    if noask:
570        return True
571    return (
572        os.environ.get(NOASK, 'false').lower()
573        in ('1', 'true')
574    )
575
576
577def get_connectors_completer(*types: str):
578    """
579    Return a prompt-toolkit Completer object to pass into `prompt()`.
580    """
581    from meerschaum.utils.misc import get_connector_labels
582    prompt_toolkit_completion = mrsm.attempt_import('prompt_toolkit.completion', lazy=False)
583    Completer = prompt_toolkit_completion.Completer
584    Completion = prompt_toolkit_completion.Completion
585
586    class ConnectorCompleter(Completer):
587        def get_completions(self, document, complete_event):
588            for label in get_connector_labels(*types, search_term=document.text):
589                yield Completion(label, start_position=(-1 * len(document.text)))
590
591    return ConnectorCompleter()
def prompt( question: str, icon: bool = True, default: Union[str, Tuple[str, str], NoneType] = None, default_editable: Optional[str] = None, detect_password: bool = True, is_password: bool = False, wrap_lines: bool = True, noask: bool = False, **kw: Any) -> str:
 17def prompt(
 18    question: str,
 19    icon: bool = True,
 20    default: Union[str, Tuple[str, str], None] = None,
 21    default_editable: Optional[str] = None,
 22    detect_password: bool = True,
 23    is_password: bool = False,
 24    wrap_lines: bool = True,
 25    noask: bool = False,
 26    **kw: Any
 27) -> str:
 28    """
 29    Ask the user a question and return the answer.
 30    Wrapper around `prompt_toolkit.prompt()` with modified behavior.
 31    For example, an empty string returns default instead of printing it for the user to delete
 32    (`prompt_toolkit` behavior).
 33
 34    Parameters
 35    ----------
 36    question: str
 37        The question to print to the user.
 38
 39    icon: bool, default True
 40        If True, prepend the configured icon.
 41
 42    default: Union[str, Tuple[str, str], None], default None
 43        If the response is '', return the default value.
 44
 45    default_editable: Optional[str], default None
 46        If provided, auto-type this user-editable string in the prompt.
 47
 48    detect_password: bool, default True
 49        If `True`, set the input method to a censored password box if the word `password`
 50        appears in the question.
 51
 52    is_password: default False
 53        If `True`, set the input method to a censored password box.
 54        May be overridden by `detect_password` unless `detect_password` is set to `False`.
 55
 56    wrap_lines: bool, default True
 57        If `True`, wrap the text across multiple lines.
 58        Flag is passed onto `prompt_toolkit`.
 59
 60    noask: bool, default False
 61        If `True`, only print the question and return the default answer.
 62
 63    Returns
 64    -------
 65    A `str` of the input provided by the user.
 66
 67    """
 68    from meerschaum.utils.packages import attempt_import
 69    from meerschaum.utils.formatting import ANSI, CHARSET, highlight_pipes, fill_ansi
 70    from meerschaum.config import get_config
 71    from meerschaum.utils.misc import filter_keywords
 72    from meerschaum.utils.daemon import running_in_daemon
 73    noask = check_noask(noask)
 74    if not noask:
 75        prompt_toolkit = attempt_import('prompt_toolkit')
 76    question_config = get_config('formatting', 'question', patch=True)
 77
 78    ### if a default is provided, append it to the question.
 79    default_answer = default
 80    if default is not None:
 81        question += " (default: "
 82        if isinstance(default, tuple) and len(default) > 1:
 83            question += f"{default[0]} [{default[1]}]"
 84            default_answer = default[0]
 85        else:
 86            question += f"{default}"
 87        question += ")"
 88
 89    ### detect password
 90    if (detect_password and 'password' in question.lower()) or is_password:
 91        kw['is_password'] = True
 92
 93    ### Add the icon and only color the first line.
 94    lines = question.split('\n')
 95    first_line = lines[0]
 96    other_lines = '' if len(lines) <= 1 else '\n'.join(lines[1:])
 97
 98    if ANSI:
 99        first_line = fill_ansi(highlight_pipes(first_line), **question_config['ansi']['rich'])
100        other_lines = highlight_pipes(other_lines)
101
102    _icon = question_config[CHARSET]['icon']
103    question = (' ' + _icon + ' ') if icon and len(_icon) > 0 else ''
104    question += first_line
105    if len(other_lines) > 0:
106        question += '\n' + other_lines
107    question += ' '
108
109    if not running_in_daemon():
110        answer = (
111            prompt_toolkit.prompt(
112                prompt_toolkit.formatted_text.ANSI(question),
113                wrap_lines=wrap_lines,
114                default=default_editable or '',
115                **filter_keywords(prompt_toolkit.prompt, **kw)
116            ) if not noask else ''
117        )
118    else:
119        print(question, end='\n', flush=True)
120        try:
121            answer = input() if not noask else ''
122        except EOFError:
123            answer = ''
124    if noask:
125        print(question)
126    if answer == '' and default is not None:
127        return default_answer
128    return answer

Ask the user a question and return the answer. Wrapper around prompt_toolkit.prompt() with modified behavior. For example, an empty string returns default instead of printing it for the user to delete (prompt_toolkit behavior).

Parameters
  • question (str): The question to print to the user.
  • icon (bool, default True): If True, prepend the configured icon.
  • default (Union[str, Tuple[str, str], None], default None): If the response is '', return the default value.
  • default_editable (Optional[str], default None): If provided, auto-type this user-editable string in the prompt.
  • detect_password (bool, default True): If True, set the input method to a censored password box if the word password appears in the question.
  • is_password (default False): If True, set the input method to a censored password box. May be overridden by detect_password unless detect_password is set to False.
  • wrap_lines (bool, default True): If True, wrap the text across multiple lines. Flag is passed onto prompt_toolkit.
  • noask (bool, default False): If True, only print the question and return the default answer.
Returns
  • A str of the input provided by the user.
def yes_no( question: str = '', options: Tuple[str, str] = ('y', 'n'), default: str = 'y', wrappers: Tuple[str, str] = ('[', ']'), icon: bool = True, yes: bool = False, noask: bool = False, **kw: Any) -> bool:
131def yes_no(
132    question: str = '',
133    options: Tuple[str, str] = ('y', 'n'),
134    default: str = 'y',
135    wrappers: Tuple[str, str] = ('[', ']'),
136    icon: bool = True,
137    yes: bool = False,
138    noask: bool = False,
139    **kw : Any
140) -> bool:
141    """
142    Print a question and prompt the user with a yes / no input.
143    Returns `True` for `'yes'`, False for `'no'`.
144
145    Parameters
146    ----------
147    question: str, default ''
148        The question to print to the user.
149
150    options: Tuple[str, str], default ('y', 'n')
151        The `y/n` options. The first is considered `True`, and all options must be lower case.
152
153    default: str, default y
154        The default option. Is represented with a capital to distinguish that it's the default.
155
156    wrappers: Tuple[str, str], default ('[', ']')
157        Text to print around the '[y/n]' options.
158
159    icon: bool, default True
160        If True, prepend the configured question icon.
161
162    Returns
163    -------
164    A bool indicating the user's choice.
165
166    Examples
167    --------
168    ```python-repl
169    >>> yes_no("Do you like me?", default='y')
170     ❓ Do you like me? [Y/n]
171    True
172    >>> yes_no("Cats or dogs?", options=('cats', 'dogs'))
173     ❓ Cats or dogs? [cats/dogs]
174     Please enter a valid response.
175     ❓ Cats or dogs? [cats/dogs] dogs
176    False
177    ```
178    """
179    from meerschaum.utils.warnings import error, warn
180
181    default = options[0] if yes else default
182    noask = yes or check_noask(noask)
183
184    ending = f" {wrappers[0]}" + "/".join(
185        [
186            o.upper() if o.lower() == default.lower()
187            else o.lower() for o in options
188        ]
189    ) + f"{wrappers[1]}"
190    while True:
191        try:
192            answer = prompt(question + ending, icon=icon, detect_password=False, noask=noask)
193            success = True
194        except KeyboardInterrupt:
195            success = False
196        
197        if not success:
198            error("Error getting response. Aborting...", stack=False)
199        if answer == "":
200            answer = default
201
202        if answer.lower() in options:
203            break
204        warn('Please enter a valid reponse.', stack=False)
205    
206    return answer.lower() == options[0].lower()

Print a question and prompt the user with a yes / no input. Returns True for 'yes', False for 'no'.

Parameters
  • question (str, default ''): The question to print to the user.
  • options (Tuple[str, str], default ('y', 'n')): The y/n options. The first is considered True, and all options must be lower case.
  • default (str, default y): The default option. Is represented with a capital to distinguish that it's the default.
  • wrappers (Tuple[str, str], default ('[', ']')): Text to print around the '[y/n]' options.
  • icon (bool, default True): If True, prepend the configured question icon.
Returns
  • A bool indicating the user's choice.
Examples
>>> yes_no("Do you like me?", default='y')
 ❓ Do you like me? [Y/n]
True
>>> yes_no("Cats or dogs?", options=('cats', 'dogs'))
 ❓ Cats or dogs? [cats/dogs]
 Please enter a valid response.
 ❓ Cats or dogs? [cats/dogs] dogs
False
def choose( question: str, choices: List[Union[str, Tuple[str, str]]], default: Union[str, List[str], NoneType] = None, numeric: bool = True, multiple: bool = False, as_indices: bool = False, delimiter: str = ',', icon: bool = True, warn: bool = True, noask: bool = False, **kw) -> Union[str, Tuple[str], NoneType]:
209def choose(
210    question: str,
211    choices: List[Union[str, Tuple[str, str]]],
212    default: Union[str, List[str], None] = None,
213    numeric: bool = True,
214    multiple: bool = False,
215    as_indices: bool = False,
216    delimiter: str = ',',
217    icon: bool = True,
218    warn: bool = True,
219    noask: bool = False,
220    **kw
221) -> Union[str, Tuple[str], None]:
222    """
223    Present a list of options and return the user's choice.
224
225    Parameters
226    ----------
227    question: str
228        The question to be printed.
229
230    choices: List[Union[str, Tuple[str, str]]
231        A list of options.
232        If an option is a tuple of two strings, the first string is treated as the index
233        and not displayed. In this case, set `as_indices` to `True` to return the index.
234
235    default: Union[str, List[str], None], default None
236        If the user declines to enter a choice, return this value.
237
238    numeric: bool, default True
239        If `True`, number the items in the list and ask for a number as input.
240        If `False`, require the user to type the complete string.
241
242    multiple: bool, default False
243        If `True`, allow the user to choose multiple answers separated by `delimiter`.
244
245    as_indices: bool, default False
246        If `True`, return the indices for the choices.
247        If a choice is a tuple of two strings, the first is assumed to be the index.
248        Otherwise the index in the list is returned.
249
250    delimiter: str, default ','
251        If `multiple`, separate answers by this string. Raise a warning if this string is contained
252        in any of the choices.
253
254    icon: bool, default True
255        If `True`, include the question icon.
256
257    warn: bool, default True
258        If `True`, raise warnings when invalid input is entered.
259
260    noask: bool, default False
261        If `True`, skip printing the question and return the default value.
262
263    Returns
264    -------
265    A string for a single answer or a tuple of strings if `multiple` is `True`.
266
267    """
268    from meerschaum.utils.warnings import warn as _warn
269    from meerschaum.utils.packages import attempt_import
270    from meerschaum.utils.misc import print_options
271    noask = check_noask(noask)
272
273    ### Handle empty choices.
274    if len(choices) == 0:
275        _warn(f"No available choices. Returning default value '{default}'.", stacklevel=3)
276        return default
277
278    ### If the default case is to include multiple answers, allow for multiple inputs.
279    if isinstance(default, list):
280        multiple = True
281
282    choices_indices = {}
283    for i, c in enumerate(choices):
284        if isinstance(c, tuple):
285            i, c = c
286        choices_indices[i] = c
287
288    def _enforce_default(d):
289        if d is not None and d not in choices and d not in choices_indices and warn:
290            _warn(
291                f"Default choice '{default}' is not contained in the choices {choices}. "
292                + "Setting numeric = False.",
293                stacklevel = 3
294            )
295            return False
296        return True
297
298    ### Throw a warning if the default isn't a choice.
299    for d in (default if isinstance(default, list) else [default]):
300        if not _enforce_default(d):
301            numeric = False
302            break
303
304    _default = default
305    _choices = list(choices_indices.values())
306    if multiple:
307        question += f"\n    Enter your choices, separated by '{delimiter}'.\n"
308
309    altered_choices = {}
310    altered_indices = {}
311    altered_default_indices = {}
312    delim_replacement = '_' if delimiter != '_' else '-'
313    can_strip_start_spaces, can_strip_end_spaces = True, True
314    for i, c in choices_indices.items():
315        if isinstance(c, tuple):
316            key, c = c
317        if can_strip_start_spaces and c.startswith(' '):
318            can_strip_start_spaces = False
319        if can_strip_end_spaces and c.endswith(' '):
320            can_strip_end_spaces = False
321
322    if multiple:
323        ### Check if the defaults have the delimiter.
324        for i, d in enumerate(default if isinstance(default, list) else [default]):
325            if d is None or delimiter not in d:
326                continue
327            new_d = d.replace(delimiter, delim_replacement)
328            altered_choices[new_d] = d
329            altered_default_indices[i] = new_d
330        for i, new_d in altered_default_indices.items():
331            if not isinstance(default, list):
332                default = new_d
333                break
334            default[i] = new_d
335
336        ### Check if the choices have the delimiter.
337        for i, c in choices_indices.items():
338            if delimiter in c and not numeric and warn:
339                _warn(
340                    f"The delimiter '{delimiter}' is contained within choice '{c}'.\n"
341                    + f"Replacing the string '{delimiter}' with '{delim_replacement}' in "
342                    + "the choice for correctly parsing input (will be replaced upon returning the prompt).",
343                    stacklevel = 3,
344                )
345                new_c = c.replace(delimiter, delim_replacement)
346                altered_choices[new_c] = c
347                altered_indices[i] = new_c
348        for i, new_c in altered_indices.items():
349            choices_indices[i] = new_c
350        default = delimiter.join(default) if isinstance(default, list) else default
351
352    question_options = []
353    if numeric:
354        _choices = [str(i + 1) for i, c in enumerate(choices)]
355        _default = ''
356        if default is not None:
357            for d in (default.split(delimiter) if multiple else [default]):
358                if d not in choices and d in choices_indices:
359                    d_index = d
360                    d_value = choices_indices[d]
361                    for _i, _option in enumerate(choices):
362                        if (
363                            isinstance(_option, tuple) and (
364                                _option[1] == d_value
365                                or
366                                _option[0] == d_index
367                            )
368                        ) or d_index == _i:
369                            d = _option
370
371                _d = str(choices.index(d) + 1)
372                _default += _d + delimiter
373        _default = _default[:-1 * len(delimiter)]
374        #  question += '\n'
375        choices_digits = len(str(len(choices)))
376        for i, c in enumerate(choices_indices.values()):
377            question_options.append(
378                f"  {i + 1}. "
379                + (" " * (choices_digits - len(str(i + 1))))
380                + f"{c}\n"
381            )
382        default_tuple = (_default, default) if default is not None else None
383    else:
384        default_tuple = default
385        #  question += '\n'
386        for c in choices_indices.values():
387            question_options.append(f"{c}\n")
388
389    if 'completer' not in kw:
390        WordCompleter = attempt_import('prompt_toolkit.completion').WordCompleter
391        kw['completer'] = WordCompleter(choices_indices.values(), sentence=True)
392
393    valid = False
394    while not valid:
395        print_options(question_options, header='')
396        answer = prompt(
397            question,
398            icon = icon,
399            default = default_tuple,
400            noask = noask,
401            **kw
402        )
403        ### Split along the delimiter.
404        _answers = [answer] if not multiple else [a for a in answer.split(delimiter)]
405
406        ### Remove trailing spaces if possible.
407        _answers = [(_a.rstrip(' ') if can_strip_end_spaces else _a) for _a in _answers]
408
409        ### Remove leading spaces if possible.
410        _answers = [(_a.lstrip(' ') if can_strip_start_spaces else _a) for _a in _answers]
411
412        ### Remove empty strings.
413        _answers = [_a for _a in _answers if _a]
414
415        if multiple and len(_answers) == 0:
416            _answers = default_tuple if isinstance(default_tuple, list) else [default_tuple]
417        answers = [altered_choices.get(a, a) for a in _answers]
418
419        valid = (len(answers) > 1 or not (len(answers) == 1 and answers[0] is None))
420        for a in answers:
421            if (
422                a not in {_original for _new, _original in altered_choices.items()}
423                and a not in _choices
424                and a != default
425                and not noask
426            ):
427                valid = False
428                break
429        if valid:
430            break
431        if warn:
432            _warn("Please pick a valid choice.", stack=False)
433
434    if not multiple:
435        if not numeric:
436            return answer
437        try:
438            _answer = choices[int(answer) - 1]
439            if as_indices and isinstance(_answer, tuple):
440                return _answer[0]
441            return _answer
442        except Exception:
443            _warn(f"Could not cast answer '{answer}' to an integer.", stacklevel=3)
444
445    if not numeric:
446        return answers
447
448    _answers = []
449    for a in answers:
450        try:
451            _answer = choices[int(a) - 1]
452            _answer_to_return = altered_choices.get(_answer, _answer)
453            if isinstance(_answer_to_return, tuple) and as_indices:
454                _answer_to_return = _answer_to_return[0]
455            _answers.append(_answer_to_return)
456        except Exception:
457            _warn(f"Could not cast answer '{a}' to an integer.", stacklevel=3)
458    return _answers

Present a list of options and return the user's choice.

Parameters
  • question (str): The question to be printed.
  • choices (List[Union[str, Tuple[str, str]]): A list of options. If an option is a tuple of two strings, the first string is treated as the index and not displayed. In this case, set as_indices to True to return the index.
  • default (Union[str, List[str], None], default None): If the user declines to enter a choice, return this value.
  • numeric (bool, default True): If True, number the items in the list and ask for a number as input. If False, require the user to type the complete string.
  • multiple (bool, default False): If True, allow the user to choose multiple answers separated by delimiter.
  • as_indices (bool, default False): If True, return the indices for the choices. If a choice is a tuple of two strings, the first is assumed to be the index. Otherwise the index in the list is returned.
  • delimiter (str, default ','): If multiple, separate answers by this string. Raise a warning if this string is contained in any of the choices.
  • icon (bool, default True): If True, include the question icon.
  • warn (bool, default True): If True, raise warnings when invalid input is entered.
  • noask (bool, default False): If True, skip printing the question and return the default value.
Returns
  • A string for a single answer or a tuple of strings if multiple is True.
def get_password( username: Optional[str] = None, minimum_length: Optional[int] = None, confirm: bool = True, **kw: Any) -> str:
461def get_password(
462    username: Optional[str] = None,
463    minimum_length: Optional[int] = None,
464    confirm: bool = True,
465    **kw: Any
466) -> str:
467    """
468    Prompt the user for a password.
469
470    Parameters
471    ----------
472    username: Optional[str], default None
473        If provided, print the username when asking for a password.
474
475    minimum_length: Optional[int], default None
476        If provided, enforce a password of at least this length.
477
478    confirm: bool, default True
479        If `True`, prompt the user for a password twice.
480
481    Returns
482    -------
483    The password string (censored from terminal output when typing).
484
485    Examples
486    --------
487    ```python-repl
488    >>> get_password()
489     ❓ Password: *******
490     ❓ Confirm password: *******
491    'hunter2'
492    ```
493
494    """
495    from meerschaum.utils.warnings import warn
496    while True:
497        password = prompt(
498            "Password" + (f" for user '{username}':" if username is not None else ":"),
499            is_password=True,
500            **kw
501        )
502        if minimum_length is not None and len(password) < minimum_length:
503            warn(
504                "Password is too short. " +
505                f"Please enter a password that is at least {minimum_length} characters.",
506                stack=False
507            )
508            continue
509
510        if not confirm:
511            return password
512
513        _password = prompt(
514            "Confirm password" + (f" for user '{username}':" if username is not None else ":"),
515            is_password=True,
516            **kw
517        )
518        if password != _password:
519            warn("Passwords do not match! Please try again.", stack=False)
520            continue
521        else:
522            return password

Prompt the user for a password.

Parameters
  • username (Optional[str], default None): If provided, print the username when asking for a password.
  • minimum_length (Optional[int], default None): If provided, enforce a password of at least this length.
  • confirm (bool, default True): If True, prompt the user for a password twice.
Returns
  • The password string (censored from terminal output when typing).
Examples
>>> get_password()
 ❓ Password: *******
 ❓ Confirm password: *******
'hunter2'
def get_email( username: Optional[str] = None, allow_omit: bool = True, **kw: Any) -> str:
525def get_email(username: Optional[str] = None, allow_omit: bool = True, **kw: Any) -> str:
526    """
527    Prompt the user for an email and enforce that it's valid.
528
529    Parameters
530    ----------
531    username: Optional[str], default None
532        If provided, print the username in the prompt.
533
534    allow_omit: bool, default True
535        If `True`, allow the user to omit the email.
536
537    Returns
538    -------
539    The provided email string.
540
541    Examples
542    --------
543    ```python-repl
544    >>> get_email()
545     ❓ Email (empty to omit): foo@foo
546     Invalid email! Please try again.
547     ❓ Email (empty to omit): foo@foo.com
548    'foo@foo.com'
549    ```
550    """
551    from meerschaum.utils.warnings import warn
552    from meerschaum.utils.misc import is_valid_email
553    while True:
554        email = prompt(
555            "Email" + (f" for user '{username}'" if username is not None else "") +
556            (" (empty to omit):" if allow_omit else ": "),
557            **kw
558        )
559        if (allow_omit and email == '') or is_valid_email(email):
560            return email
561        warn("Invalid email! Please try again.", stack=False)

Prompt the user for an email and enforce that it's valid.

Parameters
  • username (Optional[str], default None): If provided, print the username in the prompt.
  • allow_omit (bool, default True): If True, allow the user to omit the email.
Returns
  • The provided email string.
Examples
>>> get_email()
 ❓ Email (empty to omit): foo@foo
 Invalid email! Please try again.
 ❓ Email (empty to omit): foo@foo.com
'foo@foo.com'
def check_noask(noask: bool = False) -> bool:
564def check_noask(noask: bool = False) -> bool:
565    """
566    Flip `noask` to `True` if `MRSM_NOASK` is set.
567    """
568    from meerschaum.config.static import STATIC_CONFIG
569    NOASK = STATIC_CONFIG['environment']['noask']
570    if noask:
571        return True
572    return (
573        os.environ.get(NOASK, 'false').lower()
574        in ('1', 'true')
575    )

Flip noask to True if MRSM_NOASK is set.

def get_connectors_completer(*types: str):
578def get_connectors_completer(*types: str):
579    """
580    Return a prompt-toolkit Completer object to pass into `prompt()`.
581    """
582    from meerschaum.utils.misc import get_connector_labels
583    prompt_toolkit_completion = mrsm.attempt_import('prompt_toolkit.completion', lazy=False)
584    Completer = prompt_toolkit_completion.Completer
585    Completion = prompt_toolkit_completion.Completion
586
587    class ConnectorCompleter(Completer):
588        def get_completions(self, document, complete_event):
589            for label in get_connector_labels(*types, search_term=document.text):
590                yield Completion(label, start_position=(-1 * len(document.text)))
591
592    return ConnectorCompleter()

Return a prompt-toolkit Completer object to pass into prompt().