comparison 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
comparison
equal deleted inserted replaced
-1:000000000000 0:4f3585e2f14b
1 # vim: fileencoding=utf-8
2
3 # Human friendly input/output in Python.
4 #
5 # Author: Peter Odding <peter@peterodding.com>
6 # Last Change: February 9, 2020
7 # URL: https://humanfriendly.readthedocs.io
8
9 """
10 Interactive terminal prompts.
11
12 The :mod:`~humanfriendly.prompts` module enables interaction with the user
13 (operator) by asking for confirmation (:func:`prompt_for_confirmation()`) and
14 asking to choose from a list of options (:func:`prompt_for_choice()`). It works
15 by rendering interactive prompts on the terminal.
16 """
17
18 # Standard library modules.
19 import logging
20 import sys
21
22 # Modules included in our package.
23 from humanfriendly.compat import interactive_prompt
24 from humanfriendly.terminal import (
25 HIGHLIGHT_COLOR,
26 ansi_strip,
27 ansi_wrap,
28 connected_to_terminal,
29 terminal_supports_colors,
30 warning,
31 )
32 from humanfriendly.text import format, concatenate
33
34 # Public identifiers that require documentation.
35 __all__ = (
36 'MAX_ATTEMPTS',
37 'TooManyInvalidReplies',
38 'logger',
39 'prepare_friendly_prompts',
40 'prepare_prompt_text',
41 'prompt_for_choice',
42 'prompt_for_confirmation',
43 'prompt_for_input',
44 'retry_limit',
45 )
46
47 MAX_ATTEMPTS = 10
48 """The number of times an interactive prompt is shown on invalid input (an integer)."""
49
50 # Initialize a logger for this module.
51 logger = logging.getLogger(__name__)
52
53
54 def prompt_for_confirmation(question, default=None, padding=True):
55 """
56 Prompt the user for confirmation.
57
58 :param question: The text that explains what the user is confirming (a string).
59 :param default: The default value (a boolean) or :data:`None`.
60 :param padding: Refer to the documentation of :func:`prompt_for_input()`.
61 :returns: - If the user enters 'yes' or 'y' then :data:`True` is returned.
62 - If the user enters 'no' or 'n' then :data:`False` is returned.
63 - If the user doesn't enter any text or standard input is not
64 connected to a terminal (which makes it impossible to prompt
65 the user) the value of the keyword argument ``default`` is
66 returned (if that value is not :data:`None`).
67 :raises: - Any exceptions raised by :func:`retry_limit()`.
68 - Any exceptions raised by :func:`prompt_for_input()`.
69
70 When `default` is :data:`False` and the user doesn't enter any text an
71 error message is printed and the prompt is repeated:
72
73 >>> prompt_for_confirmation("Are you sure?")
74 <BLANKLINE>
75 Are you sure? [y/n]
76 <BLANKLINE>
77 Error: Please enter 'yes' or 'no' (there's no default choice).
78 <BLANKLINE>
79 Are you sure? [y/n]
80
81 The same thing happens when the user enters text that isn't recognized:
82
83 >>> prompt_for_confirmation("Are you sure?")
84 <BLANKLINE>
85 Are you sure? [y/n] about what?
86 <BLANKLINE>
87 Error: Please enter 'yes' or 'no' (the text 'about what?' is not recognized).
88 <BLANKLINE>
89 Are you sure? [y/n]
90 """
91 # Generate the text for the prompt.
92 prompt_text = prepare_prompt_text(question, bold=True)
93 # Append the valid replies (and default reply) to the prompt text.
94 hint = "[Y/n]" if default else "[y/N]" if default is not None else "[y/n]"
95 prompt_text += " %s " % prepare_prompt_text(hint, color=HIGHLIGHT_COLOR)
96 # Loop until a valid response is given.
97 logger.debug("Requesting interactive confirmation from terminal: %r", ansi_strip(prompt_text).rstrip())
98 for attempt in retry_limit():
99 reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
100 if reply.lower() in ('y', 'yes'):
101 logger.debug("Confirmation granted by reply (%r).", reply)
102 return True
103 elif reply.lower() in ('n', 'no'):
104 logger.debug("Confirmation denied by reply (%r).", reply)
105 return False
106 elif (not reply) and default is not None:
107 logger.debug("Default choice selected by empty reply (%r).",
108 "granted" if default else "denied")
109 return default
110 else:
111 details = ("the text '%s' is not recognized" % reply
112 if reply else "there's no default choice")
113 logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
114 "invalid" if reply else "empty", details,
115 attempt, MAX_ATTEMPTS)
116 warning("{indent}Error: Please enter 'yes' or 'no' ({details}).",
117 indent=' ' if padding else '', details=details)
118
119
120 def prompt_for_choice(choices, default=None, padding=True):
121 """
122 Prompt the user to select a choice from a group of options.
123
124 :param choices: A sequence of strings with available options.
125 :param default: The default choice if the user simply presses Enter
126 (expected to be a string, defaults to :data:`None`).
127 :param padding: Refer to the documentation of
128 :func:`~humanfriendly.prompts.prompt_for_input()`.
129 :returns: The string corresponding to the user's choice.
130 :raises: - :exc:`~exceptions.ValueError` if `choices` is an empty sequence.
131 - Any exceptions raised by
132 :func:`~humanfriendly.prompts.retry_limit()`.
133 - Any exceptions raised by
134 :func:`~humanfriendly.prompts.prompt_for_input()`.
135
136 When no options are given an exception is raised:
137
138 >>> prompt_for_choice([])
139 Traceback (most recent call last):
140 File "humanfriendly/prompts.py", line 148, in prompt_for_choice
141 raise ValueError("Can't prompt for choice without any options!")
142 ValueError: Can't prompt for choice without any options!
143
144 If a single option is given the user isn't prompted:
145
146 >>> prompt_for_choice(['only one choice'])
147 'only one choice'
148
149 Here's what the actual prompt looks like by default:
150
151 >>> prompt_for_choice(['first option', 'second option'])
152 <BLANKLINE>
153 1. first option
154 2. second option
155 <BLANKLINE>
156 Enter your choice as a number or unique substring (Control-C aborts): second
157 <BLANKLINE>
158 'second option'
159
160 If you don't like the whitespace (empty lines and indentation):
161
162 >>> prompt_for_choice(['first option', 'second option'], padding=False)
163 1. first option
164 2. second option
165 Enter your choice as a number or unique substring (Control-C aborts): first
166 'first option'
167 """
168 indent = ' ' if padding else ''
169 # Make sure we can use 'choices' more than once (i.e. not a generator).
170 choices = list(choices)
171 if len(choices) == 1:
172 # If there's only one option there's no point in prompting the user.
173 logger.debug("Skipping interactive prompt because there's only option (%r).", choices[0])
174 return choices[0]
175 elif not choices:
176 # We can't render a choice prompt without any options.
177 raise ValueError("Can't prompt for choice without any options!")
178 # Generate the prompt text.
179 prompt_text = ('\n\n' if padding else '\n').join([
180 # Present the available choices in a user friendly way.
181 "\n".join([
182 (u" %i. %s" % (i, choice)) + (" (default choice)" if choice == default else "")
183 for i, choice in enumerate(choices, start=1)
184 ]),
185 # Instructions for the user.
186 "Enter your choice as a number or unique substring (Control-C aborts): ",
187 ])
188 prompt_text = prepare_prompt_text(prompt_text, bold=True)
189 # Loop until a valid choice is made.
190 logger.debug("Requesting interactive choice on terminal (options are %s) ..",
191 concatenate(map(repr, choices)))
192 for attempt in retry_limit():
193 reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
194 if not reply and default is not None:
195 logger.debug("Default choice selected by empty reply (%r).", default)
196 return default
197 elif reply.isdigit():
198 index = int(reply) - 1
199 if 0 <= index < len(choices):
200 logger.debug("Option (%r) selected by numeric reply (%s).", choices[index], reply)
201 return choices[index]
202 # Check for substring matches.
203 matches = []
204 for choice in choices:
205 lower_reply = reply.lower()
206 lower_choice = choice.lower()
207 if lower_reply == lower_choice:
208 # If we have an 'exact' match we return it immediately.
209 logger.debug("Option (%r) selected by reply (exact match).", choice)
210 return choice
211 elif lower_reply in lower_choice and len(lower_reply) > 0:
212 # Otherwise we gather substring matches.
213 matches.append(choice)
214 if len(matches) == 1:
215 # If a single choice was matched we return it.
216 logger.debug("Option (%r) selected by reply (substring match on %r).", matches[0], reply)
217 return matches[0]
218 else:
219 # Give the user a hint about what went wrong.
220 if matches:
221 details = format("text '%s' matches more than one choice: %s", reply, concatenate(matches))
222 elif reply.isdigit():
223 details = format("number %i is not a valid choice", int(reply))
224 elif reply and not reply.isspace():
225 details = format("text '%s' doesn't match any choices", reply)
226 else:
227 details = "there's no default choice"
228 logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
229 "invalid" if reply else "empty", details,
230 attempt, MAX_ATTEMPTS)
231 warning("%sError: Invalid input (%s).", indent, details)
232
233
234 def prompt_for_input(question, default=None, padding=True, strip=True):
235 """
236 Prompt the user for input (free form text).
237
238 :param question: An explanation of what is expected from the user (a string).
239 :param default: The return value if the user doesn't enter any text or
240 standard input is not connected to a terminal (which
241 makes it impossible to prompt the user).
242 :param padding: Render empty lines before and after the prompt to make it
243 stand out from the surrounding text? (a boolean, defaults
244 to :data:`True`)
245 :param strip: Strip leading/trailing whitespace from the user's reply?
246 :returns: The text entered by the user (a string) or the value of the
247 `default` argument.
248 :raises: - :exc:`~exceptions.KeyboardInterrupt` when the program is
249 interrupted_ while the prompt is active, for example
250 because the user presses Control-C_.
251 - :exc:`~exceptions.EOFError` when reading from `standard input`_
252 fails, for example because the user presses Control-D_ or
253 because the standard input stream is redirected (only if
254 `default` is :data:`None`).
255
256 .. _Control-C: https://en.wikipedia.org/wiki/Control-C#In_command-line_environments
257 .. _Control-D: https://en.wikipedia.org/wiki/End-of-transmission_character#Meaning_in_Unix
258 .. _interrupted: https://en.wikipedia.org/wiki/Unix_signal#SIGINT
259 .. _standard input: https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29
260 """
261 prepare_friendly_prompts()
262 reply = None
263 try:
264 # Prefix an empty line to the text and indent by one space?
265 if padding:
266 question = '\n' + question
267 question = question.replace('\n', '\n ')
268 # Render the prompt and wait for the user's reply.
269 try:
270 reply = interactive_prompt(question)
271 finally:
272 if reply is None:
273 # If the user terminated the prompt using Control-C or
274 # Control-D instead of pressing Enter no newline will be
275 # rendered after the prompt's text. The result looks kind of
276 # weird:
277 #
278 # $ python -c 'print(raw_input("Are you sure? "))'
279 # Are you sure? ^CTraceback (most recent call last):
280 # File "<string>", line 1, in <module>
281 # KeyboardInterrupt
282 #
283 # We can avoid this by emitting a newline ourselves if an
284 # exception was raised (signaled by `reply' being None).
285 sys.stderr.write('\n')
286 if padding:
287 # If the caller requested (didn't opt out of) `padding' then we'll
288 # emit a newline regardless of whether an exception is being
289 # handled. This helps to make interactive prompts `stand out' from
290 # a surrounding `wall of text' on the terminal.
291 sys.stderr.write('\n')
292 except BaseException as e:
293 if isinstance(e, EOFError) and default is not None:
294 # If standard input isn't connected to an interactive terminal
295 # but the caller provided a default we'll return that.
296 logger.debug("Got EOF from terminal, returning default value (%r) ..", default)
297 return default
298 else:
299 # Otherwise we log that the prompt was interrupted but propagate
300 # the exception to the caller.
301 logger.warning("Interactive prompt was interrupted by exception!", exc_info=True)
302 raise
303 if default is not None and not reply:
304 # If the reply is empty and `default' is None we don't want to return
305 # None because it's nicer for callers to be able to assume that the
306 # return value is always a string.
307 return default
308 else:
309 return reply.strip()
310
311
312 def prepare_prompt_text(prompt_text, **options):
313 """
314 Wrap a text to be rendered as an interactive prompt in ANSI escape sequences.
315
316 :param prompt_text: The text to render on the prompt (a string).
317 :param options: Any keyword arguments are passed on to :func:`.ansi_wrap()`.
318 :returns: The resulting prompt text (a string).
319
320 ANSI escape sequences are only used when the standard output stream is
321 connected to a terminal. When the standard input stream is connected to a
322 terminal any escape sequences are wrapped in "readline hints".
323 """
324 return (ansi_wrap(prompt_text, readline_hints=connected_to_terminal(sys.stdin), **options)
325 if terminal_supports_colors(sys.stdout)
326 else prompt_text)
327
328
329 def prepare_friendly_prompts():
330 u"""
331 Make interactive prompts more user friendly.
332
333 The prompts presented by :func:`python2:raw_input()` (in Python 2) and
334 :func:`python3:input()` (in Python 3) are not very user friendly by
335 default, for example the cursor keys (:kbd:`←`, :kbd:`↑`, :kbd:`→` and
336 :kbd:`↓`) and the :kbd:`Home` and :kbd:`End` keys enter characters instead
337 of performing the action you would expect them to. By simply importing the
338 :mod:`readline` module these prompts become much friendlier (as mentioned
339 in the Python standard library documentation).
340
341 This function is called by the other functions in this module to enable
342 user friendly prompts.
343 """
344 import readline # NOQA
345
346
347 def retry_limit(limit=MAX_ATTEMPTS):
348 """
349 Allow the user to provide valid input up to `limit` times.
350
351 :param limit: The maximum number of attempts (a number,
352 defaults to :data:`MAX_ATTEMPTS`).
353 :returns: A generator of numbers starting from one.
354 :raises: :exc:`TooManyInvalidReplies` when an interactive prompt
355 receives repeated invalid input (:data:`MAX_ATTEMPTS`).
356
357 This function returns a generator for interactive prompts that want to
358 repeat on invalid input without getting stuck in infinite loops.
359 """
360 for i in range(limit):
361 yield i + 1
362 msg = "Received too many invalid replies on interactive prompt, giving up! (tried %i times)"
363 formatted_msg = msg % limit
364 # Make sure the event is logged.
365 logger.warning(formatted_msg)
366 # Force the caller to decide what to do now.
367 raise TooManyInvalidReplies(formatted_msg)
368
369
370 class TooManyInvalidReplies(Exception):
371
372 """Raised by interactive prompts when they've received too many invalid inputs."""