Mercurial > repos > shellac > guppy_basecaller
comparison 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 |
comparison
equal
deleted
inserted
replaced
1:75ca89e9b81c | 2:6af9afd405e9 |
---|---|
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.""" |