| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- # 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.
- """
- try:
- import readline # NOQA
- except ImportError:
- # might not be available on Windows if pyreadline isn't installed
- pass
- 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."""
|