pybricks-cli 1.0.0b1__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.
pb_cli/escape.py ADDED
@@ -0,0 +1,258 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Telling keys meant for the hub from keys meant for ``pb``.
5
+
6
+ In a session with a hub every keystroke belongs to the hub, Ctrl-C included --
7
+ that is the point, since Ctrl-C is how a running program is interrupted. So
8
+ there has to be one key that does not, and after it a letter saying what to do.
9
+ This is the arrangement ``telnet``, ``screen``, ``picocom`` and ``microcom``
10
+ all use, and users of those tools already know the shape of it.
11
+
12
+ **Which key it is matters more here than in those tools**, because the thing on
13
+ the other end is MicroPython, and MicroPython's REPL claims an unusual number
14
+ of control characters. Its line editor uses Ctrl-A, B, D, E, F, K, N, P, U and
15
+ W, and the REPL itself gives Ctrl-A, B, C, D and E meanings of their own: raw
16
+ mode, friendly mode, interrupt, soft reset and paste mode. That rules out the
17
+ usual choices immediately -- ``screen`` and ``picocom`` escape with Ctrl-A,
18
+ which is raw REPL, and ``tmux`` with Ctrl-B, which is friendly REPL.
19
+
20
+ ``Ctrl-]`` is what is left, and it is the one with the best claim anyway: it is
21
+ telnet's, so it is the most widely recognised of them, and MicroPython does not
22
+ use it for anything. Checked rather than assumed -- neither ``readline.c`` nor
23
+ ``pyexec.c`` mentions ``0x1d`` at all. Sending the escape key to the hub is
24
+ still possible, by pressing it twice, which is also telnet's convention.
25
+
26
+ **Stopping has a second way in, because it is the one done in a hurry.**
27
+ ``Ctrl-] s`` is a modifier, a bracket and a remembered letter at the moment
28
+ somebody wants their robot to stop, so :data:`STOP_KEY` -- Escape, pressed
29
+ twice -- does the same thing. It is one key, it is on every keyboard, and a
30
+ hand finds it without looking.
31
+
32
+ Escape is also the first byte of every arrow key, and the hub's line editor
33
+ needs those, so the two have to be told apart. **Two Escapes in a row are
34
+ unambiguous in the byte stream**: a terminal's escape sequences are always
35
+ Escape followed by an introducer -- ``[``, ``O`` and the like -- so no sequence
36
+ can contain two Escapes running, and no timing window is needed to know which
37
+ this is. That is the whole reason it is two presses rather than one; one press
38
+ could only be told from an arrow key by how long nothing followed it, and a
39
+ window is a thing that is wrong occasionally rather than never.
40
+
41
+ The cost is that a lone Escape is held back until the next byte arrives, and
42
+ that is imperceptible: the hub gets both together, and its line editor was
43
+ waiting for the second byte anyway. What is left is Alt-Escape and the habit
44
+ of pressing Escape twice in ``vi``, neither of which means anything at a
45
+ MicroPython prompt, and both of which land on the safe side -- for a robot, an
46
+ unasked-for stop is the better way to be wrong.
47
+
48
+ The matching is a byte at a time and carries its state in the value it hands
49
+ back, because both pairs can easily arrive in separate reads: on Windows the
50
+ console hands over one key at a time, so a user typing at any ordinary speed
51
+ splits them almost always. Assuming a whole sequence arrives in one read is the
52
+ single most common bug in code like this, and a state machine of three states
53
+ -- nothing pending, a command awaited, an Escape held -- is both the fix and
54
+ something that can be tested exhaustively without a terminal anywhere near it.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from collections.abc import Mapping
60
+ from dataclasses import dataclass, replace
61
+ from enum import Enum
62
+ from typing import Final
63
+
64
+ __all__ = ["ESCAPE_KEY", "STOP_KEY", "Action", "Filter", "describe", "help_lines"]
65
+
66
+ ESCAPE_KEY: Final = 0x1D
67
+ """Ctrl-], the byte that means the next key is for ``pb``.
68
+
69
+ Telnet's, and free: MicroPython gives it no meaning, so nothing is taken away
70
+ from the hub by reserving it.
71
+ """
72
+
73
+ STOP_KEY: Final = 0x1B
74
+ """Escape, which stops what is running on the hub when pressed twice.
75
+
76
+ Not taken from the hub, because one press still reaches it -- held only until
77
+ the key after it says whether it began a sequence. See the module docstring for
78
+ why two presses can be recognised with no timing at all.
79
+ """
80
+
81
+ _NAMES: Final[Mapping[int, str]] = {STOP_KEY: "Esc"}
82
+ """Keys with a name of their own, rather than the Ctrl-something they encode.
83
+
84
+ Escape is Ctrl-[ as far as the byte is concerned, and nobody has called it that
85
+ since terminals had a key marked Meta.
86
+ """
87
+
88
+
89
+ class Action(Enum):
90
+ """What an escape command asks for."""
91
+
92
+ QUIT = "quit"
93
+ STOP = "stop"
94
+ HELP = "help"
95
+ UNKNOWN = "unknown"
96
+ """A key that follows the escape and means nothing. Reported rather than
97
+ ignored, so the user is shown the help instead of wondering."""
98
+
99
+
100
+ COMMANDS: Final[Mapping[int, Action]] = {
101
+ ord("q"): Action.QUIT,
102
+ ord("Q"): Action.QUIT,
103
+ 0x04: Action.QUIT,
104
+ ord("s"): Action.STOP,
105
+ ord("S"): Action.STOP,
106
+ ord("h"): Action.HELP,
107
+ ord("H"): Action.HELP,
108
+ ord("?"): Action.HELP,
109
+ }
110
+ """What each key after the escape does.
111
+
112
+ Ctrl-D is here as a second way to quit because it is what fingers do at a
113
+ prompt when they mean "I am finished", and on its own it belongs to the hub
114
+ where it is a soft reset.
115
+
116
+ Help is advertised as ``h`` and answers to ``?`` as well. Both are worth
117
+ having: ``?`` is what the other escape-key tools use, and ``h`` is the one a
118
+ hand can reach without a shift key.
119
+
120
+ There is deliberately no "restart the interpreter". A session that finds a
121
+ program running starts the interpreter by itself as soon as that program ends,
122
+ and a session whose interpreter stops is over, so nothing is left for a restart
123
+ command to do that the session does not already do on its own.
124
+ """
125
+
126
+
127
+ def help_lines(
128
+ key: int = ESCAPE_KEY, stop_key: int = STOP_KEY
129
+ ) -> list[tuple[str, str]]:
130
+ """List the escape commands, as a user would have to type them.
131
+
132
+ Args:
133
+ key: The escape byte in use.
134
+ stop_key: The byte that stops the hub when pressed twice.
135
+
136
+ Returns:
137
+ Each command's keystrokes and what it does, in the order to show them.
138
+ Stopping comes first, because it is the one somebody is reading this
139
+ list in a hurry to find.
140
+ """
141
+ name = describe(key)
142
+ stop = describe(stop_key)
143
+ return [
144
+ (f"{stop} {stop}", "stop what is running, ending the session"),
145
+ (f"{name} q", "leave the session"),
146
+ (f"{name} s", f"the same as {stop} {stop}"),
147
+ (f"{name} h", "show this help"),
148
+ (f"{name} {name}", f"send {name} to the hub"),
149
+ ]
150
+
151
+
152
+ def describe(key: int = ESCAPE_KEY) -> str:
153
+ """Name a control byte the way a person would say it.
154
+
155
+ Args:
156
+ key: The byte.
157
+
158
+ Returns:
159
+ Something like ``Ctrl-]``, or the key's own name where it has one.
160
+ """
161
+ named = _NAMES.get(key)
162
+ if named is not None:
163
+ return named
164
+
165
+ if 0 < key < 0x20:
166
+ return f"Ctrl-{chr(key + 0x40)}"
167
+
168
+ return repr(chr(key))
169
+
170
+
171
+ @dataclass(frozen=True, slots=True)
172
+ class Filter:
173
+ """Splits what was typed into bytes for the hub and commands for ``pb``.
174
+
175
+ A value rather than something a session edits: :meth:`feed` returns the
176
+ filter to read the *next* keys with, so what a half-finished escape needs
177
+ is carried in the value rather than accumulated in an object. That is worth
178
+ the extra name at the call site, because a read ending between the escape
179
+ key and its command is the ordinary case and not an edge one -- a console
180
+ hands over one key at a time, so the two halves nearly always arrive in
181
+ separate reads -- and a value makes each step of that a thing a test can
182
+ state on one line.
183
+ """
184
+
185
+ key: int = ESCAPE_KEY
186
+ """The byte that starts a command."""
187
+
188
+ stop_key: int = STOP_KEY
189
+ """The byte that stops the hub when it arrives twice running."""
190
+
191
+ armed: bool = False
192
+ """Whether the escape key has been seen and its command is awaited."""
193
+
194
+ held: bool = False
195
+ """Whether a :attr:`stop_key` is being held back to see what follows it.
196
+
197
+ One press belongs to the hub and two are the stop, and which it was is
198
+ known as soon as the next byte arrives -- so the first one waits rather
199
+ than being guessed at. Nothing is lost by waiting: the hub's line editor
200
+ reads an escape as the start of a sequence and is waiting for that same
201
+ byte itself.
202
+ """
203
+
204
+ def feed(self, data: bytes) -> tuple[Filter, bytes, tuple[Action, ...]]:
205
+ """Sort one read from the terminal.
206
+
207
+ Args:
208
+ data: The bytes just typed.
209
+
210
+ Returns:
211
+ The filter to read the next keys with, what to send to the hub,
212
+ and the commands that were asked for, in the order they were
213
+ typed.
214
+ """
215
+ armed = self.armed
216
+ held = self.held
217
+ for_hub = bytearray()
218
+ actions: list[Action] = []
219
+
220
+ for byte in data:
221
+ if armed:
222
+ armed = False
223
+
224
+ if byte == self.key:
225
+ # Pressed twice, so it was meant for the hub after all.
226
+ for_hub.append(byte)
227
+ else:
228
+ actions.append(COMMANDS.get(byte, Action.UNKNOWN))
229
+
230
+ continue
231
+
232
+ if held:
233
+ held = False
234
+
235
+ if byte == self.stop_key:
236
+ actions.append(Action.STOP)
237
+
238
+ continue
239
+
240
+ # Whatever this is, it is not a second press, so the one being
241
+ # held belonged to the hub -- and this byte is then read as if
242
+ # it had never been held at all, since it may perfectly well be
243
+ # the escape key starting a command of its own.
244
+ for_hub.append(self.stop_key)
245
+
246
+ if byte == self.key:
247
+ armed = True
248
+ elif byte == self.stop_key:
249
+ held = True
250
+ else:
251
+ for_hub.append(byte)
252
+
253
+ # Unchanged is the common case, and a value that did not change has no
254
+ # reason to be a different object.
255
+ if armed == self.armed and held == self.held:
256
+ return self, bytes(for_hub), tuple(actions)
257
+
258
+ return replace(self, armed=armed, held=held), bytes(for_hub), tuple(actions)
pb_cli/keys.py ADDED
@@ -0,0 +1,332 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Reading single keystrokes, for the few places that ask a person to choose.
5
+
6
+ Deliberately small, and not to be confused with :mod:`pb_cli.console`, which is
7
+ what a session with a hub reads its keyboard through: that one forwards every
8
+ byte a key produces, in raw mode, and getting it right is most of a project.
9
+ This one only has to tell up from down from Enter, which is enough for a
10
+ chooser.
11
+
12
+ Three properties make it much safer than it looks:
13
+
14
+ **Nothing blocks.** :func:`read_ready` returns immediately whether or not a key
15
+ is waiting, so the caller stays in its own event loop. There is no reader thread
16
+ to abandon and no uninterruptible read to cancel, which is where the difficulty
17
+ in this area really lives.
18
+
19
+ **Waiting is a separate call, and is real waiting where the platform allows
20
+ it.** :func:`wait_for_key` returns once there may be something to read. On
21
+ POSIX that is a wait on the terminal's own file descriptor, so a keypress is
22
+ acted on in microseconds and nothing is woken while nobody is typing. Windows
23
+ cannot do the same: a console handle is signalled by records that carry no
24
+ character, and :mod:`msvcrt` neither reports those nor removes them, so a wait
25
+ on the handle would return at once and go on returning. Seeing them means
26
+ reading them with the console API, which is :mod:`pb_cli.console`'s job and
27
+ costs a change of console mode -- and that would take Ctrl-C away from the
28
+ operating system, which is the one thing here that must keep working. So
29
+ Windows waits on a short timer instead.
30
+
31
+ **Windows needs no mode change at all.** :mod:`msvcrt` reads the console input
32
+ buffer directly, so there is no console state to save and none to restore, and
33
+ therefore no way to leave the terminal wedged. Only POSIX needs cbreak mode,
34
+ and it is entered and left by a *synchronous* context manager, whose
35
+ ``__exit__`` cannot be cancelled -- which is stronger than shielding.
36
+
37
+ Only the reading and the waiting are per platform. Deciding what a keystroke
38
+ means is shared,
39
+ and understands both grammars at once: Windows reports an arrow as a prefix
40
+ character followed by a scan code, while a terminal reports it as an escape
41
+ sequence. Neither can be mistaken for the other, so one function handles both
42
+ and can be tested on either platform.
43
+
44
+ Ctrl-C is left to the operating system rather than read as a byte. It arrives
45
+ as :class:`KeyboardInterrupt`, so the way out of a chooser works even if
46
+ everything else here is wrong.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import contextlib
52
+ import sys
53
+ from collections.abc import Generator
54
+ from enum import Enum
55
+ from typing import Final
56
+
57
+ import anyio
58
+
59
+ __all__ = [
60
+ "Key",
61
+ "keys_available",
62
+ "poll_key",
63
+ "read_ready",
64
+ "read_second",
65
+ "reading_keys",
66
+ "wait_for_key",
67
+ ]
68
+
69
+
70
+ class Key(Enum):
71
+ """The handful of keys a chooser cares about."""
72
+
73
+ UP = "up"
74
+ DOWN = "down"
75
+ ENTER = "enter"
76
+ CANCEL = "cancel"
77
+ """Escape or ``q``. Ctrl-C arrives as ``KeyboardInterrupt`` instead."""
78
+
79
+ OTHER = "other"
80
+ """Anything else, reported so a caller can ignore it deliberately."""
81
+
82
+
83
+ _WINDOWS_PREFIXES = ("\x00", "\xe0")
84
+ """What the console sends before the scan code of a special key."""
85
+
86
+ _WINDOWS_SCAN_CODES = {"H": Key.UP, "P": Key.DOWN}
87
+
88
+ _CSI_FINAL_BYTES = {"A": Key.UP, "B": Key.DOWN}
89
+ """The last character of a terminal's escape sequence for the arrows."""
90
+
91
+
92
+ def keys_available() -> bool:
93
+ """Whether single keystrokes can be read at all.
94
+
95
+ Returns:
96
+ True when standard input is a terminal this can read from. False for a
97
+ pipe, a file, or a shell that emulates a terminal over pipes -- Git
98
+ Bash's ``mintty`` being the one on hand, where a native Windows Python
99
+ sees a pipe and no console. A caller must check this and say something
100
+ useful rather than waiting for a keystroke that cannot arrive.
101
+ """
102
+
103
+ try:
104
+ if not sys.stdin.isatty():
105
+ return False
106
+ except (AttributeError, ValueError): # pragma: no cover - odd embeddings
107
+ # A closed or replaced stdin, which is not a terminal either.
108
+ return False
109
+
110
+ return _is_console()
111
+
112
+
113
+ def poll_key() -> Key | None:
114
+ """Read one keystroke if one is waiting.
115
+
116
+ Returns:
117
+ What was pressed, or ``None`` if nothing has been typed. Never blocks,
118
+ so a caller can redraw a display between calls.
119
+ """
120
+ char = read_ready()
121
+ if char is None:
122
+ return None
123
+
124
+ if not char:
125
+ # The terminal has gone away, so there is nothing left to choose
126
+ # with, and leaving is the same answer as Escape. Saying so matters
127
+ # more than it looks: a descriptor at end of file stays readable for
128
+ # ever, so anything else here would spin rather than wait.
129
+ return Key.CANCEL
130
+
131
+ if char in _WINDOWS_PREFIXES:
132
+ # A Windows special key, which arrives as a prefix and then its
133
+ # scan code. read_second rather than read_ready: the two halves
134
+ # are not both in the console buffer, so asking whether anything
135
+ # is waiting gets the wrong answer. See read_second.
136
+ return _WINDOWS_SCAN_CODES.get(read_second() or "", Key.OTHER)
137
+
138
+ if char == "\x1b":
139
+ # Escape either stands alone or opens a sequence such as "\x1b[A" for
140
+ # the up arrow. Telling those apart is the classic trap here, and
141
+ # polling answers it directly: if nothing more is waiting this
142
+ # instant, the key really was Escape.
143
+ if read_ready() != "[":
144
+ return Key.CANCEL
145
+
146
+ return _CSI_FINAL_BYTES.get(read_ready() or "", Key.OTHER)
147
+
148
+ if char in ("\r", "\n"):
149
+ return Key.ENTER
150
+
151
+ if char in ("q", "Q"):
152
+ return Key.CANCEL
153
+
154
+ return Key.OTHER
155
+
156
+
157
+ if sys.platform == "win32":
158
+ POLL_SECONDS: Final = 0.05
159
+ """How long to wait before looking for a keystroke again.
160
+
161
+ Windows only, and standing in for a wait that cannot be had here -- see
162
+ the module docstring. Fast enough that a keypress feels immediate and slow
163
+ enough to be free.
164
+ """
165
+
166
+ async def wait_for_key() -> None:
167
+ """Wait a short while, since the console cannot be waited on.
168
+
169
+ A poll, and the one the repository keeps deliberately. It is bounded
170
+ by how long somebody spends choosing a hub, and the alternative costs
171
+ a change of console mode that would take Ctrl-C away from the
172
+ operating system.
173
+ """
174
+ await anyio.sleep(POLL_SECONDS)
175
+
176
+ @contextlib.contextmanager
177
+ def reading_keys() -> Generator[None]:
178
+ """Do nothing, because Windows needs nothing done.
179
+
180
+ Yields:
181
+ Nothing. ``msvcrt`` reads the console buffer without changing the
182
+ console mode, so there is no state to restore.
183
+ """
184
+ yield
185
+
186
+ def read_ready() -> str | None:
187
+ """Read one character if the console has one waiting.
188
+
189
+ Returns:
190
+ The character, or ``None`` if nothing has been typed.
191
+ """
192
+ import msvcrt
193
+
194
+ return msvcrt.getwch() if msvcrt.kbhit() else None
195
+
196
+ def read_second() -> str | None:
197
+ """Read the second half of a special key, which is already held.
198
+
199
+ A special key reaches a program as two characters: a prefix,
200
+ and then the key's scan code. The runtime hands over the first
201
+ and keeps the second, and -- measured, by writing a down arrow
202
+ into a real console with ``WriteConsoleInputW`` -- ``kbhit``
203
+ then says nothing is waiting, because nothing is: the second
204
+ half is in the runtime rather than in the console's buffer.
205
+
206
+ Guarding this read with ``kbhit`` therefore loses every arrow
207
+ key, and strands its scan code to be read back later as a
208
+ keystroke of its own -- so the next key pressed is misread as
209
+ well.
210
+
211
+ Returns:
212
+ The second character. The read is unguarded, which is safe
213
+ precisely because a prefix means the runtime is holding it:
214
+ it returned in under a millisecond when measured.
215
+ """
216
+ import msvcrt
217
+
218
+ return msvcrt.getwch()
219
+
220
+ def _is_console() -> bool:
221
+ """Whether standard input really is a console.
222
+
223
+ ``isatty`` is not enough here, and the difference is a hang rather
224
+ than a wrong answer. Windows calls every character device a terminal,
225
+ so redirecting from ``NUL`` reports a terminal, and then nothing is
226
+ ever typed at it. Asking the console API settles it: the call fails
227
+ for anything that is not a console.
228
+
229
+ Returns:
230
+ True if keystrokes can arrive.
231
+ """
232
+ import ctypes
233
+
234
+ # ctypes rather than pywin32 throughout: importing pythoncom
235
+ # initialises COM as an apartment and makes bleak hang.
236
+ stdin_handle = -10 # STD_INPUT_HANDLE
237
+ handle = ctypes.windll.kernel32.GetStdHandle(stdin_handle)
238
+ mode = ctypes.c_uint()
239
+ return bool(ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode)))
240
+
241
+ else:
242
+
243
+ async def wait_for_key() -> None:
244
+ """Wait until the terminal has something to read.
245
+
246
+ Nothing is woken while nobody is typing, and a keypress is acted on
247
+ as it arrives rather than at the next turn of a timer.
248
+ """
249
+ await anyio.wait_readable(sys.stdin.fileno())
250
+
251
+ @contextlib.contextmanager
252
+ def reading_keys() -> Generator[None]:
253
+ """Put the terminal in cbreak mode for the duration of the block.
254
+
255
+ Cbreak rather than raw, so that the kernel still turns Ctrl-C into a
256
+ signal. That way the way out of a chooser does not depend on this
257
+ module reading and interpreting a byte correctly.
258
+
259
+ Yields:
260
+ Nothing. The previous settings are restored on the way out, by a
261
+ synchronous ``__exit__`` that cannot be cancelled.
262
+ """
263
+ import termios
264
+ import tty
265
+
266
+ fd = sys.stdin.fileno()
267
+ saved = termios.tcgetattr(fd)
268
+
269
+ try:
270
+ tty.setcbreak(fd)
271
+ yield
272
+ finally:
273
+ # TCSADRAIN so that anything already written is not cut off.
274
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
275
+
276
+ def read_second() -> str | None:
277
+ """Read the next character of a sequence, if one is waiting.
278
+
279
+ The counterpart of the Windows version, and here it is simply
280
+ another read: every byte of an escape sequence is in the
281
+ terminal's own buffer, so asking whether one is waiting is the
282
+ right question.
283
+
284
+ Returns:
285
+ The character, or ``None`` if nothing more has arrived.
286
+ """
287
+ return read_ready()
288
+
289
+ def read_ready() -> str | None:
290
+ """Read one character if the terminal has one waiting.
291
+
292
+ The read is from the file descriptor rather than through
293
+ :data:`sys.stdin`, and that is the whole correctness of this module
294
+ on POSIX. ``sys.stdin`` is a text wrapper with a buffer of its own,
295
+ and a terminal sends an arrow key's three bytes in one go -- so
296
+ reading the escape through it drew all three into that buffer,
297
+ ``select`` on the descriptor then truthfully reported nothing waiting,
298
+ and :func:`poll_key` concluded the Escape had stood alone. Every arrow
299
+ key was read as leave, and its remaining two bytes as two more
300
+ keystrokes. Measured against a real pseudo-terminal, which is the only
301
+ way it could have been found: down, up and Enter came back as
302
+ ``CANCEL``, ``OTHER``, ``OTHER``. What is asked about and what is read
303
+ from have to be the same thing.
304
+
305
+ Returns:
306
+ The character, or ``None`` if nothing has been typed, or the empty
307
+ string if the terminal has gone away.
308
+ """
309
+ import os
310
+ import select
311
+
312
+ fd = sys.stdin.fileno()
313
+ readable, _, _ = select.select([fd], [], [], 0)
314
+
315
+ if not readable:
316
+ return None
317
+
318
+ # One byte at a time, as Latin-1 so that nothing here can raise
319
+ # part-way through a character. A key that produces several bytes is
320
+ # not one this module knows, and comes out as OTHER either way.
321
+ return os.read(fd, 1).decode("latin-1")
322
+
323
+ def _is_console() -> bool:
324
+ """Whether standard input really is a terminal.
325
+
326
+ On POSIX ``isatty`` means what it says, so there is nothing more to
327
+ ask. The Windows implementation has to work harder.
328
+
329
+ Returns:
330
+ True, the caller having already checked ``isatty``.
331
+ """
332
+ return True