diff env/lib/python3.9/site-packages/humanfriendly/prompts.py @ 0:4f3585e2f14b draft default tip

"planemo upload commit 60cee0fc7c0cda8592644e1aad72851dec82c959"
author shellac
date Mon, 22 Mar 2021 18:12:50 +0000
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/env/lib/python3.9/site-packages/humanfriendly/prompts.py	Mon Mar 22 18:12:50 2021 +0000
@@ -0,0 +1,372 @@
+# vim: fileencoding=utf-8
+
+# Human friendly input/output in Python.
+#
+# Author: Peter Odding <peter@peterodding.com>
+# Last Change: February 9, 2020
+# URL: https://humanfriendly.readthedocs.io
+
+"""
+Interactive terminal prompts.
+
+The :mod:`~humanfriendly.prompts` module enables interaction with the user
+(operator) by asking for confirmation (:func:`prompt_for_confirmation()`) and
+asking to choose from a list of options (:func:`prompt_for_choice()`). It works
+by rendering interactive prompts on the terminal.
+"""
+
+# Standard library modules.
+import logging
+import sys
+
+# Modules included in our package.
+from humanfriendly.compat import interactive_prompt
+from humanfriendly.terminal import (
+    HIGHLIGHT_COLOR,
+    ansi_strip,
+    ansi_wrap,
+    connected_to_terminal,
+    terminal_supports_colors,
+    warning,
+)
+from humanfriendly.text import format, concatenate
+
+# Public identifiers that require documentation.
+__all__ = (
+    'MAX_ATTEMPTS',
+    'TooManyInvalidReplies',
+    'logger',
+    'prepare_friendly_prompts',
+    'prepare_prompt_text',
+    'prompt_for_choice',
+    'prompt_for_confirmation',
+    'prompt_for_input',
+    'retry_limit',
+)
+
+MAX_ATTEMPTS = 10
+"""The number of times an interactive prompt is shown on invalid input (an integer)."""
+
+# Initialize a logger for this module.
+logger = logging.getLogger(__name__)
+
+
+def prompt_for_confirmation(question, default=None, padding=True):
+    """
+    Prompt the user for confirmation.
+
+    :param question: The text that explains what the user is confirming (a string).
+    :param default: The default value (a boolean) or :data:`None`.
+    :param padding: Refer to the documentation of :func:`prompt_for_input()`.
+    :returns: - If the user enters 'yes' or 'y' then :data:`True` is returned.
+              - If the user enters 'no' or 'n' then :data:`False`  is returned.
+              - If the user doesn't enter any text or standard input is not
+                connected to a terminal (which makes it impossible to prompt
+                the user) the value of the keyword argument ``default`` is
+                returned (if that value is not :data:`None`).
+    :raises: - Any exceptions raised by :func:`retry_limit()`.
+             - Any exceptions raised by :func:`prompt_for_input()`.
+
+    When `default` is :data:`False` and the user doesn't enter any text an
+    error message is printed and the prompt is repeated:
+
+    >>> prompt_for_confirmation("Are you sure?")
+     <BLANKLINE>
+     Are you sure? [y/n]
+     <BLANKLINE>
+     Error: Please enter 'yes' or 'no' (there's no default choice).
+     <BLANKLINE>
+     Are you sure? [y/n]
+
+    The same thing happens when the user enters text that isn't recognized:
+
+    >>> prompt_for_confirmation("Are you sure?")
+     <BLANKLINE>
+     Are you sure? [y/n] about what?
+     <BLANKLINE>
+     Error: Please enter 'yes' or 'no' (the text 'about what?' is not recognized).
+     <BLANKLINE>
+     Are you sure? [y/n]
+    """
+    # Generate the text for the prompt.
+    prompt_text = prepare_prompt_text(question, bold=True)
+    # Append the valid replies (and default reply) to the prompt text.
+    hint = "[Y/n]" if default else "[y/N]" if default is not None else "[y/n]"
+    prompt_text += " %s " % prepare_prompt_text(hint, color=HIGHLIGHT_COLOR)
+    # Loop until a valid response is given.
+    logger.debug("Requesting interactive confirmation from terminal: %r", ansi_strip(prompt_text).rstrip())
+    for attempt in retry_limit():
+        reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
+        if reply.lower() in ('y', 'yes'):
+            logger.debug("Confirmation granted by reply (%r).", reply)
+            return True
+        elif reply.lower() in ('n', 'no'):
+            logger.debug("Confirmation denied by reply (%r).", reply)
+            return False
+        elif (not reply) and default is not None:
+            logger.debug("Default choice selected by empty reply (%r).",
+                         "granted" if default else "denied")
+            return default
+        else:
+            details = ("the text '%s' is not recognized" % reply
+                       if reply else "there's no default choice")
+            logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
+                         "invalid" if reply else "empty", details,
+                         attempt, MAX_ATTEMPTS)
+            warning("{indent}Error: Please enter 'yes' or 'no' ({details}).",
+                    indent=' ' if padding else '', details=details)
+
+
+def prompt_for_choice(choices, default=None, padding=True):
+    """
+    Prompt the user to select a choice from a group of options.
+
+    :param choices: A sequence of strings with available options.
+    :param default: The default choice if the user simply presses Enter
+                    (expected to be a string, defaults to :data:`None`).
+    :param padding: Refer to the documentation of
+                    :func:`~humanfriendly.prompts.prompt_for_input()`.
+    :returns: The string corresponding to the user's choice.
+    :raises: - :exc:`~exceptions.ValueError` if `choices` is an empty sequence.
+             - Any exceptions raised by
+               :func:`~humanfriendly.prompts.retry_limit()`.
+             - Any exceptions raised by
+               :func:`~humanfriendly.prompts.prompt_for_input()`.
+
+    When no options are given an exception is raised:
+
+    >>> prompt_for_choice([])
+    Traceback (most recent call last):
+      File "humanfriendly/prompts.py", line 148, in prompt_for_choice
+        raise ValueError("Can't prompt for choice without any options!")
+    ValueError: Can't prompt for choice without any options!
+
+    If a single option is given the user isn't prompted:
+
+    >>> prompt_for_choice(['only one choice'])
+    'only one choice'
+
+    Here's what the actual prompt looks like by default:
+
+    >>> prompt_for_choice(['first option', 'second option'])
+    <BLANKLINE>
+      1. first option
+      2. second option
+    <BLANKLINE>
+     Enter your choice as a number or unique substring (Control-C aborts): second
+    <BLANKLINE>
+    'second option'
+
+    If you don't like the whitespace (empty lines and indentation):
+
+    >>> prompt_for_choice(['first option', 'second option'], padding=False)
+     1. first option
+     2. second option
+    Enter your choice as a number or unique substring (Control-C aborts): first
+    'first option'
+    """
+    indent = ' ' if padding else ''
+    # Make sure we can use 'choices' more than once (i.e. not a generator).
+    choices = list(choices)
+    if len(choices) == 1:
+        # If there's only one option there's no point in prompting the user.
+        logger.debug("Skipping interactive prompt because there's only option (%r).", choices[0])
+        return choices[0]
+    elif not choices:
+        # We can't render a choice prompt without any options.
+        raise ValueError("Can't prompt for choice without any options!")
+    # Generate the prompt text.
+    prompt_text = ('\n\n' if padding else '\n').join([
+        # Present the available choices in a user friendly way.
+        "\n".join([
+            (u" %i. %s" % (i, choice)) + (" (default choice)" if choice == default else "")
+            for i, choice in enumerate(choices, start=1)
+        ]),
+        # Instructions for the user.
+        "Enter your choice as a number or unique substring (Control-C aborts): ",
+    ])
+    prompt_text = prepare_prompt_text(prompt_text, bold=True)
+    # Loop until a valid choice is made.
+    logger.debug("Requesting interactive choice on terminal (options are %s) ..",
+                 concatenate(map(repr, choices)))
+    for attempt in retry_limit():
+        reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
+        if not reply and default is not None:
+            logger.debug("Default choice selected by empty reply (%r).", default)
+            return default
+        elif reply.isdigit():
+            index = int(reply) - 1
+            if 0 <= index < len(choices):
+                logger.debug("Option (%r) selected by numeric reply (%s).", choices[index], reply)
+                return choices[index]
+        # Check for substring matches.
+        matches = []
+        for choice in choices:
+            lower_reply = reply.lower()
+            lower_choice = choice.lower()
+            if lower_reply == lower_choice:
+                # If we have an 'exact' match we return it immediately.
+                logger.debug("Option (%r) selected by reply (exact match).", choice)
+                return choice
+            elif lower_reply in lower_choice and len(lower_reply) > 0:
+                # Otherwise we gather substring matches.
+                matches.append(choice)
+        if len(matches) == 1:
+            # If a single choice was matched we return it.
+            logger.debug("Option (%r) selected by reply (substring match on %r).", matches[0], reply)
+            return matches[0]
+        else:
+            # Give the user a hint about what went wrong.
+            if matches:
+                details = format("text '%s' matches more than one choice: %s", reply, concatenate(matches))
+            elif reply.isdigit():
+                details = format("number %i is not a valid choice", int(reply))
+            elif reply and not reply.isspace():
+                details = format("text '%s' doesn't match any choices", reply)
+            else:
+                details = "there's no default choice"
+            logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
+                         "invalid" if reply else "empty", details,
+                         attempt, MAX_ATTEMPTS)
+            warning("%sError: Invalid input (%s).", indent, details)
+
+
+def prompt_for_input(question, default=None, padding=True, strip=True):
+    """
+    Prompt the user for input (free form text).
+
+    :param question: An explanation of what is expected from the user (a string).
+    :param default: The return value if the user doesn't enter any text or
+                    standard input is not connected to a terminal (which
+                    makes it impossible to prompt the user).
+    :param padding: Render empty lines before and after the prompt to make it
+                    stand out from the surrounding text? (a boolean, defaults
+                    to :data:`True`)
+    :param strip: Strip leading/trailing whitespace from the user's reply?
+    :returns: The text entered by the user (a string) or the value of the
+              `default` argument.
+    :raises: - :exc:`~exceptions.KeyboardInterrupt` when the program is
+               interrupted_ while the prompt is active, for example
+               because the user presses Control-C_.
+             - :exc:`~exceptions.EOFError` when reading from `standard input`_
+               fails, for example because the user presses Control-D_ or
+               because the standard input stream is redirected (only if
+               `default` is :data:`None`).
+
+    .. _Control-C: https://en.wikipedia.org/wiki/Control-C#In_command-line_environments
+    .. _Control-D: https://en.wikipedia.org/wiki/End-of-transmission_character#Meaning_in_Unix
+    .. _interrupted: https://en.wikipedia.org/wiki/Unix_signal#SIGINT
+    .. _standard input: https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29
+    """
+    prepare_friendly_prompts()
+    reply = None
+    try:
+        # Prefix an empty line to the text and indent by one space?
+        if padding:
+            question = '\n' + question
+            question = question.replace('\n', '\n ')
+        # Render the prompt and wait for the user's reply.
+        try:
+            reply = interactive_prompt(question)
+        finally:
+            if reply is None:
+                # If the user terminated the prompt using Control-C or
+                # Control-D instead of pressing Enter no newline will be
+                # rendered after the prompt's text. The result looks kind of
+                # weird:
+                #
+                #   $ python -c 'print(raw_input("Are you sure? "))'
+                #   Are you sure? ^CTraceback (most recent call last):
+                #     File "<string>", line 1, in <module>
+                #   KeyboardInterrupt
+                #
+                # We can avoid this by emitting a newline ourselves if an
+                # exception was raised (signaled by `reply' being None).
+                sys.stderr.write('\n')
+            if padding:
+                # If the caller requested (didn't opt out of) `padding' then we'll
+                # emit a newline regardless of whether an exception is being
+                # handled. This helps to make interactive prompts `stand out' from
+                # a surrounding `wall of text' on the terminal.
+                sys.stderr.write('\n')
+    except BaseException as e:
+        if isinstance(e, EOFError) and default is not None:
+            # If standard input isn't connected to an interactive terminal
+            # but the caller provided a default we'll return that.
+            logger.debug("Got EOF from terminal, returning default value (%r) ..", default)
+            return default
+        else:
+            # Otherwise we log that the prompt was interrupted but propagate
+            # the exception to the caller.
+            logger.warning("Interactive prompt was interrupted by exception!", exc_info=True)
+            raise
+    if default is not None and not reply:
+        # If the reply is empty and `default' is None we don't want to return
+        # None because it's nicer for callers to be able to assume that the
+        # return value is always a string.
+        return default
+    else:
+        return reply.strip()
+
+
+def prepare_prompt_text(prompt_text, **options):
+    """
+    Wrap a text to be rendered as an interactive prompt in ANSI escape sequences.
+
+    :param prompt_text: The text to render on the prompt (a string).
+    :param options: Any keyword arguments are passed on to :func:`.ansi_wrap()`.
+    :returns: The resulting prompt text (a string).
+
+    ANSI escape sequences are only used when the standard output stream is
+    connected to a terminal. When the standard input stream is connected to a
+    terminal any escape sequences are wrapped in "readline hints".
+    """
+    return (ansi_wrap(prompt_text, readline_hints=connected_to_terminal(sys.stdin), **options)
+            if terminal_supports_colors(sys.stdout)
+            else prompt_text)
+
+
+def prepare_friendly_prompts():
+    u"""
+    Make interactive prompts more user friendly.
+
+    The prompts presented by :func:`python2:raw_input()` (in Python 2) and
+    :func:`python3:input()` (in Python 3) are not very user friendly by
+    default, for example the cursor keys (:kbd:`←`, :kbd:`↑`, :kbd:`→` and
+    :kbd:`↓`) and the :kbd:`Home` and :kbd:`End` keys enter characters instead
+    of performing the action you would expect them to. By simply importing the
+    :mod:`readline` module these prompts become much friendlier (as mentioned
+    in the Python standard library documentation).
+
+    This function is called by the other functions in this module to enable
+    user friendly prompts.
+    """
+    import readline  # NOQA
+
+
+def retry_limit(limit=MAX_ATTEMPTS):
+    """
+    Allow the user to provide valid input up to `limit` times.
+
+    :param limit: The maximum number of attempts (a number,
+                  defaults to :data:`MAX_ATTEMPTS`).
+    :returns: A generator of numbers starting from one.
+    :raises: :exc:`TooManyInvalidReplies` when an interactive prompt
+             receives repeated invalid input (:data:`MAX_ATTEMPTS`).
+
+    This function returns a generator for interactive prompts that want to
+    repeat on invalid input without getting stuck in infinite loops.
+    """
+    for i in range(limit):
+        yield i + 1
+    msg = "Received too many invalid replies on interactive prompt, giving up! (tried %i times)"
+    formatted_msg = msg % limit
+    # Make sure the event is logged.
+    logger.warning(formatted_msg)
+    # Force the caller to decide what to do now.
+    raise TooManyInvalidReplies(formatted_msg)
+
+
+class TooManyInvalidReplies(Exception):
+
+    """Raised by interactive prompts when they've received too many invalid inputs."""