Mercurial > repos > shellac > guppy_basecaller
diff env/lib/python3.7/site-packages/humanfriendly/prompts.py @ 2:6af9afd405e9 draft
"planemo upload commit 0a63dd5f4d38a1f6944587f52a8cd79874177fc1"
author | shellac |
---|---|
date | Thu, 14 May 2020 14:56:58 -0400 |
parents | 26e78fe6e8c4 |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/env/lib/python3.7/site-packages/humanfriendly/prompts.py Thu May 14 14:56:58 2020 -0400 @@ -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."""