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/console/posix.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""The POSIX terminal, in raw mode.
|
|
5
|
+
|
|
6
|
+
Waiting is the event loop's own job here: a terminal is a file descriptor, so
|
|
7
|
+
``anyio.wait_readable`` registers it with the selector and the task is woken
|
|
8
|
+
when a key is pressed. No thread, no timer, and cancellation lands where the
|
|
9
|
+
task is waiting rather than being deferred to the end of some interval.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Generator
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import anyio
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pb_cli.console import Console
|
|
25
|
+
|
|
26
|
+
__all__ = ["platform_problem", "raw_console"]
|
|
27
|
+
|
|
28
|
+
READ_LIMIT = 4096
|
|
29
|
+
"""Bytes to take from the terminal in one go.
|
|
30
|
+
|
|
31
|
+
Well past what typing produces and enough that pasting a block of code arrives
|
|
32
|
+
in a couple of reads rather than a hundred.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def platform_problem() -> str | None:
|
|
37
|
+
"""Whether a terminal can be put into raw mode.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
None. On POSIX ``isatty`` means what it says, and the caller has
|
|
41
|
+
already asked it.
|
|
42
|
+
"""
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _PosixConsole:
|
|
47
|
+
"""A POSIX terminal, in raw mode."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, in_fd: int, out_fd: int) -> None:
|
|
50
|
+
"""Hold the descriptors raw mode was set on.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
in_fd: The terminal to read from.
|
|
54
|
+
out_fd: The terminal to write to.
|
|
55
|
+
"""
|
|
56
|
+
self._in = in_fd
|
|
57
|
+
self._out = out_fd
|
|
58
|
+
|
|
59
|
+
async def wait(self) -> None:
|
|
60
|
+
"""Wait until the terminal has input."""
|
|
61
|
+
await anyio.wait_readable(self._in)
|
|
62
|
+
|
|
63
|
+
def read(self) -> bytes:
|
|
64
|
+
"""Take whatever has been typed.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The bytes typed, or empty.
|
|
68
|
+
"""
|
|
69
|
+
import select
|
|
70
|
+
|
|
71
|
+
readable, _, _ = select.select([self._in], [], [], 0)
|
|
72
|
+
if not readable:
|
|
73
|
+
return b""
|
|
74
|
+
|
|
75
|
+
return os.read(self._in, READ_LIMIT)
|
|
76
|
+
|
|
77
|
+
def write(self, data: bytes) -> None:
|
|
78
|
+
"""Write bytes to the terminal, byte for byte.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
data: What to write.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
while data:
|
|
85
|
+
data = data[os.write(self._out, data) :]
|
|
86
|
+
|
|
87
|
+
def size(self) -> tuple[int, int]:
|
|
88
|
+
"""Return the terminal's size.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Columns and rows.
|
|
92
|
+
"""
|
|
93
|
+
columns, lines = shutil.get_terminal_size()
|
|
94
|
+
|
|
95
|
+
return columns, lines
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextlib.contextmanager
|
|
99
|
+
def raw_console() -> Generator[Console]:
|
|
100
|
+
"""Put the terminal into raw mode for the duration of the block.
|
|
101
|
+
|
|
102
|
+
Raw rather than cbreak, unlike :mod:`pb_cli.keys`: the interpreter on the
|
|
103
|
+
hub needs Ctrl-C as a byte, so the kernel must not turn it into a signal.
|
|
104
|
+
Output processing goes too, which is why everything written while this is
|
|
105
|
+
held ends its lines with a carriage return of its own.
|
|
106
|
+
|
|
107
|
+
Yields:
|
|
108
|
+
The terminal.
|
|
109
|
+
"""
|
|
110
|
+
import termios
|
|
111
|
+
import tty
|
|
112
|
+
|
|
113
|
+
in_fd = sys.stdin.fileno()
|
|
114
|
+
out_fd = sys.stdout.fileno()
|
|
115
|
+
saved = termios.tcgetattr(in_fd)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
tty.setraw(in_fd)
|
|
119
|
+
|
|
120
|
+
yield _PosixConsole(in_fd, out_fd)
|
|
121
|
+
|
|
122
|
+
finally:
|
|
123
|
+
# TCSADRAIN so that anything already written is not cut off. A
|
|
124
|
+
# synchronous exit, so no cancellation can skip it.
|
|
125
|
+
termios.tcsetattr(in_fd, termios.TCSADRAIN, saved)
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""The Windows console, in raw mode.
|
|
5
|
+
|
|
6
|
+
Waiting is the hard half on this platform, and the shape here is the one
|
|
7
|
+
prompt_toolkit arrived at independently: **wait in a thread, read on the loop.**
|
|
8
|
+
|
|
9
|
+
No event loop will wait on a console handle. A console is not a socket, so
|
|
10
|
+
neither ``anyio.wait_readable`` nor the proactor loop's public surface will take
|
|
11
|
+
one, and the proactor cannot be swapped for a selector loop because the
|
|
12
|
+
service's named pipes need it. So a thread does the waiting, in
|
|
13
|
+
``WaitForMultipleObjects`` over the console handle and an event of ours -- and
|
|
14
|
+
setting that event releases it in microseconds, which is what keeps the thread
|
|
15
|
+
inside the task tree rather than abandoned outside it.
|
|
16
|
+
|
|
17
|
+
**The thread waits; it never reads.** That distinction is the whole design.
|
|
18
|
+
A thread parked in ``ReadConsoleInputW`` cannot be released without breaking the
|
|
19
|
+
console: measured on Windows 11, ``CancelSynchronousIo`` and ``CancelIoEx`` both
|
|
20
|
+
abort the read, and afterwards *every* read on the console fails to return for
|
|
21
|
+
the life of the process -- reopening ``CONIN$`` does not recover it. An
|
|
22
|
+
abandoned reader is no better, because it goes on eating keystrokes after cooked
|
|
23
|
+
mode is restored, so the next command typed at the shell loses its first
|
|
24
|
+
characters. A thread parked in a wait has neither problem: it consumes nothing
|
|
25
|
+
and it lets go the instant it is asked.
|
|
26
|
+
|
|
27
|
+
**Records that produce no characters are drained rather than ignored.** A
|
|
28
|
+
console reports more than typing -- the window gaining or losing focus is a
|
|
29
|
+
record like any other -- and every record signals the handle whether or not it
|
|
30
|
+
carries a character. So the read takes every record waiting and keeps only the
|
|
31
|
+
characters. That is not tidiness: a record left in the buffer keeps the handle
|
|
32
|
+
signalled, so every wait after it returns at once and the session spins on a
|
|
33
|
+
full core. Measured: with a focus event waiting, a zero-length wait on the
|
|
34
|
+
handle reports it signalled; with the buffer drained, it does not.
|
|
35
|
+
|
|
36
|
+
``msvcrt`` cannot do the draining, which is why this reads records itself.
|
|
37
|
+
``kbhit`` reports a focus event as nothing waiting -- true, there is no
|
|
38
|
+
character -- and leaves it exactly where it was. Measured: one focus event
|
|
39
|
+
written, ``kbhit`` false, and one record still in the buffer afterwards.
|
|
40
|
+
|
|
41
|
+
Keys going back up are *not* part of this, which is worth saying because they
|
|
42
|
+
look like the obvious culprit and are the first thing tried. With
|
|
43
|
+
``ENABLE_VIRTUAL_TERMINAL_INPUT`` set the console does not queue them at all:
|
|
44
|
+
written into the buffer, a key-up record is discarded before anything can read
|
|
45
|
+
it. Measured, after assuming otherwise.
|
|
46
|
+
|
|
47
|
+
What is *not* needed here, because ``ENABLE_VIRTUAL_TERMINAL_INPUT`` is set, is
|
|
48
|
+
the translation from virtual key codes to escape sequences that other tools
|
|
49
|
+
carry a few hundred lines for. The console has already done it, so a record's
|
|
50
|
+
character is a byte of the sequence a terminal would have sent.
|
|
51
|
+
|
|
52
|
+
Every kernel32 entry below is a binding of our own, built from a prototype and
|
|
53
|
+
its paramflags, so that a Win32 call reads as a Python one: what the function
|
|
54
|
+
fills in comes back as the return value, the arguments that never vary are
|
|
55
|
+
defaults, and a failure is an ``OSError`` rather than a result to remember to
|
|
56
|
+
test. Nothing here assigns to the function objects ctypes hands out, which are
|
|
57
|
+
shared with every other caller in the process.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import contextlib
|
|
63
|
+
import ctypes
|
|
64
|
+
import ctypes.wintypes as wintypes
|
|
65
|
+
import shutil
|
|
66
|
+
from collections.abc import Generator
|
|
67
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
68
|
+
|
|
69
|
+
import anyio
|
|
70
|
+
import anyio.to_thread
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from pb_cli.console import Console
|
|
74
|
+
|
|
75
|
+
__all__ = ["platform_problem", "raw_console"]
|
|
76
|
+
|
|
77
|
+
READ_LIMIT: Final = 4096
|
|
78
|
+
"""Characters to take from the console in one go.
|
|
79
|
+
|
|
80
|
+
Well past what typing produces and enough that pasting a block of code arrives
|
|
81
|
+
in a couple of reads rather than a hundred.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
_GENERIC_READ = 0x80000000
|
|
85
|
+
_GENERIC_WRITE = 0x40000000
|
|
86
|
+
_FILE_SHARE_READ = 0x00000001
|
|
87
|
+
_FILE_SHARE_WRITE = 0x00000002
|
|
88
|
+
_OPEN_EXISTING = 3
|
|
89
|
+
_INVALID_HANDLE = ctypes.c_void_p(-1).value
|
|
90
|
+
|
|
91
|
+
_STD_INPUT = -10
|
|
92
|
+
_STD_OUTPUT = -11
|
|
93
|
+
|
|
94
|
+
_ENABLE_PROCESSED_INPUT = 0x0001
|
|
95
|
+
_ENABLE_EXTENDED_FLAGS = 0x0080
|
|
96
|
+
_ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
|
|
97
|
+
|
|
98
|
+
_ENABLE_PROCESSED_OUTPUT = 0x0001
|
|
99
|
+
_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
100
|
+
_ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
|
|
101
|
+
_DISABLE_NEWLINE_AUTO_RETURN = 0x0008
|
|
102
|
+
|
|
103
|
+
_CP_UTF8 = 65001
|
|
104
|
+
_INFINITE = 0xFFFFFFFF
|
|
105
|
+
_KEY_EVENT = 0x0001
|
|
106
|
+
|
|
107
|
+
_RAW_INPUT_MODE = _ENABLE_VIRTUAL_TERMINAL_INPUT | _ENABLE_EXTENDED_FLAGS
|
|
108
|
+
"""What the console input has to be for a session with a hub.
|
|
109
|
+
|
|
110
|
+
Everything else is cleared, and each omission matters. Without
|
|
111
|
+
``ENABLE_LINE_INPUT`` and ``ENABLE_ECHO_INPUT`` there is no line buffering and
|
|
112
|
+
no local echo, which is what lets the hub do its own echoing and line editing.
|
|
113
|
+
Without ``ENABLE_PROCESSED_INPUT`` Ctrl-C arrives as the byte ``0x03`` and
|
|
114
|
+
raises no console control event, which is the whole reason an interrupt can be
|
|
115
|
+
sent to the interpreter. ``ENABLE_MOUSE_INPUT`` and ``ENABLE_WINDOW_INPUT`` are
|
|
116
|
+
off because they queue records that produce no characters.
|
|
117
|
+
``ENABLE_QUICK_EDIT_MODE`` is off because a stray mouse drag would otherwise
|
|
118
|
+
freeze the session -- and clearing it only takes effect when
|
|
119
|
+
``ENABLE_EXTENDED_FLAGS`` is set in the same call, which is why that flag is
|
|
120
|
+
here.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Char(ctypes.Union):
|
|
125
|
+
_fields_ = (("UnicodeChar", wintypes.WCHAR), ("AsciiChar", ctypes.c_char))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _Key(ctypes.Structure):
|
|
129
|
+
_fields_ = (
|
|
130
|
+
("bKeyDown", wintypes.BOOL),
|
|
131
|
+
("wRepeatCount", wintypes.WORD),
|
|
132
|
+
("wVirtualKeyCode", wintypes.WORD),
|
|
133
|
+
("wVirtualScanCode", wintypes.WORD),
|
|
134
|
+
("uChar", _Char),
|
|
135
|
+
("dwControlKeyState", wintypes.DWORD),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Event(ctypes.Union):
|
|
140
|
+
_fields_ = (("KeyEvent", _Key),)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Record(ctypes.Structure):
|
|
144
|
+
"""One console input record.
|
|
145
|
+
|
|
146
|
+
The union carries only the key event, which is what makes this the right
|
|
147
|
+
width: every other event this console can produce is smaller, so a record
|
|
148
|
+
is as wide as the real one and the records after the first land where they
|
|
149
|
+
should. Getting that wrong reads as "only the first key arrived".
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
_fields_ = (("EventType", wintypes.WORD), ("Event", _Event))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ctypes rather than pywin32 throughout: importing pythoncom initialises COM as
|
|
156
|
+
# an apartment, which makes bleak hang.
|
|
157
|
+
_kernel32: Final = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
158
|
+
|
|
159
|
+
_IN: Final = 1
|
|
160
|
+
"""A parameter the caller supplies."""
|
|
161
|
+
|
|
162
|
+
_OUT: Final = 2
|
|
163
|
+
"""A parameter the function fills in.
|
|
164
|
+
|
|
165
|
+
ctypes allocates it, passes it by reference, and hands it back as the call's
|
|
166
|
+
return value, so the ``DWORD`` and ``byref`` dance stays out of the code that
|
|
167
|
+
does the work. The catch is that the function's own result is then discarded --
|
|
168
|
+
which for a Win32 ``BOOL`` is the only thing that says it failed -- so anything
|
|
169
|
+
with one of these needs an :func:`_returned_true` to put the failure back.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _returned_true(result: Any, _func: Any, args: Any) -> Any:
|
|
174
|
+
"""Raise the error the platform recorded, if the call reported failure.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
result: What the function returned.
|
|
178
|
+
_func: The function, which is not needed.
|
|
179
|
+
args: What it was called with, output parameters included.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
``args``, which is ctypes' signal to carry on and hand back the output
|
|
183
|
+
parameters as the call's return value.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
OSError: If the call failed.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
if not result:
|
|
190
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
191
|
+
|
|
192
|
+
return args
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _returned_handle(result: Any, _func: Any, args: Any) -> Any:
|
|
196
|
+
"""Raise the error the platform recorded, if no handle came back.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
result: The handle, or a value saying there is none.
|
|
200
|
+
_func: The function, which is not needed.
|
|
201
|
+
args: What it was called with, which is not needed.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The handle.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
OSError: If the call failed.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
if not result or result == _INVALID_HANDLE:
|
|
211
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
212
|
+
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _entry(
|
|
217
|
+
name: str,
|
|
218
|
+
restype: Any,
|
|
219
|
+
*params: tuple[Any, tuple[Any, ...]],
|
|
220
|
+
check: Any = None,
|
|
221
|
+
) -> Any:
|
|
222
|
+
"""Bind one kernel32 function, with a signature of our own.
|
|
223
|
+
|
|
224
|
+
A prototype rather than ``getattr`` and then ``argtypes``, because the
|
|
225
|
+
function object a library hands out is shared with every other caller in
|
|
226
|
+
the process: declaring types on it would change how somebody else's call is
|
|
227
|
+
marshalled. This returns a function of our own and leaves theirs alone.
|
|
228
|
+
|
|
229
|
+
Declaring the types at all matters because ctypes otherwise treats
|
|
230
|
+
everything as a C ``int``, and a handle passed as four bytes works only for
|
|
231
|
+
as long as the values stay small.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
name: The exported symbol.
|
|
235
|
+
restype: What it returns.
|
|
236
|
+
*params: One ``(type, flags)`` pair per parameter, where the flags are
|
|
237
|
+
what ctypes calls paramflags: a direction, a name, and optionally a
|
|
238
|
+
default that lets the caller leave a trailing argument off.
|
|
239
|
+
check: What to do with the result, if the result is worth checking.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A callable bound to that symbol.
|
|
243
|
+
"""
|
|
244
|
+
argtypes = tuple(ctype for ctype, _ in params)
|
|
245
|
+
paramflags = tuple(flags for _, flags in params)
|
|
246
|
+
prototype = ctypes.WINFUNCTYPE(restype, *argtypes, use_last_error=True)
|
|
247
|
+
entry = prototype((name, _kernel32), paramflags)
|
|
248
|
+
|
|
249
|
+
if check is not None:
|
|
250
|
+
entry.errcheck = check
|
|
251
|
+
|
|
252
|
+
return entry
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_CreateFileW: Final = _entry(
|
|
256
|
+
"CreateFileW",
|
|
257
|
+
wintypes.HANDLE,
|
|
258
|
+
(wintypes.LPCWSTR, (_IN, "name")),
|
|
259
|
+
(wintypes.DWORD, (_IN, "access")),
|
|
260
|
+
(wintypes.DWORD, (_IN, "share")),
|
|
261
|
+
# The tail is the same on every call this module makes, so it is a default
|
|
262
|
+
# rather than four arguments repeated at each one.
|
|
263
|
+
(ctypes.c_void_p, (_IN, "security", None)),
|
|
264
|
+
(wintypes.DWORD, (_IN, "disposition", _OPEN_EXISTING)),
|
|
265
|
+
(wintypes.DWORD, (_IN, "flags", 0)),
|
|
266
|
+
(wintypes.HANDLE, (_IN, "template", None)),
|
|
267
|
+
check=_returned_handle,
|
|
268
|
+
)
|
|
269
|
+
_CreateEventW: Final = _entry(
|
|
270
|
+
"CreateEventW",
|
|
271
|
+
wintypes.HANDLE,
|
|
272
|
+
(ctypes.c_void_p, (_IN, "security", None)),
|
|
273
|
+
(wintypes.BOOL, (_IN, "manual_reset", True)),
|
|
274
|
+
(wintypes.BOOL, (_IN, "initial_state", False)),
|
|
275
|
+
(wintypes.LPCWSTR, (_IN, "name", None)),
|
|
276
|
+
check=_returned_handle,
|
|
277
|
+
)
|
|
278
|
+
_CloseHandle: Final = _entry(
|
|
279
|
+
"CloseHandle", wintypes.BOOL, (wintypes.HANDLE, (_IN, "handle"))
|
|
280
|
+
)
|
|
281
|
+
_SetEvent: Final = _entry("SetEvent", wintypes.BOOL, (wintypes.HANDLE, (_IN, "event")))
|
|
282
|
+
_ResetEvent: Final = _entry(
|
|
283
|
+
"ResetEvent", wintypes.BOOL, (wintypes.HANDLE, (_IN, "event"))
|
|
284
|
+
)
|
|
285
|
+
_WaitForMultipleObjects: Final = _entry(
|
|
286
|
+
"WaitForMultipleObjects",
|
|
287
|
+
wintypes.DWORD,
|
|
288
|
+
(wintypes.DWORD, (_IN, "count")),
|
|
289
|
+
(ctypes.POINTER(wintypes.HANDLE), (_IN, "handles")),
|
|
290
|
+
(wintypes.BOOL, (_IN, "all", False)),
|
|
291
|
+
(wintypes.DWORD, (_IN, "milliseconds", _INFINITE)),
|
|
292
|
+
)
|
|
293
|
+
_GetStdHandle: Final = _entry(
|
|
294
|
+
"GetStdHandle", wintypes.HANDLE, (wintypes.DWORD, (_IN, "which"))
|
|
295
|
+
)
|
|
296
|
+
_GetConsoleMode: Final = _entry(
|
|
297
|
+
"GetConsoleMode",
|
|
298
|
+
wintypes.BOOL,
|
|
299
|
+
(wintypes.HANDLE, (_IN, "handle")),
|
|
300
|
+
(ctypes.POINTER(wintypes.DWORD), (_OUT, "mode")),
|
|
301
|
+
check=_returned_true,
|
|
302
|
+
)
|
|
303
|
+
_SetConsoleMode: Final = _entry(
|
|
304
|
+
"SetConsoleMode",
|
|
305
|
+
wintypes.BOOL,
|
|
306
|
+
(wintypes.HANDLE, (_IN, "handle")),
|
|
307
|
+
(wintypes.DWORD, (_IN, "mode")),
|
|
308
|
+
)
|
|
309
|
+
_GetConsoleOutputCP: Final = _entry("GetConsoleOutputCP", wintypes.UINT)
|
|
310
|
+
_SetConsoleOutputCP: Final = _entry(
|
|
311
|
+
"SetConsoleOutputCP", wintypes.BOOL, (wintypes.UINT, (_IN, "code_page"))
|
|
312
|
+
)
|
|
313
|
+
_GetNumberOfConsoleInputEvents: Final = _entry(
|
|
314
|
+
"GetNumberOfConsoleInputEvents",
|
|
315
|
+
wintypes.BOOL,
|
|
316
|
+
(wintypes.HANDLE, (_IN, "handle")),
|
|
317
|
+
(ctypes.POINTER(wintypes.DWORD), (_OUT, "waiting")),
|
|
318
|
+
check=_returned_true,
|
|
319
|
+
)
|
|
320
|
+
_ReadConsoleInputW: Final = _entry(
|
|
321
|
+
"ReadConsoleInputW",
|
|
322
|
+
wintypes.BOOL,
|
|
323
|
+
(wintypes.HANDLE, (_IN, "handle")),
|
|
324
|
+
(ctypes.c_void_p, (_IN, "records")),
|
|
325
|
+
(wintypes.DWORD, (_IN, "wanted")),
|
|
326
|
+
(ctypes.POINTER(wintypes.DWORD), (_OUT, "taken")),
|
|
327
|
+
check=_returned_true,
|
|
328
|
+
)
|
|
329
|
+
_WriteFile: Final = _entry(
|
|
330
|
+
"WriteFile",
|
|
331
|
+
wintypes.BOOL,
|
|
332
|
+
(wintypes.HANDLE, (_IN, "handle")),
|
|
333
|
+
(ctypes.c_void_p, (_IN, "data")),
|
|
334
|
+
(wintypes.DWORD, (_IN, "length")),
|
|
335
|
+
(ctypes.POINTER(wintypes.DWORD), (_OUT, "written")),
|
|
336
|
+
(ctypes.c_void_p, (_IN, "overlapped", None)),
|
|
337
|
+
check=_returned_true,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def platform_problem() -> str | None:
|
|
342
|
+
"""Whether the standard handles really belong to a console.
|
|
343
|
+
|
|
344
|
+
``isatty`` is not enough on Windows: it is true for every character device,
|
|
345
|
+
so a redirect from ``NUL`` claims to be a terminal. Git Bash's ``mintty``
|
|
346
|
+
is the other case worth naming, and the common one on this platform -- it
|
|
347
|
+
is a pty emulated over pipes, so a native Python sees no console at all.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
A sentence naming the problem, or None.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
for name, std in (("input", _STD_INPUT), ("output", _STD_OUTPUT)):
|
|
354
|
+
try:
|
|
355
|
+
_GetConsoleMode(_GetStdHandle(std))
|
|
356
|
+
|
|
357
|
+
except OSError:
|
|
358
|
+
return (
|
|
359
|
+
f"standard {name} is not a Windows console (a terminal "
|
|
360
|
+
f"emulated over pipes, such as Git Bash, cannot do this)"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _WindowsConsole:
|
|
367
|
+
"""The Windows console, in raw mode."""
|
|
368
|
+
|
|
369
|
+
def __init__(self, conin: int, conout: int) -> None:
|
|
370
|
+
"""Hold the handles the modes were set on.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
conin: The console input handle.
|
|
374
|
+
conout: The console output handle.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
OSError: If the wake-up event cannot be created.
|
|
378
|
+
"""
|
|
379
|
+
self._conin = conin
|
|
380
|
+
self._conout = conout
|
|
381
|
+
self._pending = ""
|
|
382
|
+
# Manual reset, so that setting it releases a thread that has not
|
|
383
|
+
# reached the wait yet as surely as one already in it. Every wait
|
|
384
|
+
# clears it first, so a set from a previous wait cannot leak into the
|
|
385
|
+
# next one.
|
|
386
|
+
self._wake = _CreateEventW(manual_reset=True)
|
|
387
|
+
self._waited = (wintypes.HANDLE * 2)(self._wake, conin)
|
|
388
|
+
|
|
389
|
+
async def wait(self) -> None:
|
|
390
|
+
"""Wait until the console has input, or this task is cancelled.
|
|
391
|
+
|
|
392
|
+
The thread is inside the task tree rather than beside it: whichever way
|
|
393
|
+
this returns, it does not return until the thread has. That holds on
|
|
394
|
+
cancellation too, because what unwinds the cancellation is the same
|
|
395
|
+
event that releases the thread.
|
|
396
|
+
"""
|
|
397
|
+
_ResetEvent(self._wake)
|
|
398
|
+
|
|
399
|
+
async with anyio.create_task_group() as tg:
|
|
400
|
+
|
|
401
|
+
async def watch() -> None:
|
|
402
|
+
# abandon_on_cancel=False: a cancel waits for the thread, which
|
|
403
|
+
# is safe precisely because the thread is about to be released
|
|
404
|
+
# by the event set below.
|
|
405
|
+
await anyio.to_thread.run_sync(self._block, abandon_on_cancel=False)
|
|
406
|
+
tg.cancel_scope.cancel()
|
|
407
|
+
|
|
408
|
+
tg.start_soon(watch)
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
await anyio.sleep_forever()
|
|
412
|
+
|
|
413
|
+
finally:
|
|
414
|
+
# Either input arrived and the watcher cancelled us, or the
|
|
415
|
+
# caller did. Both mean the thread has to be let go before this
|
|
416
|
+
# group can exit. A synchronous call, so a cancelled scope
|
|
417
|
+
# cannot skip it.
|
|
418
|
+
_SetEvent(self._wake)
|
|
419
|
+
|
|
420
|
+
def _block(self) -> None:
|
|
421
|
+
"""Block until the console has input or the wake-up event is set.
|
|
422
|
+
|
|
423
|
+
Runs in a worker thread. ctypes releases the interpreter lock around
|
|
424
|
+
the call, so nothing else is held up by it.
|
|
425
|
+
"""
|
|
426
|
+
_WaitForMultipleObjects(2, self._waited)
|
|
427
|
+
|
|
428
|
+
def read(self) -> bytes:
|
|
429
|
+
"""Take whatever has been typed, as UTF-8.
|
|
430
|
+
|
|
431
|
+
Every record waiting is taken, and the ones that carry a character are
|
|
432
|
+
kept. In virtual terminal mode those characters are already the escape
|
|
433
|
+
sequences a terminal would have sent. They are UTF-16 code units, so a
|
|
434
|
+
character outside the basic plane arrives as two of them and the first
|
|
435
|
+
is held back until its other half comes.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
The bytes typed, or empty.
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
OSError: If the console cannot be read, which would otherwise show
|
|
442
|
+
up as a session that has quietly gone deaf.
|
|
443
|
+
"""
|
|
444
|
+
text = self._pending
|
|
445
|
+
self._pending = ""
|
|
446
|
+
text += self._take()
|
|
447
|
+
if text and "\ud800" <= text[-1] <= "\udbff":
|
|
448
|
+
# A high surrogate with its partner still to come.
|
|
449
|
+
self._pending = text[-1]
|
|
450
|
+
text = text[:-1]
|
|
451
|
+
|
|
452
|
+
return text.encode("utf-8", "surrogatepass")
|
|
453
|
+
|
|
454
|
+
def _take(self) -> str:
|
|
455
|
+
"""Drain the input buffer, keeping only what is a character.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
What was typed, as text.
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
OSError: If the console cannot be read. Deliberately not swallowed:
|
|
462
|
+
a read that fails quietly leaves every wait returning at once,
|
|
463
|
+
which is a session spinning rather than a session ending.
|
|
464
|
+
"""
|
|
465
|
+
typed: list[str] = []
|
|
466
|
+
|
|
467
|
+
while len(typed) < READ_LIMIT:
|
|
468
|
+
count = min(_GetNumberOfConsoleInputEvents(self._conin), READ_LIMIT)
|
|
469
|
+
if not count:
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
records = (_Record * count)()
|
|
473
|
+
taken = _ReadConsoleInputW(self._conin, records, count)
|
|
474
|
+
|
|
475
|
+
for index in range(taken):
|
|
476
|
+
record = records[index]
|
|
477
|
+
if record.EventType != _KEY_EVENT:
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
key = record.Event.KeyEvent
|
|
481
|
+
if not key.bKeyDown:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
char = key.uChar.UnicodeChar
|
|
485
|
+
if not char or char == "\x00":
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
typed.append(char * max(1, key.wRepeatCount))
|
|
489
|
+
|
|
490
|
+
return "".join(typed)
|
|
491
|
+
|
|
492
|
+
def write(self, data: bytes) -> None:
|
|
493
|
+
"""Write bytes to the console, byte for byte.
|
|
494
|
+
|
|
495
|
+
The console's output code page is UTF-8 for the duration of the
|
|
496
|
+
session, so the hub's bytes go out untouched. Decoding them here would
|
|
497
|
+
corrupt any that are not valid UTF-8, and hub output is arbitrary
|
|
498
|
+
bytes.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
data: What to write.
|
|
502
|
+
"""
|
|
503
|
+
_WriteFile(self._conout, data, len(data))
|
|
504
|
+
|
|
505
|
+
def size(self) -> tuple[int, int]:
|
|
506
|
+
"""Return the console's size.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Columns and rows.
|
|
510
|
+
"""
|
|
511
|
+
columns, lines = shutil.get_terminal_size()
|
|
512
|
+
|
|
513
|
+
return columns, lines
|
|
514
|
+
|
|
515
|
+
def close(self) -> None:
|
|
516
|
+
"""Release anything still waiting, and let the event go."""
|
|
517
|
+
_SetEvent(self._wake)
|
|
518
|
+
_CloseHandle(self._wake)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _open(name: str) -> int:
|
|
522
|
+
"""Open one of the console's own devices.
|
|
523
|
+
|
|
524
|
+
``CONIN$`` and ``CONOUT$`` rather than the standard handles, so this always
|
|
525
|
+
talks to the real console even where stdio has been redirected, and so
|
|
526
|
+
restoring can never target the wrong object.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
name: ``CONIN$`` or ``CONOUT$``.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
The handle.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
OSError: If the console cannot be opened.
|
|
536
|
+
"""
|
|
537
|
+
return _CreateFileW(
|
|
538
|
+
name,
|
|
539
|
+
_GENERIC_READ | _GENERIC_WRITE,
|
|
540
|
+
_FILE_SHARE_READ | _FILE_SHARE_WRITE,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@contextlib.contextmanager
|
|
545
|
+
def raw_console() -> Generator[Console]:
|
|
546
|
+
"""Put the console into raw mode for the duration of the block.
|
|
547
|
+
|
|
548
|
+
Yields:
|
|
549
|
+
The console.
|
|
550
|
+
|
|
551
|
+
Raises:
|
|
552
|
+
OSError: If the console cannot be opened.
|
|
553
|
+
"""
|
|
554
|
+
conin = _open("CONIN$")
|
|
555
|
+
conout = _open("CONOUT$")
|
|
556
|
+
|
|
557
|
+
saved_in = _GetConsoleMode(conin)
|
|
558
|
+
saved_out = _GetConsoleMode(conout)
|
|
559
|
+
saved_cp = _GetConsoleOutputCP()
|
|
560
|
+
|
|
561
|
+
_SetConsoleMode(conin, _RAW_INPUT_MODE)
|
|
562
|
+
_SetConsoleMode(
|
|
563
|
+
conout,
|
|
564
|
+
saved_out
|
|
565
|
+
| _ENABLE_PROCESSED_OUTPUT
|
|
566
|
+
| _ENABLE_WRAP_AT_EOL_OUTPUT
|
|
567
|
+
| _ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
568
|
+
| _DISABLE_NEWLINE_AUTO_RETURN,
|
|
569
|
+
)
|
|
570
|
+
_SetConsoleOutputCP(_CP_UTF8)
|
|
571
|
+
console = _WindowsConsole(conin, conout)
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
yield console
|
|
575
|
+
|
|
576
|
+
finally:
|
|
577
|
+
# Synchronous, so no cancellation can skip it. The console is released
|
|
578
|
+
# before its handles are closed, so nothing is left waiting on one that
|
|
579
|
+
# has gone. The output code page is process-wide console state and is
|
|
580
|
+
# restored with the modes.
|
|
581
|
+
console.close()
|
|
582
|
+
_SetConsoleOutputCP(saved_cp)
|
|
583
|
+
_SetConsoleMode(conin, saved_in)
|
|
584
|
+
_SetConsoleMode(conout, saved_out)
|
|
585
|
+
_CloseHandle(conin)
|
|
586
|
+
_CloseHandle(conout)
|