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/__init__.py ADDED
File without changes
pyics/app/actions.py ADDED
@@ -0,0 +1,179 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Optional
3
+
4
+ from pyics.util.logging import get_logger
5
+
6
+
7
+ class Action(ABC):
8
+ """A parameterless, kwargs-bound unit of work."""
9
+
10
+ def __init__(self, name: str, **kwargs):
11
+ """
12
+ Compose an Action object. Note that an Action object should be callable without
13
+ parameters (refer to how kwargs are used).
14
+
15
+ Parameters
16
+ ----------
17
+ name: str
18
+ Name of the Action
19
+ **kwargs
20
+ kwargs to encode call parameters to specific values.
21
+ """
22
+ self.name = name
23
+ self.kwargs = kwargs
24
+
25
+ def __call__(self):
26
+ get_logger(__name__).info(f"Triggered {self.name} action.")
27
+ self.exec(**self.kwargs)
28
+
29
+ @staticmethod
30
+ @abstractmethod
31
+ def exec(**kwargs) -> None: ...
32
+
33
+
34
+ class KeyAction(Action):
35
+ """An Action bound to a hotkey"""
36
+
37
+ def __init__(self, name: str, key: str, ref: Optional[str] = None, **kwargs):
38
+ """
39
+ Compose a KeyAction object. Note that an Action object should be callable without
40
+ parameters (refer to how kwargs are used).
41
+
42
+ Parameters
43
+ ----------
44
+ name: str
45
+ Name of the KeyAction
46
+ key: str
47
+ Hotkey associated with the action
48
+ ref: str (optional)
49
+ Optional key reference, in case the actual key-press is unwieldy.
50
+ **kwargs
51
+ kwargs to encode call parameters to specific values.
52
+ """
53
+ super().__init__(name, **kwargs)
54
+ self.key = key
55
+ self.ref = ref if ref else self.key
56
+
57
+ def __call__(self) -> None:
58
+ self.exec(**self.kwargs)
59
+
60
+
61
+ class Quit(KeyAction):
62
+ """
63
+ Quit application on ctrl-c press
64
+ """
65
+
66
+ def __init__(self, name: str = "Quit", key: str = "\x03", ref="ctrl-c", **kwargs):
67
+ """
68
+ Parameters
69
+ ----------
70
+ cli: CLI (kwarg)
71
+ CLI object
72
+ pyics: PyICs (kwarg)
73
+ PyICs object
74
+ """
75
+ super().__init__(name, key, ref=ref, **kwargs)
76
+
77
+ @staticmethod
78
+ def exec(**kwargs):
79
+ cli = kwargs["cli"]
80
+ pyics = kwargs["pyics"]
81
+
82
+ cli.running = False
83
+ pyics.running = False
84
+
85
+
86
+ class Next(KeyAction):
87
+ """Move to the next task."""
88
+
89
+ def __init__(self, name: str = "Next", key: str = "n", **kwargs):
90
+ super().__init__(name, key, **kwargs)
91
+
92
+ @staticmethod
93
+ def exec(**kwargs) -> None:
94
+ if (course := kwargs["pyics"].current_course) is not None:
95
+ course.next()
96
+
97
+
98
+ class Previous(KeyAction):
99
+ """Go back to the previous task."""
100
+
101
+ def __init__(self, name: str = "Previous", key: str = "p", **kwargs):
102
+ super().__init__(name, key, **kwargs)
103
+
104
+ @staticmethod
105
+ def exec(**kwargs) -> None:
106
+ if (course := kwargs["pyics"].current_course) is not None:
107
+ course.previous()
108
+
109
+
110
+ class Courses(KeyAction):
111
+ """Leave the current course for the course selector."""
112
+
113
+ def __init__(self, name: str = "Courses", key: str = "c", **kwargs):
114
+ super().__init__(name, key, **kwargs)
115
+
116
+ @staticmethod
117
+ def exec(**kwargs) -> None:
118
+ kwargs["pyics"].leave_course()
119
+
120
+
121
+ class ResetTask(KeyAction):
122
+ """Reset the current task."""
123
+
124
+ def __init__(self, name: str = "Reset Task", key: str = "r", **kwargs):
125
+ super().__init__(name, key, **kwargs)
126
+
127
+ @staticmethod
128
+ def exec(**kwargs) -> None:
129
+ """TODO"""
130
+ ...
131
+
132
+
133
+ class ToggleTrace(KeyAction):
134
+ """Turn tracing the current task's failed tests with snoop on or off."""
135
+
136
+ def __init__(self, name: str = "Trace", key: str = "t", **kwargs):
137
+ super().__init__(name, key, **kwargs)
138
+
139
+ @staticmethod
140
+ def exec(**kwargs) -> None:
141
+ if (course := kwargs["pyics"].current_course) is not None:
142
+ course.toggle_tracing()
143
+
144
+
145
+ class ScrollUp(KeyAction):
146
+ """Scroll the main content viewport up."""
147
+
148
+ def __init__(
149
+ self, name: str = "Scroll", key: str = "UP", ref: str = "\u2191/\u2193", **kwargs
150
+ ):
151
+ super().__init__(name, key, ref=ref, **kwargs)
152
+
153
+ @staticmethod
154
+ def exec(**kwargs) -> None:
155
+ kwargs["cli"].scroll(-1)
156
+
157
+
158
+ class ScrollDown(KeyAction):
159
+ """Scroll the main content viewport down."""
160
+
161
+ def __init__(
162
+ self, name: str = "Scroll", key: str = "DOWN", ref: str = "\u2191/\u2193", **kwargs
163
+ ):
164
+ super().__init__(name, key, ref=ref, **kwargs)
165
+
166
+ @staticmethod
167
+ def exec(**kwargs) -> None:
168
+ kwargs["cli"].scroll(1)
169
+
170
+
171
+ class Hint(KeyAction):
172
+ """Reveal the current task's next hint."""
173
+
174
+ def __init__(self, name: str = "Hint", key: str = "h", **kwargs):
175
+ super().__init__(name, key, **kwargs)
176
+
177
+ @staticmethod
178
+ def exec(**kwargs) -> None:
179
+ kwargs["pyics"].reveal_hint()
pyics/app/cli.py ADDED
@@ -0,0 +1,335 @@
1
+ import importlib.metadata
2
+ import sys
3
+ import threading
4
+ import time
5
+ from typing import Any
6
+
7
+ from pyfiglet import Figlet
8
+ from rich import box
9
+ from rich.align import Align
10
+ from rich.cells import cell_len
11
+ from rich.console import Console, ConsoleDimensions, Group, RenderableType
12
+ from rich.padding import Padding
13
+ from rich.panel import Panel
14
+ from rich.progress import BarColumn, Progress, TextColumn
15
+ from rich.segment import Segment
16
+ from rich.table import Table
17
+ from rich.text import Text, TextType
18
+
19
+ # the escape codes not used here are re-exported, as they used to be defined here
20
+ from pyics.util.terminal import ( # noqa: F401
21
+ ALT_SCREEN_ENTER,
22
+ ALT_SCREEN_LEAVE,
23
+ HIDE_CURSOR,
24
+ LINE_CLEAR,
25
+ LINE_UP,
26
+ SHOW_CURSOR,
27
+ )
28
+
29
+ # Private-use codepoint that input widgets (see pyics.app.prompts) embed in the main content
30
+ # to mark where the terminal cursor goes. Stripped before the frame is written.
31
+ CURSOR_MARKER = ""
32
+
33
+
34
+ class CLI:
35
+ def __init__(self) -> None:
36
+ self.running = False
37
+ self._thread: threading.Thread | None = None
38
+ # escape codes are written on Windows, too (see ``pyics.util.terminal``)
39
+ self.console = Console(legacy_windows=False)
40
+ self.progress = Progress(
41
+ TextColumn("[progress.description]{task.description}"),
42
+ BarColumn(
43
+ bar_width=None,
44
+ finished_style="blue",
45
+ pulse_style="blue",
46
+ complete_style="blue",
47
+ ),
48
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
49
+ expand=True,
50
+ )
51
+ self.task_id = self.progress.add_task("Progress", total=1)
52
+ self.hotkeys = {}
53
+
54
+ # main-content viewport scroll state, in lines
55
+ self.scroll_offset = 0
56
+ self._max_scroll_offset = 0
57
+ # scroll a CURSOR_MARKER into view on the next draw; only set when the content
58
+ # changes, so manual scrolling isn't undone by the next redraw
59
+ self._follow_cursor = False
60
+
61
+ # whether the terminal cursor is shown at a CURSOR_MARKER in the main content
62
+ self.cursor_visible = True
63
+
64
+ # the terminal size the last frame was drawn for, to redraw it once that changes
65
+ self._drawn_size: ConsoleDimensions | None = None
66
+
67
+ self.update_info("Ready")
68
+ self.update_main_content(
69
+ Text("Main program outputs will appear here...", style="dim"), "Main Output"
70
+ )
71
+ self.update_progress(0.0)
72
+
73
+ def __call__(self, *args: Any, **kwds: Any) -> Any:
74
+ """
75
+ Start up the cli Daemon.
76
+ """
77
+ self.running = True
78
+ self._thread = threading.Thread(target=self.run, daemon=True)
79
+ self._thread.start()
80
+
81
+ def stop(self) -> None:
82
+ """
83
+ Stop the cli Daemon, waiting for a frame that is being drawn, so that nothing is
84
+ drawn once this returns (e.g. after leaving the alternate screen).
85
+ """
86
+ self.running = False
87
+ if self._thread is not None and self._thread is not threading.current_thread():
88
+ self._thread.join(timeout=1)
89
+
90
+ def generate_header(self) -> Panel:
91
+ """
92
+ Construct the header from the info-text attribute.
93
+ """
94
+ figlet_text = Figlet(font="big").renderText("PyICs").strip("\n")
95
+
96
+ table = Table(
97
+ expand=True, box=box.MINIMAL, show_header=False, style="blue", padding=(0, 2)
98
+ )
99
+ table.add_column(
100
+ vertical="middle",
101
+ min_width=(len_figlet := len(figlet_text.split("\n")[0])),
102
+ max_width=len_figlet,
103
+ )
104
+ table.add_column(vertical="middle", ratio=1)
105
+ table.add_row(
106
+ Text(figlet_text, style="blue", no_wrap=True),
107
+ Text.from_markup(self.info_text, style="white", emoji=False),
108
+ )
109
+
110
+ return Panel(
111
+ table,
112
+ title=f"[blue]PyICs[white] {importlib.metadata.version('pyics')}",
113
+ title_align="left",
114
+ border_style="blue",
115
+ box=box.ROUNDED,
116
+ padding=(0, 0),
117
+ )
118
+
119
+ def generate_footer(self) -> Panel:
120
+ """
121
+ Construct the footer from the hotkey and progress bar attributes.
122
+ """
123
+ # wrapped between hotkeys only, so a hotkey isn't separated from its action
124
+ width = self.console.width - 4 # the panel's borders and padding
125
+ lines: list[Text] = []
126
+ for key, action in self.hotkeys.items():
127
+ hotkey = Text.assemble((f"<{key}>", "bold cyan"), f" {action}")
128
+ if lines and lines[-1].cell_len + 3 + hotkey.cell_len <= width:
129
+ lines[-1].append(" | ", style="dim")
130
+ lines[-1].append_text(hotkey)
131
+ else:
132
+ lines.append(hotkey)
133
+ hotkeys_text = Text("\n", justify="center").join(lines)
134
+
135
+ self.progress.update(self.task_id, completed=self.percentage)
136
+
137
+ group = Group(self.progress, Align.center(hotkeys_text))
138
+
139
+ return Panel(group, border_style="blue", box=box.ROUNDED)
140
+
141
+ def update_main_content(self, renderable: RenderableType, title: TextType = ""):
142
+ """
143
+ Update the content of the main panel.
144
+
145
+ Parameters
146
+ ----------
147
+ renderable: RenderableType
148
+ New content to put inside the main panel.
149
+ title: TextType (optional)
150
+ Set a frame title for the main content. If a title other than "" (default)
151
+ is provided, adds top padding to renderable.
152
+ """
153
+ self.main_content = Padding(renderable, (0 if title == "" else 1, 0, 0, 0))
154
+ self.main_title = title
155
+ self._follow_cursor = True
156
+ self._needs_redraw = True
157
+
158
+ def add_to_main_content(self, renderable: RenderableType) -> None:
159
+ """
160
+ _Add_ renderable below current main content.
161
+
162
+ **Important:** anything added to the main content this way will be overwritten
163
+ the next time a `ùpdate_main_content`` method call occurs.
164
+
165
+ Parameters
166
+ ----------
167
+ renderable: RenderableType
168
+ New content to _add_ to the main content.
169
+ """
170
+ self.main_content = Group(self.main_content, Padding(renderable, (1, 0, 0, 0)))
171
+ self._needs_redraw = True
172
+
173
+ def update_progress(self, percentage: float):
174
+ """
175
+ Update the progress displayed in the progress bar in the footer.
176
+
177
+ Parameters
178
+ ----------
179
+ percentage: float
180
+ New progress percentage (as a fraction!)
181
+ """
182
+ self.percentage = percentage
183
+ self._needs_redraw = True
184
+
185
+ def update_hotkeys(self, hotkeys: dict):
186
+ """
187
+ Update the hotkeys displayed in the footer. This intentionally _replaces_ the dict,
188
+ because that way it is easy for the controller to both remove and add or update
189
+ components.
190
+
191
+ Parameters
192
+ ----------
193
+ hotkeys: dict
194
+ Hotkey dict.
195
+ """
196
+ self.hotkeys = hotkeys
197
+ self._needs_redraw = True
198
+
199
+ def update_info(self, text: str):
200
+ """
201
+ Update the info_text. The info text is shown in the right part of the header panel.
202
+
203
+ Parameters
204
+ ----------
205
+ text: str
206
+ The new info-text to draw. Supports ``rich`` markup, so plain text that may
207
+ contain square brackets must be escaped (``rich.markup.escape``).
208
+ """
209
+ self.info_text = text
210
+ self._needs_redraw = True
211
+
212
+ def scroll(self, delta: int) -> None:
213
+ """
214
+ Move the main-content viewport by ``delta`` lines, clamped to the scrollable range.
215
+
216
+ Parameters
217
+ ----------
218
+ delta: int
219
+ Number of lines to scroll by. Positive scrolls down, negative scrolls up.
220
+ """
221
+ self.scroll_offset = max(
222
+ 0, min(self.scroll_offset + delta, self._max_scroll_offset)
223
+ )
224
+ self._needs_redraw = True
225
+
226
+ def _render_lines(self, renderable: RenderableType) -> list[list[Segment]]:
227
+ """Render a renderable to its lines, uncropped by the console's current height."""
228
+ options = self.console.options.update(height=None)
229
+ return self.console.render_lines(renderable, options, pad=False)
230
+
231
+ def _locate_and_strip_cursor(
232
+ self, lines: list[list[Segment]]
233
+ ) -> tuple[int, int] | None:
234
+ """Strip CURSOR_MARKER from the rendered ``lines`` (in place) and return its
235
+ 0-indexed ``(row, col)``, or ``None`` if absent."""
236
+ for row, line in enumerate(lines):
237
+ for i, seg in enumerate(line):
238
+ idx = seg.text.find(CURSOR_MARKER)
239
+ if idx == -1:
240
+ continue
241
+ col = cell_len("".join(s.text for s in line[:i])) + cell_len(seg.text[:idx])
242
+ line[i] = seg._replace(text=seg.text[:idx] + seg.text[idx + 1 :])
243
+ return row, col
244
+ return None
245
+
246
+ def _lines_to_text(self, lines: list[list[Segment]]) -> str:
247
+ """Join rendered lines back into a single ANSI-encoded string."""
248
+ segments: list[Segment] = []
249
+ for i, line in enumerate(lines):
250
+ if i:
251
+ segments.append(Segment.line())
252
+ segments.extend(line)
253
+ return self.console._render_buffer(segments)
254
+
255
+ def draw_interface(self):
256
+ """
257
+ Draw the PyICs interface. Header and footer are pinned to the top/bottom of the
258
+ terminal; only the main content scrolls, windowed to the space left between them.
259
+ """
260
+ # cleared before anything is read, so changes made while this frame is drawn
261
+ # (e.g. from other threads) are drawn by the next one
262
+ self._needs_redraw = False
263
+ follow_cursor, self._follow_cursor = self._follow_cursor, False
264
+ # likewise, a frame drawn while the terminal is resized is drawn again
265
+ self._drawn_size = self.console.size
266
+ header_panel = self.generate_header()
267
+ footer_panel = self.generate_footer()
268
+
269
+ main_panel = Panel(
270
+ self.main_content,
271
+ title=self.main_title, # should display task-id/task-number later
272
+ title_align="left",
273
+ box=box.SIMPLE,
274
+ )
275
+
276
+ term_height = self._drawn_size.height
277
+
278
+ header_lines = self._render_lines(header_panel)
279
+ footer_lines = self._render_lines(footer_panel)
280
+ viewport_height = max(0, term_height - len(header_lines) - len(footer_lines))
281
+
282
+ main_lines = self._render_lines(main_panel)
283
+ cursor_pos = self._locate_and_strip_cursor(main_lines)
284
+
285
+ self._max_scroll_offset = max(0, len(main_lines) - viewport_height)
286
+ if cursor_pos is not None and follow_cursor:
287
+ row, _ = cursor_pos
288
+ if row < self.scroll_offset:
289
+ # scroll all the way up if that shows the cursor, to keep the title visible
290
+ self.scroll_offset = 0 if row < viewport_height else row
291
+ elif row >= self.scroll_offset + viewport_height:
292
+ self.scroll_offset = row - viewport_height + 1
293
+ self.scroll_offset = max(0, min(self.scroll_offset, self._max_scroll_offset))
294
+ visible_main_lines = main_lines[
295
+ self.scroll_offset : self.scroll_offset + viewport_height
296
+ ]
297
+
298
+ sys.stdout.write("\033[H\033[2J")
299
+ sys.stdout.write(self._lines_to_text(header_lines))
300
+ sys.stdout.write("\n")
301
+ sys.stdout.write(self._lines_to_text(visible_main_lines))
302
+ sys.stdout.write(f"\033[{term_height - len(footer_lines) + 1};1H")
303
+ sys.stdout.write(self._lines_to_text(footer_lines))
304
+
305
+ if cursor_pos is not None and (
306
+ self.scroll_offset <= cursor_pos[0] < self.scroll_offset + viewport_height
307
+ ):
308
+ row, col = cursor_pos
309
+ abs_row = len(header_lines) + 1 + (row - self.scroll_offset)
310
+ sys.stdout.write(f"\033[{abs_row};{1 + col}H")
311
+ sys.stdout.write(SHOW_CURSOR if self.cursor_visible else HIDE_CURSOR)
312
+ else:
313
+ sys.stdout.write(HIDE_CURSOR)
314
+
315
+ sys.stdout.flush()
316
+
317
+ def run(self):
318
+ """
319
+ Main CLI loop. While running, checks if a redraw is necessary (either updated
320
+ content or window-resize), and redraws the interface when necessary.
321
+ """
322
+ sys.stdout.write(HIDE_CURSOR)
323
+ sys.stdout.flush()
324
+
325
+ try:
326
+ while self.running:
327
+ # the size is polled, as only Unix signals a resize
328
+ if self._needs_redraw or self.console.size != self._drawn_size:
329
+ self.draw_interface()
330
+
331
+ time.sleep(0.05)
332
+
333
+ finally:
334
+ sys.stdout.write(SHOW_CURSOR)
335
+ sys.stdout.flush()
@@ -0,0 +1,133 @@
1
+ import queue
2
+ import sys
3
+ import threading
4
+ import time
5
+ from collections.abc import Callable
6
+ from contextlib import ExitStack
7
+
8
+ from prompt_toolkit.input import Input, create_input
9
+ from prompt_toolkit.key_binding import KeyPress
10
+ from prompt_toolkit.keys import Keys
11
+
12
+ # Tokens of keys that don't type a character. Terminals also send the arrow keys (or the
13
+ # scroll keys) for mouse-wheel scrolling while in the alt-screen buffer (xterm "alternate
14
+ # scroll mode"), so this doubles as mouse-wheel support.
15
+ _KEY_TOKENS: dict[str, str] = {
16
+ Keys.Up: "UP",
17
+ Keys.Down: "DOWN",
18
+ Keys.Left: "LEFT",
19
+ Keys.Right: "RIGHT",
20
+ Keys.ScrollUp: "UP",
21
+ Keys.ScrollDown: "DOWN",
22
+ Keys.Escape: "\x1b",
23
+ }
24
+
25
+
26
+ def _terminal_input() -> Input:
27
+ """The input of the terminal that stdin is, for the platform (see
28
+ ``prompt_toolkit.input``). Raises if stdin isn't a terminal."""
29
+ if sys.stdin is None or not sys.stdin.isatty():
30
+ raise OSError("stdin is not a terminal")
31
+ return create_input()
32
+
33
+
34
+ def _tokens(key_press: KeyPress) -> list[str]:
35
+ """
36
+ The tokens ``key_press`` is queued as: one of ``_KEY_TOKENS``, or else the text the
37
+ terminal sent for it, e.g. ``"a"``, or the control character of enter or ctrl-c.
38
+ Other keys that don't type a character (e.g. delete) are sent as escape sequences,
39
+ which are queued as single tokens of several characters.
40
+ """
41
+ if key_press.key == Keys.BracketedPaste:
42
+ # e.g. pasted text, as Windows detects it: typed character by character
43
+ return list(key_press.data)
44
+ token = _KEY_TOKENS.get(key_press.key, key_press.data)
45
+ return [token] if token else []
46
+
47
+
48
+ class KeyListener(queue.Queue):
49
+ """
50
+ Queues the keys pressed while in this context as tokens (see ``_tokens``). The
51
+ terminal is in raw mode in the meantime: keys aren't echoed, and ctrl-c is a key
52
+ rather than a signal.
53
+ """
54
+
55
+ def __init__(self, *args, **kwargs) -> None:
56
+ super().__init__(*args, **kwargs)
57
+ self._raw_mode_ready = threading.Event()
58
+ self._stop_event = threading.Event()
59
+ # why keys can't be read, raised by __enter__
60
+ self._error: Exception | None = None
61
+ self.thread: threading.Thread | None = None
62
+
63
+ def _key_listener(self) -> None:
64
+ """
65
+ KeyListener main functionality.
66
+ """
67
+ with ExitStack() as stack:
68
+ try:
69
+ keyboard = _terminal_input()
70
+ stack.enter_context(keyboard.raw_mode())
71
+ except Exception as err: # noqa: BLE001 - raised by __enter__
72
+ self._error = err
73
+ return
74
+ finally:
75
+ # Unblock __enter__ only once raw mode is actually in effect (or setup
76
+ # failed), so nothing else can race the terminal into a stale mode.
77
+ self._raw_mode_ready.set()
78
+
79
+ while not self._stop_event.is_set():
80
+ key_presses = keyboard.read_keys()
81
+ if not key_presses:
82
+ time.sleep(0.01)
83
+ # a lone esc can't be told from the start of an escape sequence
84
+ # (e.g. an arrow key) until nothing follows it
85
+ key_presses = keyboard.read_keys() or keyboard.flush_keys()
86
+ for key_press in key_presses:
87
+ for token in _tokens(key_press):
88
+ self.put(token)
89
+
90
+ def stop(self) -> None:
91
+ """
92
+ Signal the reader thread to stop and block until it has actually exited (and cooked
93
+ terminal mode has been restored), so callers can rely on that being true the instant
94
+ this returns.
95
+ """
96
+ self._stop_event.set()
97
+ if self.thread is not None:
98
+ self.thread.join(timeout=1.0)
99
+
100
+ def __enter__(self):
101
+ self.thread = threading.Thread(target=self._key_listener, daemon=True)
102
+ self.thread.start()
103
+ self._raw_mode_ready.wait()
104
+ if self._error is not None:
105
+ raise self._error
106
+ return self
107
+
108
+ def __exit__(self, exc_type, exc, tb):
109
+ self.stop()
110
+
111
+ @staticmethod
112
+ def hey_listen(q_param: str) -> Callable:
113
+ """
114
+ KeyListener nested decorator. I.e., this static method decorates the decorator
115
+ (so that we can use the ``q_param`` parameter)
116
+
117
+ Parameters
118
+ ----------
119
+ q_param: str
120
+ The parameter name, under which the queue is passed to the decorated function.
121
+ """
122
+
123
+ def dec(func: Callable) -> Callable:
124
+ def mod(*args, **kwargs):
125
+ with KeyListener() as q:
126
+ func(*args, **kwargs, **{q_param: q})
127
+
128
+ return mod
129
+
130
+ return dec
131
+
132
+
133
+ hey_listen = KeyListener.hey_listen