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