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.
@@ -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)