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.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. 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