python-interactive-courses 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
pyics/app/prompts.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import textwrap
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich.console import Group, RenderableType
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from pyics.app.cli import CLI, CURSOR_MARKER
|
|
16
|
+
|
|
17
|
+
_SUBMIT_KEYS = ("\r", "\n")
|
|
18
|
+
_BACKSPACE_KEYS = ("\x7f", "\x08")
|
|
19
|
+
_CANCEL_KEY = "\x03"
|
|
20
|
+
_BACK_KEY = "\x1b" # a lone esc; KeyListener delivers escape sequences as other tokens
|
|
21
|
+
_SCROLL_KEYS = {"UP": -1, "DOWN": 1}
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromptCancelled(Exception):
|
|
27
|
+
"""Raised when the user cancels a prompt (ctrl-c). Aborts the whole flow, not just the
|
|
28
|
+
current field."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PromptBack(Exception):
|
|
32
|
+
"""Raised when the user asks to go back to the previous prompt (esc). Only raised by
|
|
33
|
+
prompts run with ``allow_back=True``; see ``PromptSession``."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Prompt(ABC):
|
|
37
|
+
"""
|
|
38
|
+
Base class for blocking, single-field input widgets rendered into a CLI's main content
|
|
39
|
+
panel.
|
|
40
|
+
|
|
41
|
+
``run()`` blocks the calling thread until the field is submitted, while the CLI's own
|
|
42
|
+
render thread keeps repainting the content ``run()`` pushes. This lets a script's main
|
|
43
|
+
thread simply be "blocked by input requests" (see scripts/create_task.py).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
hotkeys: dict[str, str]
|
|
47
|
+
# whether the terminal cursor is shown at CURSOR_MARKER, rather than the marker only
|
|
48
|
+
# keeping the viewport scrolled to it
|
|
49
|
+
show_cursor = True
|
|
50
|
+
# whether up/down (and the mouse wheel) scroll the main content, if the widget itself
|
|
51
|
+
# doesn't use them
|
|
52
|
+
scroll_with_arrows = True
|
|
53
|
+
|
|
54
|
+
def __init__(self, label: str, *, details: RenderableType | None = None) -> None:
|
|
55
|
+
self.label = label
|
|
56
|
+
self.details = details
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def render_body(self) -> RenderableType:
|
|
60
|
+
"""The widget in its current state. Embed ``CURSOR_MARKER`` where the terminal
|
|
61
|
+
cursor should be."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def handle_key(self, key: str) -> bool:
|
|
65
|
+
"""Handle one key token from the KeyListener. Returns True once submitted."""
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def answer(self) -> str:
|
|
69
|
+
"""The current answer."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def set_answer(self, answer: str) -> None:
|
|
73
|
+
"""Prefill the widget with an answer."""
|
|
74
|
+
|
|
75
|
+
def parse(self, answer: str) -> Any:
|
|
76
|
+
"""Convert an answer to this prompt's value. Raises if the answer is invalid."""
|
|
77
|
+
return answer
|
|
78
|
+
|
|
79
|
+
def render(self) -> RenderableType:
|
|
80
|
+
# details go below the widget, so long ones can't push the input (and the
|
|
81
|
+
# label in the panel title) out of the cursor-following viewport
|
|
82
|
+
if self.details is None:
|
|
83
|
+
return self.render_body()
|
|
84
|
+
return Group(self.render_body(), Text(""), self.details)
|
|
85
|
+
|
|
86
|
+
def run(self, cli: CLI, q: queue.Queue[str], *, allow_back: bool = False) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Block until the field is submitted and return the answer. The CLI's previous main
|
|
89
|
+
content, hotkeys and cursor state are restored afterwards.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
cli: CLI
|
|
94
|
+
CLI to render into.
|
|
95
|
+
q: queue.Queue[str]
|
|
96
|
+
A running KeyListener's queue. Nothing else may read from it while this runs.
|
|
97
|
+
allow_back: bool (optional)
|
|
98
|
+
If True, esc raises ``PromptBack``. ctrl-c always raises ``PromptCancelled``.
|
|
99
|
+
"""
|
|
100
|
+
old_state = (cli.hotkeys, cli.main_content, cli.main_title, cli.cursor_visible)
|
|
101
|
+
try:
|
|
102
|
+
cli.cursor_visible = self.show_cursor
|
|
103
|
+
cli.scroll_offset = 0
|
|
104
|
+
back = {"esc": "back"} if allow_back else {}
|
|
105
|
+
scroll = {"↑/↓": "scroll"} if self.scroll_with_arrows else {}
|
|
106
|
+
cli.update_hotkeys({**back, **scroll, **self.hotkeys, "ctrl-c": "cancel"})
|
|
107
|
+
cli.update_main_content(self.render(), title=self.label)
|
|
108
|
+
while True:
|
|
109
|
+
key = q.get()
|
|
110
|
+
if key == _CANCEL_KEY:
|
|
111
|
+
raise PromptCancelled()
|
|
112
|
+
if allow_back and key == _BACK_KEY:
|
|
113
|
+
raise PromptBack()
|
|
114
|
+
if self.scroll_with_arrows and key in _SCROLL_KEYS:
|
|
115
|
+
# not re-rendered, so the viewport doesn't jump back to the cursor
|
|
116
|
+
cli.scroll(_SCROLL_KEYS[key])
|
|
117
|
+
continue
|
|
118
|
+
if self.handle_key(key):
|
|
119
|
+
return self.answer()
|
|
120
|
+
cli.update_main_content(self.render(), title=self.label)
|
|
121
|
+
finally:
|
|
122
|
+
# restored directly, since update_main_content would re-wrap the old content
|
|
123
|
+
cli.hotkeys, cli.main_content, cli.main_title, cli.cursor_visible = old_state
|
|
124
|
+
cli._needs_redraw = True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TextInput(Prompt):
|
|
128
|
+
"""Single- or multi-line text entry, with the terminal cursor tracking the caret."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
label: str,
|
|
133
|
+
*,
|
|
134
|
+
multiline: bool = False,
|
|
135
|
+
initial: str = "",
|
|
136
|
+
details: RenderableType | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
super().__init__(label, details=details)
|
|
139
|
+
self.multiline = multiline
|
|
140
|
+
self.hotkeys = {"ctrl-d" if multiline else "enter": "submit"}
|
|
141
|
+
self.set_answer(initial)
|
|
142
|
+
|
|
143
|
+
def answer(self) -> str:
|
|
144
|
+
return self.buffer
|
|
145
|
+
|
|
146
|
+
def set_answer(self, answer: str) -> None:
|
|
147
|
+
self.buffer = answer
|
|
148
|
+
self.cursor_index = len(answer)
|
|
149
|
+
|
|
150
|
+
def _format(self, text: str) -> RenderableType:
|
|
151
|
+
return Text(text)
|
|
152
|
+
|
|
153
|
+
def render_body(self) -> RenderableType:
|
|
154
|
+
i = self.cursor_index
|
|
155
|
+
return self._format(self.buffer[:i] + CURSOR_MARKER + self.buffer[i:])
|
|
156
|
+
|
|
157
|
+
def handle_key(self, key: str) -> bool:
|
|
158
|
+
i = self.cursor_index
|
|
159
|
+
if key in _SUBMIT_KEYS and self.multiline:
|
|
160
|
+
self._insert("\n")
|
|
161
|
+
elif key in _SUBMIT_KEYS or (self.multiline and key == "\x04"): # ctrl-d
|
|
162
|
+
return True
|
|
163
|
+
elif key in _BACKSPACE_KEYS:
|
|
164
|
+
if i > 0:
|
|
165
|
+
self.buffer = self.buffer[: i - 1] + self.buffer[i:]
|
|
166
|
+
self.cursor_index -= 1
|
|
167
|
+
elif key == "LEFT":
|
|
168
|
+
self.cursor_index = max(0, i - 1)
|
|
169
|
+
elif key == "RIGHT":
|
|
170
|
+
self.cursor_index = min(len(self.buffer), i + 1)
|
|
171
|
+
elif len(key) == 1 and key.isprintable() and key != CURSOR_MARKER:
|
|
172
|
+
self._insert(key)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def _insert(self, s: str) -> None:
|
|
176
|
+
i = self.cursor_index
|
|
177
|
+
self.buffer = self.buffer[:i] + s + self.buffer[i:]
|
|
178
|
+
self.cursor_index += len(s)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CodeInput(TextInput):
|
|
182
|
+
"""``TextInput`` with live Python syntax highlighting."""
|
|
183
|
+
|
|
184
|
+
def _format(self, text: str) -> RenderableType:
|
|
185
|
+
return Syntax(text, "python", background_color="default")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _common_prefix(strings: list[str]) -> str:
|
|
189
|
+
"""Longest string that all ``strings`` start with. Compared character by character,
|
|
190
|
+
unlike ``os.path.commonpath``, which only matches whole path components."""
|
|
191
|
+
low, high = min(strings), max(strings)
|
|
192
|
+
for i, char in enumerate(low):
|
|
193
|
+
if char != high[i]:
|
|
194
|
+
return low[:i]
|
|
195
|
+
return low
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _complete_path(text: str) -> tuple[str, list[str]]:
|
|
199
|
+
"""
|
|
200
|
+
Bash-style tab completion of a (partial) file path. ``~`` is expanded, and dotfiles are
|
|
201
|
+
only matched if the partial name starts with ``.``.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
tuple[str, list[str]]
|
|
206
|
+
The path extended to the longest common prefix of its matches, and the matching
|
|
207
|
+
names to show if there are several (directories suffixed with ``/``). Matches are
|
|
208
|
+
separated by ``/``, which Windows accepts as well.
|
|
209
|
+
"""
|
|
210
|
+
text = os.path.expanduser(text)
|
|
211
|
+
matches = [
|
|
212
|
+
m.replace(os.sep, "/") + ("/" if os.path.isdir(m) else "")
|
|
213
|
+
for m in sorted(glob.glob(glob.escape(text) + "*"))
|
|
214
|
+
]
|
|
215
|
+
if len(matches) <= 1:
|
|
216
|
+
return (matches[0] if matches else text), []
|
|
217
|
+
head = text[: max(text.rfind("/"), text.rfind(os.sep)) + 1]
|
|
218
|
+
return _common_prefix(matches), [m[len(head) :] for m in matches]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class PathInput(TextInput):
|
|
222
|
+
"""Single-line ``TextInput`` for file paths, with bash-style tab completion. If the
|
|
223
|
+
completion is ambiguous, the candidates are listed below the input."""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self, label: str, *, initial: str = "", details: RenderableType | None = None
|
|
227
|
+
) -> None:
|
|
228
|
+
super().__init__(label, initial=initial, details=details)
|
|
229
|
+
self.hotkeys = {"tab": "complete", **self.hotkeys}
|
|
230
|
+
self.candidates: list[str] = []
|
|
231
|
+
|
|
232
|
+
def render_body(self) -> RenderableType:
|
|
233
|
+
text = super().render_body()
|
|
234
|
+
if not self.candidates:
|
|
235
|
+
return text
|
|
236
|
+
return Group(text, Text(""), Columns(Text(c, style="dim") for c in self.candidates))
|
|
237
|
+
|
|
238
|
+
def handle_key(self, key: str) -> bool:
|
|
239
|
+
if key == "\t":
|
|
240
|
+
self.buffer, self.candidates = _complete_path(self.buffer)
|
|
241
|
+
self.cursor_index = len(self.buffer)
|
|
242
|
+
return False
|
|
243
|
+
self.candidates = []
|
|
244
|
+
return super().handle_key(key)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class SelectInput(Prompt):
|
|
248
|
+
"""Select one of ``options`` with the up/down arrow keys. If ``hints`` has a hint for
|
|
249
|
+
the selected option, it is shown dimmed below it, separated by blank lines."""
|
|
250
|
+
|
|
251
|
+
show_cursor = False
|
|
252
|
+
scroll_with_arrows = False # up/down move the selection, which the viewport follows
|
|
253
|
+
|
|
254
|
+
def __init__(
|
|
255
|
+
self,
|
|
256
|
+
label: str,
|
|
257
|
+
options: list[str],
|
|
258
|
+
*,
|
|
259
|
+
hints: dict[str, str] | None = None,
|
|
260
|
+
details: RenderableType | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
if not options:
|
|
263
|
+
raise ValueError("SelectInput requires at least one option")
|
|
264
|
+
super().__init__(label, details=details)
|
|
265
|
+
self.hotkeys = {"↑/↓": "select", "enter": "confirm"}
|
|
266
|
+
self.options = options
|
|
267
|
+
self.hints = hints or {}
|
|
268
|
+
self.index = 0
|
|
269
|
+
|
|
270
|
+
def answer(self) -> str:
|
|
271
|
+
return self.options[self.index]
|
|
272
|
+
|
|
273
|
+
def set_answer(self, answer: str) -> None:
|
|
274
|
+
if answer in self.options:
|
|
275
|
+
self.index = self.options.index(answer)
|
|
276
|
+
|
|
277
|
+
def parse(self, answer: str) -> str:
|
|
278
|
+
if answer not in self.options:
|
|
279
|
+
raise ValueError(f"{answer!r} is not one of the options")
|
|
280
|
+
return answer
|
|
281
|
+
|
|
282
|
+
def render_body(self) -> RenderableType:
|
|
283
|
+
text = Text()
|
|
284
|
+
for i, option in enumerate(self.options):
|
|
285
|
+
if i:
|
|
286
|
+
text.append("\n")
|
|
287
|
+
if i != self.index:
|
|
288
|
+
text.append(" " + option)
|
|
289
|
+
continue
|
|
290
|
+
text.append(CURSOR_MARKER + "❯ ", style="bold blue")
|
|
291
|
+
text.append(option, style="reverse bold blue")
|
|
292
|
+
if hint := self.hints.get(option):
|
|
293
|
+
text.append("\n\n" + textwrap.indent(hint, " "), style="dim")
|
|
294
|
+
if i < len(self.options) - 1:
|
|
295
|
+
text.append("\n")
|
|
296
|
+
return text
|
|
297
|
+
|
|
298
|
+
def handle_key(self, key: str) -> bool:
|
|
299
|
+
if key == "UP":
|
|
300
|
+
self.index = max(0, self.index - 1)
|
|
301
|
+
elif key == "DOWN":
|
|
302
|
+
self.index = min(len(self.options) - 1, self.index + 1)
|
|
303
|
+
return key in _SUBMIT_KEYS
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ReorderInput(Prompt):
|
|
307
|
+
"""Reorder ``options`` with the arrow keys: space picks up the selected option, up/down
|
|
308
|
+
then move it, and space drops it again. Answers are the new order, as comma-separated
|
|
309
|
+
indices into ``options``; ``parse`` returns the reordered options."""
|
|
310
|
+
|
|
311
|
+
show_cursor = False
|
|
312
|
+
scroll_with_arrows = False # up/down move the selection, which the viewport follows
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self, label: str, options: list[str], *, details: RenderableType | None = None
|
|
316
|
+
) -> None:
|
|
317
|
+
if not options:
|
|
318
|
+
raise ValueError("ReorderInput requires at least one option")
|
|
319
|
+
super().__init__(label, details=details)
|
|
320
|
+
self.hotkeys = {
|
|
321
|
+
"↑/↓": "select/move",
|
|
322
|
+
"space": "pick up/drop",
|
|
323
|
+
"enter": "confirm",
|
|
324
|
+
}
|
|
325
|
+
self.options = options
|
|
326
|
+
self.order = list(range(len(options)))
|
|
327
|
+
self.index = 0
|
|
328
|
+
self.grabbed = False
|
|
329
|
+
|
|
330
|
+
def _indices(self, answer: str) -> list[int]:
|
|
331
|
+
order = [int(i) for i in answer.split(",")]
|
|
332
|
+
if sorted(order) != list(range(len(self.options))):
|
|
333
|
+
raise ValueError(f"{answer!r} is not an order of the options")
|
|
334
|
+
return order
|
|
335
|
+
|
|
336
|
+
def answer(self) -> str:
|
|
337
|
+
return ",".join(map(str, self.order))
|
|
338
|
+
|
|
339
|
+
def set_answer(self, answer: str) -> None:
|
|
340
|
+
try:
|
|
341
|
+
self.order = self._indices(answer)
|
|
342
|
+
except ValueError:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
def parse(self, answer: str) -> list[str]:
|
|
346
|
+
return [self.options[i] for i in self._indices(answer)]
|
|
347
|
+
|
|
348
|
+
def render_body(self) -> RenderableType:
|
|
349
|
+
text = Text()
|
|
350
|
+
for i, option in enumerate(self.options[j] for j in self.order):
|
|
351
|
+
if i:
|
|
352
|
+
text.append("\n")
|
|
353
|
+
if i != self.index:
|
|
354
|
+
text.append(" " + option)
|
|
355
|
+
elif self.grabbed:
|
|
356
|
+
text.append(CURSOR_MARKER + "↕ ", style="bold yellow")
|
|
357
|
+
text.append(option, style="reverse bold yellow")
|
|
358
|
+
else:
|
|
359
|
+
text.append(CURSOR_MARKER + "❯ ", style="bold blue")
|
|
360
|
+
text.append(option, style="reverse bold blue")
|
|
361
|
+
return text
|
|
362
|
+
|
|
363
|
+
def handle_key(self, key: str) -> bool:
|
|
364
|
+
if key in ("UP", "DOWN"):
|
|
365
|
+
i = self.index
|
|
366
|
+
j = max(0, min(len(self.options) - 1, i + (1 if key == "DOWN" else -1)))
|
|
367
|
+
if self.grabbed:
|
|
368
|
+
self.order[i], self.order[j] = self.order[j], self.order[i]
|
|
369
|
+
self.index = j
|
|
370
|
+
elif key == " ":
|
|
371
|
+
self.grabbed = not self.grabbed
|
|
372
|
+
return key in _SUBMIT_KEYS
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class PromptSession:
|
|
376
|
+
"""
|
|
377
|
+
Runs a multi-prompt flow that the user can step back through, one prompt at a time.
|
|
378
|
+
|
|
379
|
+
Every answer is recorded. Going back (esc) discards the last answer and replays the
|
|
380
|
+
flow from the start: recorded answers are returned without showing their prompts, and
|
|
381
|
+
the first unanswered prompt is shown live, prefilled with the discarded answer. This
|
|
382
|
+
keeps flows plain imperative code (loops, branches, nested helpers), at the cost of one
|
|
383
|
+
requirement: a flow must ask the same prompts given the same answers. Side effects in a
|
|
384
|
+
flow are re-run on every replay.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(self, cli: CLI, q: queue.Queue[str], *, info: str = "") -> None:
|
|
388
|
+
self.cli = cli
|
|
389
|
+
self.q = q
|
|
390
|
+
self.info = info
|
|
391
|
+
self._answers: list[str] = []
|
|
392
|
+
self._position = 0
|
|
393
|
+
self._prefill: str | None = None
|
|
394
|
+
|
|
395
|
+
def run(self, flow: Callable[[PromptSession], T]) -> T:
|
|
396
|
+
"""Run ``flow(self)`` to completion, replaying it whenever the user goes back."""
|
|
397
|
+
while True:
|
|
398
|
+
self._position = 0
|
|
399
|
+
try:
|
|
400
|
+
return flow(self)
|
|
401
|
+
except PromptBack:
|
|
402
|
+
self._prefill = self._answers.pop()
|
|
403
|
+
self.cli.update_info(self.info)
|
|
404
|
+
|
|
405
|
+
def ask(self, prompt: Prompt, parse: Callable[[Any], T] = lambda value: value) -> T:
|
|
406
|
+
"""
|
|
407
|
+
Ask ``prompt`` and return its answer, converted by ``prompt.parse`` and then
|
|
408
|
+
``parse``. If either raises, the error is shown in the info bar and the prompt is
|
|
409
|
+
asked again, keeping the rejected input.
|
|
410
|
+
"""
|
|
411
|
+
if self._position < len(self._answers):
|
|
412
|
+
try:
|
|
413
|
+
value = parse(prompt.parse(self._answers[self._position]))
|
|
414
|
+
except Exception: # noqa: BLE001
|
|
415
|
+
# the recorded answer is no longer valid (e.g. a loaded file changed), so
|
|
416
|
+
# neither are the ones after it
|
|
417
|
+
del self._answers[self._position :]
|
|
418
|
+
self._prefill = None
|
|
419
|
+
else:
|
|
420
|
+
self._position += 1
|
|
421
|
+
return value
|
|
422
|
+
|
|
423
|
+
if self._prefill is not None:
|
|
424
|
+
prompt.set_answer(self._prefill)
|
|
425
|
+
self._prefill = None
|
|
426
|
+
while True:
|
|
427
|
+
answer = prompt.run(self.cli, self.q, allow_back=self._position > 0)
|
|
428
|
+
try:
|
|
429
|
+
value = parse(prompt.parse(answer))
|
|
430
|
+
break
|
|
431
|
+
except Exception as err: # noqa: BLE001
|
|
432
|
+
self.cli.update_info(
|
|
433
|
+
f"[red]{type(err).__name__}:[/red] {escape(str(err))}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
self.cli.update_info(self.info)
|
|
437
|
+
self._answers.append(answer)
|
|
438
|
+
self._position += 1
|
|
439
|
+
return value
|