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,369 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Managing the background service that keeps hubs connected.
5
+
6
+ Rarely needed: the service starts by itself when something wants it and stops
7
+ when nothing has for a while. These exist for the times it has to be looked at
8
+ directly -- after an upgrade, or when a hub is behaving oddly and the log is
9
+ the only place the reason will be.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import logging
16
+ from contextlib import aclosing
17
+ from pathlib import Path
18
+ from typing import Annotated, NamedTuple
19
+
20
+ import anyio
21
+ import cyclopts
22
+ from rich.console import Console
23
+
24
+ # The unknown here is watchfiles' own AnyEvent union, which names trio's
25
+ # Event as one of the things a stop_event may be. trio is not installed and
26
+ # never will be -- bleak pins this to asyncio -- and nothing here passes a
27
+ # stop_event at all, since aclosing is what stops the watch.
28
+ from watchfiles import awatch # pyright: ignore[reportUnknownVariableType]
29
+
30
+ import pb_client
31
+ from pb_cli import errors, ui
32
+ from pb_cli.app import Verbose, configure_logging, service_app
33
+ from pb_cli.errors import EXIT_OK
34
+ from pb_core.paths import log_file
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ __all__ = [
39
+ "service_log",
40
+ "service_start",
41
+ "service_status",
42
+ "service_stop",
43
+ ]
44
+
45
+ _HANDLED_HERE = (pb_client.PbClientError, BaseExceptionGroup)
46
+ """What to catch when a command deals with a failure itself.
47
+
48
+ Catching the error's own type is not enough: anything raised inside an
49
+ ``async with pb_client.connect()`` block comes back wrapped in a group of one,
50
+ so the type to catch is not the type that was raised.
51
+ """
52
+
53
+
54
+ @service_app.command(name="status")
55
+ async def service_status(*, verbose: Verbose = (), start: bool = False) -> int:
56
+ """Show whether the background service is running.
57
+
58
+ Args:
59
+ verbose: Increase log detail.
60
+ start: Start the service if it is not running, instead of reporting
61
+ that it is not.
62
+ """
63
+ configure_logging(verbose)
64
+ return await errors.run(_service_status, start)
65
+
66
+
67
+ async def _service_status(start: bool) -> int:
68
+ out, err = ui.console(), ui.console(err=True)
69
+
70
+ try:
71
+ async with pb_client.connect(autostart=start) as service:
72
+ info = await service.info()
73
+ except _HANDLED_HERE as exc:
74
+ # "It is not running" is the answer to the question, not a failure to
75
+ # answer it, so it is reported here rather than as an error.
76
+ if errors.find(exc, pb_client.ServiceUnavailable) is None:
77
+ raise
78
+
79
+ err.print("[yellow]Not running.[/]")
80
+ err.print("It starts by itself when needed, or run [bold]pb service start[/].")
81
+ return errors.EXIT_NOTHING_CONNECTED
82
+
83
+ ui.rows(
84
+ out,
85
+ [
86
+ ("status", "running"),
87
+ ("version", info.version),
88
+ ("protocol", str(info.protocol_version)),
89
+ ("pid", str(info.pid)),
90
+ ("uptime_s", f"{info.uptime_s:.1f}"),
91
+ ("endpoint", info.endpoint),
92
+ ("clients", str(info.clients)),
93
+ ("log", info.log_file),
94
+ ],
95
+ )
96
+ return EXIT_OK
97
+
98
+
99
+ @service_app.command(name="start")
100
+ async def service_start(*, verbose: Verbose = ()) -> int:
101
+ """Start the background service if it is not already running.
102
+
103
+ Args:
104
+ verbose: Increase log detail.
105
+ """
106
+ configure_logging(verbose)
107
+ return await errors.run(_service_start)
108
+
109
+
110
+ async def _service_start() -> int:
111
+ err = ui.console(err=True)
112
+
113
+ async with pb_client.connect(autostart=True) as service:
114
+ info = await service.info()
115
+
116
+ err.print(f"Running (pid {info.pid}).")
117
+ return EXIT_OK
118
+
119
+
120
+ @service_app.command(name="stop")
121
+ async def service_stop(*, verbose: Verbose = ()) -> int:
122
+ """Stop the background service, disconnecting any hubs.
123
+
124
+ Args:
125
+ verbose: Increase log detail.
126
+ """
127
+ configure_logging(verbose)
128
+ return await errors.run(_service_stop)
129
+
130
+
131
+ async def _service_stop() -> int:
132
+ err = ui.console(err=True)
133
+
134
+ try:
135
+ async with pb_client.connect(autostart=False) as service:
136
+ await service.shutdown()
137
+ except _HANDLED_HERE as exc:
138
+ # Stopping something that is already stopped succeeded.
139
+ if errors.find(exc, pb_client.ServiceUnavailable) is None:
140
+ raise
141
+
142
+ err.print("Not running.")
143
+ return EXIT_OK
144
+
145
+ err.print("Stopping.")
146
+ return EXIT_OK
147
+
148
+
149
+ TAIL_BYTES = 1024 * 1024
150
+ """How much of the end of the log to read when only the last lines are wanted.
151
+
152
+ The file rotates at 2 MiB, so this is most of one, and it saves reading all of
153
+ a large file to answer a question about its last few lines.
154
+ """
155
+
156
+ FOLLOW_STEP_MS = 20
157
+ """How long the watcher waits for quiet before reporting what it has seen.
158
+
159
+ The pause after a burst of writing, so a line reaches the screen about as fast
160
+ as the terminal can draw it.
161
+ """
162
+
163
+ FOLLOW_DEBOUNCE_MS = 100
164
+ """The longest the watcher groups changes before reporting them anyway.
165
+
166
+ A log being written steadily never goes quiet for :data:`FOLLOW_STEP_MS`, so
167
+ without this it would be reported only when the writing paused. The library's
168
+ own default is 1600 ms, which is fine for rebuilding a project and far too slow
169
+ for watching output arrive.
170
+ """
171
+
172
+
173
+ @service_app.command(name="log")
174
+ async def service_log(
175
+ *,
176
+ lines: int = 50,
177
+ follow: Annotated[bool, cyclopts.Parameter(name=["-f", "--follow"])] = False,
178
+ verbose: Verbose = (),
179
+ ) -> int:
180
+ """Show what the background service has been doing.
181
+
182
+ The service has no terminal of its own, so its log is the only place its
183
+ side of the story is written down. This reads the file directly and never
184
+ contacts the service, which means it still works when the service is not
185
+ running -- which is when it is most likely to be wanted.
186
+
187
+ Args:
188
+ lines: How many lines from the end to show. Zero shows the whole file.
189
+ follow: Keep watching, printing new lines as they arrive. Ctrl-C
190
+ leaves.
191
+ verbose: Increase log detail.
192
+ """
193
+ configure_logging(verbose)
194
+ return await errors.run(_service_log, lines, follow)
195
+
196
+
197
+ async def _service_log(lines: int, follow: bool) -> int:
198
+ out, err = ui.console(), ui.console(err=True)
199
+ path = log_file()
200
+ if not path.is_file():
201
+ # Nothing written is an answer rather than a failure: the service has
202
+ # not run since this machine last tidied up after it.
203
+ err.print("The background service has not written a log yet.")
204
+ err.print("It starts by itself when needed, or run [bold]pb service start[/].")
205
+ return EXIT_OK
206
+
207
+ offset = _print_the_end(out, path, lines)
208
+
209
+ if not follow:
210
+ return EXIT_OK
211
+
212
+ err.print(f"Watching [bold]{path}[/]. Ctrl-C to stop.")
213
+
214
+ with contextlib.suppress(KeyboardInterrupt):
215
+ # Caught rather than left to the top level: Ctrl-C is how this command
216
+ # is meant to end, so it is not an interruption of anything.
217
+ await _follow(out, path, offset)
218
+
219
+ return EXIT_OK
220
+
221
+
222
+ def _print_the_end(out: Console, path: Path, lines: int) -> int:
223
+ """Print the last of the log, and say where reading reached.
224
+
225
+ Args:
226
+ out: Where the lines go.
227
+ path: The log file.
228
+ lines: How many to print, or zero for all of them.
229
+
230
+ Returns:
231
+ The offset reading reached, for following on from.
232
+ """
233
+ end = path.stat().st_size
234
+ start = max(0, end - TAIL_BYTES) if lines > 0 else 0
235
+
236
+ with path.open("rb") as handle:
237
+ handle.seek(start)
238
+ block = handle.read()
239
+
240
+ if start:
241
+ # The seek lands in the middle of a line, and half a line is worse
242
+ # than one line fewer.
243
+ block = block.split(b"\n", 1)[-1]
244
+
245
+ text = _decode(block).splitlines()
246
+
247
+ for line in text[-lines:] if lines > 0 else text:
248
+ ui.plain(out, line)
249
+
250
+ return end
251
+
252
+
253
+ async def _follow(out: Console, path: Path, offset: int) -> None:
254
+ """Print what is added to the log, until the caller is interrupted.
255
+
256
+ Waits to be told rather than asking. The directory is watched rather than
257
+ the file, because a tail has two questions and only one of them is about
258
+ the file it is holding: the log rotates at 2 MiB, and the replacement
259
+ arriving is a change to the directory.
260
+
261
+ Args:
262
+ out: Where the lines go.
263
+ path: The log file.
264
+ offset: Where reading has already reached.
265
+ """
266
+ # anyio's Path, not pathlib's: this runs for as long as somebody watches,
267
+ # and a file system that has gone slow -- a network drive, a sleeping disk
268
+ # -- must not stall the event loop it is running in.
269
+ watched = anyio.Path(path)
270
+ state = _Tail(offset=offset)
271
+ # Once before waiting, since the service may have written something
272
+ # between the tail above and the watch starting, and a line missed here
273
+ # would never be reported by any later change.
274
+ state = await _drain(out, watched, state)
275
+
276
+ # aclosing because this generator holds a thread and the watcher behind
277
+ # it, released when its stop event is set on the way out. Leaving on
278
+ # Ctrl-C is the ordinary way this command ends, so that path is the one
279
+ # that has to be tidy. See the async rules in CONTRIBUTING.md.
280
+ async with aclosing(
281
+ awatch(
282
+ path.parent,
283
+ # Every change, rather than the library's default filter, which
284
+ # exists for source trees and has opinions about file names.
285
+ watch_filter=None,
286
+ recursive=False,
287
+ step=FOLLOW_STEP_MS,
288
+ debounce=FOLLOW_DEBOUNCE_MS,
289
+ # Overriding watchfiles' own choice, deliberately and with a
290
+ # measurement behind it. It turns polling on automatically for the
291
+ # whole of WSL, because inotify does not work for files on a
292
+ # Windows drive mounted through drvfs -- but its polling reports
293
+ # *nothing at all* for a file on tmpfs, measured on WSL2 with
294
+ # Ubuntu 26.04: three appends over a second, and no event in the
295
+ # three seconds that followed. The same watch with this set to
296
+ # False reported every one of them 20 ms after it happened. The
297
+ # log lives in the state directory, which on WSL is inside the
298
+ # Linux file system where inotify works. A log on a Windows drive
299
+ # is the case this gives up on, and it is the case where the
300
+ # alternative reports nothing anyway.
301
+ force_polling=False,
302
+ )
303
+ ) as changes:
304
+ async for _ in changes:
305
+ state = await _drain(out, watched, state)
306
+
307
+
308
+ class _Tail(NamedTuple):
309
+ """Where reading has reached, and the part of a line still waiting."""
310
+
311
+ offset: int
312
+ """How much of the file has been read."""
313
+
314
+ pending: bytes = b""
315
+ """Bytes after the last newline, held back until the rest arrives."""
316
+
317
+
318
+ async def _drain(out: Console, watched: anyio.Path, state: _Tail) -> _Tail:
319
+ """Print whatever has been added since the last read.
320
+
321
+ Args:
322
+ out: Where the lines go.
323
+ watched: The log file.
324
+ state: Where reading had reached.
325
+
326
+ Returns:
327
+ Where reading has reached now.
328
+ """
329
+
330
+ try:
331
+ size = (await watched.stat()).st_size
332
+ except FileNotFoundError:
333
+ # Rotated away, and the replacement is not there yet.
334
+ return _Tail(offset=0)
335
+
336
+ if size < state.offset:
337
+ # Rotated: what is here now is a new file, so a position from the old
338
+ # one means nothing.
339
+ state = _Tail(offset=0)
340
+
341
+ if size == state.offset:
342
+ return state
343
+
344
+ async with await watched.open("rb") as handle:
345
+ await handle.seek(state.offset)
346
+ block = await handle.read()
347
+
348
+ # A line can arrive in two pieces, so hold back anything after the last
349
+ # newline until the rest of it turns up.
350
+ complete, _, pending = (state.pending + block).rpartition(b"\n")
351
+
352
+ for line in _decode(complete).splitlines():
353
+ ui.plain(out, line)
354
+
355
+ return _Tail(offset=state.offset + len(block), pending=pending)
356
+
357
+
358
+ def _decode(block: bytes) -> str:
359
+ """Turn log bytes into text without ever failing on them.
360
+
361
+ Args:
362
+ block: What was read.
363
+
364
+ Returns:
365
+ The text. The service writes UTF-8, but a file cut mid-character by
366
+ rotation, or written by an older build, has to stay readable rather
367
+ than raise.
368
+ """
369
+ return block.decode("utf-8", "replace")
pb_cli/config.py ADDED
@@ -0,0 +1,213 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Where an option comes from when it is not on the command line.
5
+
6
+ Three layers, highest first: what was typed, then the environment, then the
7
+ project's ``pyproject.toml``. Below them the command decides for itself -- for
8
+ a hub that means the one chosen with ``pb use``, and then the only one
9
+ connected.
10
+
11
+ The project layer is what makes a robot project work for whoever clones it:
12
+ ``[tool.pb] hub`` names the hub once, and every shell opened anywhere inside
13
+ the project agrees, without anybody having to remember a flag.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import tomllib
19
+ from pathlib import Path
20
+ from typing import cast
21
+
22
+ import cyclopts
23
+
24
+ __all__ = [
25
+ "ENV_PREFIX",
26
+ "KNOWN_KEYS",
27
+ "PROJECT_FILE",
28
+ "ProjectConfigError",
29
+ "check_project_file",
30
+ "config_sources",
31
+ "find_project_file",
32
+ ]
33
+
34
+ PROJECT_FILE = "pyproject.toml"
35
+ """Where a project says which hub it belongs to.
36
+
37
+ Under ``[tool.pb]``, found by walking up from the working directory.
38
+ """
39
+
40
+ ENV_PREFIX = "PB_"
41
+ """How an option is spelled in the environment: ``--hub`` is ``PB_HUB``.
42
+
43
+ Every option, not only that one. One prefix is easier to remember than a list
44
+ of blessed variables, and an environment variable is ephemeral -- it belongs to
45
+ one shell and one moment -- so being generous costs little.
46
+ """
47
+
48
+ KNOWN_KEYS = frozenset({"hub", "transport"})
49
+ """What ``[tool.pb]`` is allowed to contain.
50
+
51
+ Deliberately a short list rather than "any option this tool happens to have".
52
+ The file is committed and read by everyone who clones the project, which makes
53
+ it documented surface rather than a convenience, and surface should say what it
54
+ covers. Adding a key here is how it grows.
55
+
56
+ ``transport`` earns its place for the same reason ``hub`` did: a project whose
57
+ robot drives away from the desk wants Bluetooth every time, and saying so once
58
+ in the file beats everyone who clones it remembering a flag.
59
+ """
60
+
61
+
62
+ class ProjectConfigError(Exception):
63
+ """The project file says something this tool does not understand."""
64
+
65
+ def __init__(self, message: str, path: Path) -> None:
66
+ """Build the error.
67
+
68
+ Args:
69
+ message: What is wrong, as a sentence.
70
+ path: The file it is wrong in.
71
+ """
72
+ super().__init__(message)
73
+ self.path = path
74
+
75
+
76
+ def config_sources(
77
+ project_file: Path | None = None,
78
+ ) -> tuple[cyclopts.config.Env, cyclopts.config.Toml]:
79
+ """Build the layers an option falls back to when it is not given.
80
+
81
+ Args:
82
+ project_file: The project file to read, when it has already been
83
+ found. Given by the launcher, which knows where ``-C`` pointed and
84
+ applies the "nearest file with a ``[tool.pb]`` table" rule. Left
85
+ out, cyclopts searches upwards from the working directory itself
86
+ and stops at the first ``pyproject.toml`` it finds, which is the
87
+ older and less careful behaviour.
88
+
89
+ Returns:
90
+ The sources, in the order cyclopts consults them: the environment
91
+ first, then the project file. Anything typed on the command line beats
92
+ both.
93
+ """
94
+ return (
95
+ cyclopts.config.Env(ENV_PREFIX, command=False),
96
+ cyclopts.config.Toml(
97
+ project_file if project_file is not None else PROJECT_FILE,
98
+ root_keys=("tool", "pb"),
99
+ # From the working directory upwards, so it is the project that
100
+ # matters rather than where in it the command was run.
101
+ search_parents=project_file is None,
102
+ # One table for every command rather than a table per command:
103
+ # [tool.pb] hub, not [tool.pb.run] hub.
104
+ use_commands_as_keys=False,
105
+ # Necessary rather than lenient. A key is matched against the
106
+ # parameters of the command being run, so "hub" would otherwise be
107
+ # an unknown option to every command that does not take one -- pb
108
+ # check and pb list among them, which would then fail merely
109
+ # because the file exists. What this gives up, a misspelled key
110
+ # passing unnoticed, is taken back by check_project_file.
111
+ allow_unknown=True,
112
+ ),
113
+ )
114
+
115
+
116
+ def _settings(path: Path) -> dict[str, object] | None:
117
+ """Read one file's ``[tool.pb]`` table.
118
+
119
+ Args:
120
+ path: A ``pyproject.toml``.
121
+
122
+ Returns:
123
+ The table, or ``None`` if the file does not exist or has no
124
+ ``[tool.pb]``.
125
+
126
+ Raises:
127
+ ProjectConfigError: If the file exists but cannot be read or parsed,
128
+ or if ``[tool.pb]`` is not a table. A file that cannot be read is
129
+ worth saying so about, where a file that simply says nothing about
130
+ this tool is not.
131
+ """
132
+
133
+ if not path.is_file():
134
+ return None
135
+
136
+ try:
137
+ document = tomllib.loads(path.read_text(encoding="utf-8"))
138
+ except OSError as exc:
139
+ raise ProjectConfigError(f"{PROJECT_FILE} could not be read.", path) from exc
140
+ except tomllib.TOMLDecodeError as exc:
141
+ raise ProjectConfigError(f"{PROJECT_FILE} is not valid TOML.", path) from exc
142
+
143
+ table: object = document.get("tool")
144
+ settings = (
145
+ cast("dict[str, object]", table).get("pb") if isinstance(table, dict) else None
146
+ )
147
+ if settings is None:
148
+ return None
149
+
150
+ if not isinstance(settings, dict):
151
+ raise ProjectConfigError("[tool.pb] is not a table.", path)
152
+
153
+ return cast("dict[str, object]", settings)
154
+
155
+
156
+ def find_project_file(start: Path | None = None) -> Path | None:
157
+ """Find the project file that applies here.
158
+
159
+ A file with no ``[tool.pb]`` is searched *past* rather than stopping the
160
+ search. Otherwise a monorepo cannot name its hub once at the top and
161
+ override it per project -- a member's own ``pyproject.toml``, which it has
162
+ for entirely unrelated reasons, would shadow it -- and an unrelated project
163
+ file anywhere in the path would turn the feature off silently, which is the
164
+ worst way for it to be off.
165
+
166
+ Args:
167
+ start: Where to look from, defaulting to the working directory.
168
+
169
+ Returns:
170
+ The nearest ``pyproject.toml`` at or above ``start`` that has a
171
+ ``[tool.pb]`` table, or ``None``.
172
+
173
+ Raises:
174
+ ProjectConfigError: If a candidate cannot be read or parsed.
175
+ """
176
+ here = (start or Path.cwd()).resolve()
177
+
178
+ for directory in (here, *here.parents):
179
+ candidate = directory / PROJECT_FILE
180
+ if _settings(candidate) is not None:
181
+ return candidate
182
+
183
+ return None
184
+
185
+
186
+ def check_project_file(start: Path | None = None) -> None:
187
+ """Refuse a project file that says something unrecognised.
188
+
189
+ Args:
190
+ start: Where to look from, defaulting to the working directory.
191
+
192
+ Raises:
193
+ ProjectConfigError: If the file cannot be read, or ``[tool.pb]`` holds
194
+ a key this tool does not understand. Being strict here is the point
195
+ of reading it ourselves: cyclopts has to be told to ignore unknown
196
+ keys, and a hub that is quietly not applied because of a typo is
197
+ exactly the kind of thing somebody spends an afternoon on.
198
+ """
199
+ path = find_project_file(start)
200
+ if path is None:
201
+ return
202
+
203
+ settings = _settings(path)
204
+ if settings is None: # pragma: no cover - find_project_file just read it
205
+ return
206
+
207
+ unknown = sorted(set(settings) - KNOWN_KEYS)
208
+ if unknown:
209
+ named = ", ".join(unknown)
210
+ raise ProjectConfigError(
211
+ f"[tool.pb] does not understand {named}.",
212
+ path,
213
+ )
@@ -0,0 +1,117 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The terminal, in raw mode, as bytes in and bytes out.
5
+
6
+ This is the piece :mod:`pb_cli.keys` deliberately is not. A chooser only has to
7
+ tell up from down from Enter; a session with a hub has to deliver *every* byte
8
+ a key produces, unchanged, including the ones that would otherwise be a signal
9
+ -- Ctrl-C is a byte for the interpreter on the hub, not an interrupt for us.
10
+
11
+ Three properties are what make this tractable, and each one removes a class of
12
+ bug that hand-rolled terminal code is famous for.
13
+
14
+ **Waiting is waiting, and reading never blocks.** The two are separate calls:
15
+ :meth:`Console.wait` returns once there may be something to read, and
16
+ :meth:`Console.read` takes whatever is there without ever blocking. So a
17
+ keystroke wakes the session in microseconds rather than at the next turn of a
18
+ timer, and nothing anywhere polls the keyboard. Splitting them is what makes
19
+ that possible on both platforms at once, because the two arrive at it
20
+ differently: POSIX waits on the file descriptor itself, and Windows waits on a
21
+ console handle in a thread, since no event loop will do it. Neither ever blocks
22
+ inside the read, which is the thing that cannot be cancelled.
23
+
24
+ **Windows and POSIX produce the same bytes.** Setting
25
+ ``ENABLE_VIRTUAL_TERMINAL_INPUT`` makes the console encode special keys as the
26
+ escape sequences a terminal would send, so an arrow key really does arrive as
27
+ ``ESC [ A`` on both. Measured, not assumed. Everything above this package is
28
+ therefore shared, and none of the vkey-to-sequence translation tables that
29
+ other tools carry is needed.
30
+
31
+ **Leaving raw mode is a synchronous ``__exit__``.** A sync exit cannot be
32
+ cancelled at all, which is stronger than shielding, so the terminal is restored
33
+ on every ordinary path -- return, exception, cancellation -- by the language
34
+ itself rather than by anything this module remembers to do.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import sys
40
+ from typing import Protocol, runtime_checkable
41
+
42
+ if sys.platform == "win32":
43
+ from pb_cli.console.windows import platform_problem, raw_console
44
+
45
+ else:
46
+ from pb_cli.console.posix import platform_problem, raw_console
47
+
48
+ __all__ = ["Console", "available", "raw_console", "why_unavailable"]
49
+
50
+
51
+ @runtime_checkable
52
+ class Console(Protocol):
53
+ """A terminal that has been put into raw mode."""
54
+
55
+ async def wait(self) -> None:
56
+ """Wait until there may be something to read.
57
+
58
+ Returns when the terminal has input, which is not a promise that
59
+ :meth:`read` will yield any bytes: a console reports more than typing,
60
+ and the window merely gaining focus counts as input here. The caller
61
+ reads, gets nothing, and waits again.
62
+ """
63
+ ...
64
+
65
+ def read(self) -> bytes:
66
+ """Take whatever has been typed.
67
+
68
+ Returns:
69
+ The bytes waiting, or empty if nothing has been typed. Never
70
+ blocks.
71
+ """
72
+ ...
73
+
74
+ def write(self, data: bytes) -> None:
75
+ """Put bytes on the screen exactly as given.
76
+
77
+ Args:
78
+ data: What to write. Not decoded, not translated: it may be part
79
+ of an escape sequence, or half of a character whose other half
80
+ has not arrived.
81
+ """
82
+ ...
83
+
84
+ def size(self) -> tuple[int, int]:
85
+ """Return the terminal's size.
86
+
87
+ Returns:
88
+ Columns and rows.
89
+ """
90
+ ...
91
+
92
+
93
+ def why_unavailable() -> str | None:
94
+ """Explain why a raw terminal cannot be had here.
95
+
96
+ Returns:
97
+ A sentence naming the problem, or ``None`` if there is no problem.
98
+ """
99
+
100
+ for name, stream in (("input", sys.stdin), ("output", sys.stdout)):
101
+ try:
102
+ if not stream.isatty():
103
+ return f"standard {name} is not a terminal"
104
+
105
+ except (AttributeError, ValueError): # pragma: no cover - odd embeddings
106
+ return f"there is no standard {name}"
107
+
108
+ return platform_problem()
109
+
110
+
111
+ def available() -> bool:
112
+ """Whether an interactive session is possible here.
113
+
114
+ Returns:
115
+ True if this process has a terminal it can put into raw mode.
116
+ """
117
+ return why_unavailable() is None