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/__init__.py +8 -0
- pb_cli/__main__.py +7 -0
- pb_cli/app.py +127 -0
- pb_cli/banner.py +338 -0
- pb_cli/cli.py +123 -0
- pb_cli/commands/__init__.py +16 -0
- pb_cli/commands/hub.py +637 -0
- pb_cli/commands/program.py +374 -0
- pb_cli/commands/repl.py +146 -0
- pb_cli/commands/service.py +369 -0
- pb_cli/config.py +213 -0
- pb_cli/console/__init__.py +117 -0
- pb_cli/console/posix.py +125 -0
- pb_cli/console/windows.py +586 -0
- pb_cli/errors.py +680 -0
- pb_cli/escape.py +258 -0
- pb_cli/keys.py +332 -0
- pb_cli/picker.py +124 -0
- pb_cli/py.typed +0 -0
- pb_cli/session.py +813 -0
- pb_cli/ui.py +454 -0
- pybricks_cli-1.0.0b1.dist-info/METADATA +59 -0
- pybricks_cli-1.0.0b1.dist-info/RECORD +26 -0
- pybricks_cli-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_cli-1.0.0b1.dist-info/entry_points.txt +3 -0
- pybricks_cli-1.0.0b1.dist-info/licenses/LICENSE +21 -0
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
|