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 ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The Pybricks command line interface."""
5
+
6
+ __version__ = "1.0.0b1"
7
+
8
+ __all__ = ["__version__"]
pb_cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ from pb_cli.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
pb_cli/app.py ADDED
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The command line's shape: the app itself, its groups, and shared options.
5
+
6
+ Separate from the commands so that the command modules can all import it
7
+ without importing each other. :mod:`pb_cli.cli` pulls those modules in, which is
8
+ what registers them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from typing import Annotated
17
+
18
+ import cyclopts
19
+
20
+ from pb_cli import __version__
21
+ from pb_cli.config import config_sources
22
+ from pb_core.logging import TRACE, setup_logging
23
+
24
+ __all__ = [
25
+ "HUB_COMMANDS",
26
+ "PROGRAM_COMMANDS",
27
+ "SERVICE_COMMANDS",
28
+ "Verbose",
29
+ "app",
30
+ "configure_logging",
31
+ "service_app",
32
+ ]
33
+
34
+ app = cyclopts.App(
35
+ name="pb",
36
+ version=__version__,
37
+ help=(
38
+ "Talk to Pybricks hubs.\n\n"
39
+ "A hub stays connected between commands, so you only need to run "
40
+ "pb connect once."
41
+ ),
42
+ # Every command is in a group of its own, so what is left in the default
43
+ # panel is --help and --version. Calling that panel "Commands" would be a
44
+ # lie, and putting it last keeps the commands at the top where someone
45
+ # reading the help is looking.
46
+ group_commands=cyclopts.Group("Options", sort_key=3),
47
+ config=config_sources(),
48
+ )
49
+
50
+ HUB_COMMANDS = cyclopts.Group("Hubs", sort_key=0)
51
+ """Finding a hub, holding on to one, and asking what it is doing.
52
+
53
+ Ordered by what someone actually has to do, which is why each command carries
54
+ an explicit ``sort_key`` rather than falling back to alphabetical. ``connect``
55
+ comes first, because it is the one command nobody gets anywhere without, with
56
+ its opposite ``disconnect`` beside it.
57
+
58
+ The rest are things a user may want rather than steps they must take, so they
59
+ come after: ``status`` and ``list`` report on what is already connected, and
60
+ ``scan`` comes last because nobody has to run it at all -- ``connect`` does its
61
+ own looking, and ``connect --scan`` shows the same list when the choice
62
+ matters.
63
+ """
64
+
65
+ PROGRAM_COMMANDS = cyclopts.Group("Programs", sort_key=1)
66
+ """Getting a program onto a hub and controlling it once it is there."""
67
+
68
+ SERVICE_COMMANDS = cyclopts.Group("Background service", sort_key=2)
69
+ """The thing holding the connections open. Rarely needed, so it comes last."""
70
+
71
+ service_app = cyclopts.App(
72
+ name="service",
73
+ help="Manage the background service that keeps hubs connected.",
74
+ group=SERVICE_COMMANDS,
75
+ # Not inherited from the root app, where the default panel holds only the
76
+ # flags: down here it really is the list of commands.
77
+ group_commands=cyclopts.Group("Commands"),
78
+ )
79
+ app.command(service_app)
80
+
81
+
82
+ Verbose = Annotated[
83
+ Sequence[bool],
84
+ cyclopts.Parameter(
85
+ name=["-v", "--verbose"],
86
+ help="Increase log detail. Repeat for more (-v, -vv, -vvv).",
87
+ show_default=False,
88
+ # No --no-verbose: there is nothing to turn off, and offering it
89
+ # would be one more thing in every command's help for no reason.
90
+ negative=(),
91
+ ),
92
+ ]
93
+ """How loud to be, counted rather than measured.
94
+
95
+ A sequence of flags rather than a number, because that is what makes the help
96
+ true. As an ``int`` this took a value -- ``-v 1`` worked and ``-v`` was an
97
+ error saying it "requires an argument", while every command's help said to
98
+ repeat it. Collecting the flags gives ``-v``, ``-v -v`` and the stacked
99
+ ``-vv``, which is what anybody typing it expects.
100
+ """
101
+
102
+
103
+ def configure_logging(verbose: Verbose | int = ()) -> None:
104
+ """Map repeated ``-v`` flags onto log levels.
105
+
106
+ Args:
107
+ verbose: The flags as they were given, or how many there
108
+ were. Both, because the commands hand over what they
109
+ were passed and a count reads better everywhere else.
110
+ """
111
+ count = verbose if isinstance(verbose, int) else len(verbose)
112
+ if count <= 0:
113
+ level = logging.WARNING
114
+ elif count == 1:
115
+ level = logging.INFO
116
+ elif count == 2:
117
+ level = logging.DEBUG
118
+ else:
119
+ level = TRACE
120
+
121
+ setup_logging(
122
+ level=level,
123
+ style="rich" if sys.stderr.isatty() else "plain",
124
+ # bleak is noisy enough to bury our own lines, and is only interesting
125
+ # once someone is debugging the radio itself.
126
+ quiet_loggers=("bleak", "asyncio") if count < 3 else (),
127
+ )
pb_cli/banner.py ADDED
@@ -0,0 +1,338 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The status line along the bottom of an interactive session.
5
+
6
+ Pybricks Code keeps a bar at the foot of its window naming the hub and showing
7
+ its battery, and it is genuinely useful: what a user needs to know while typing
8
+ at a hub is which hub they are typing at, whether anything is listening, and
9
+ whether the battery is about to end the conversation. None of that belongs in
10
+ the scrolling text, where it would be pushed away by the next line of output.
11
+
12
+ **The line is pinned with a scrolling margin** -- ``DECSTBM``, which every
13
+ terminal in use here supports, conhost included. With the margin set to every
14
+ row but the last, the hub's output scrolls in the region above and the bottom
15
+ row is simply never touched by it. The hub's own line editing then behaves
16
+ exactly as it would with no status line at all, which matters more than it
17
+ sounds: the interpreter moves the cursor about as it echoes, and any scheme
18
+ that redrew the bar around that editing would fight it on every keystroke.
19
+
20
+ The alternate screen buffer was the other candidate and is what a full-screen
21
+ program would normally use. It is not used here because leaving it takes the
22
+ whole session with it, and the last thing printed at a REPL is usually the
23
+ thing you wanted to keep.
24
+
25
+ **Nothing here draws with rich.** In raw mode a bare newline does not return
26
+ the carriage, so every line this module writes ends with a carriage return of
27
+ its own, and a library that emits its own newlines would staircase down the
28
+ screen instead.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from collections.abc import Mapping
35
+ from dataclasses import dataclass
36
+ from typing import Final
37
+
38
+ import pb_client
39
+ from pb_cli import escape
40
+ from pb_cli.console import Console
41
+
42
+ __all__ = ["NOTICE_SECONDS", "WARNINGS", "Banner", "describe_running"]
43
+
44
+ NOTICE_SECONDS: Final = 4.0
45
+ """How long a passing message stays in the status line before it fades."""
46
+
47
+ WARNINGS: Final[Mapping[str, str]] = {
48
+ "BATTERY_LOW_VOLTAGE_SHUTDOWN": "battery empty",
49
+ "BATTERY_HIGH_TEMP_SHUTDOWN": "battery too hot",
50
+ "BATTERY_LOW_VOLTAGE_WARNING": "battery low",
51
+ "BATTERY_HIGH_TEMP_WARNING": "battery hot",
52
+ "BATTERY_HIGH_CURRENT": "drawing too much",
53
+ "BLE_LOW_SIGNAL": "weak signal",
54
+ "SHUTDOWN_REQUEST": "switching off",
55
+ }
56
+ """Status flags worth a word in the bar, worst first.
57
+
58
+ Only one is shown, because the bar has one line and the worst of them is the
59
+ one that matters. Ordinary states -- connected, advertising, a program running
60
+ -- are missing on purpose: they are either uninteresting or already said.
61
+
62
+ The battery is low or it is not: the hub reports two bits about it and no
63
+ level, so there is nothing here to draw a gauge from. See "Wanted from the
64
+ firmware" in the design notes.
65
+ """
66
+
67
+ _SET_MARGIN = "\x1b[1;{bottom}r"
68
+ _RESET_MARGIN = "\x1b[r"
69
+ _SAVE_CURSOR = "\x1b7"
70
+ _RESTORE_CURSOR = "\x1b8"
71
+ _GOTO = "\x1b[{row};1H"
72
+ _CLEAR_LINE = "\x1b[2K"
73
+ _REVERSE = "\x1b[7m"
74
+ _NORMAL = "\x1b[0m"
75
+
76
+ _SEPARATOR = " "
77
+
78
+
79
+ def describe_running(status: pb_client.Status | None) -> str:
80
+ """Say what the hub is doing, in the words a user would use.
81
+
82
+ Args:
83
+ status: The hub's last report, or ``None`` if it has not made one.
84
+
85
+ Returns:
86
+ A short phrase for the status line.
87
+ """
88
+
89
+ if status is None:
90
+ return "waiting for the hub"
91
+
92
+ if status.repl_running:
93
+ return "REPL"
94
+
95
+ if status.program_running:
96
+ return "program running"
97
+
98
+ return "nothing running"
99
+
100
+
101
+ def worst_warning(status: pb_client.Status | None) -> str | None:
102
+ """Return the most serious thing the hub is complaining about.
103
+
104
+ Args:
105
+ status: The hub's last report.
106
+
107
+ Returns:
108
+ The warning to show, or ``None`` if all is well.
109
+ """
110
+
111
+ if status is None:
112
+ return None
113
+
114
+ for flag, text in WARNINGS.items():
115
+ if flag in status.names:
116
+ return text
117
+
118
+ return None
119
+
120
+
121
+ @dataclass
122
+ class _Layout:
123
+ """What the status line says, before it is fitted to the screen."""
124
+
125
+ left: str
126
+ right: str
127
+
128
+ def fit(self, columns: int) -> str:
129
+ """Lay the two halves out across the given width.
130
+
131
+ Args:
132
+ columns: How wide the terminal is.
133
+
134
+ Returns:
135
+ One line of exactly ``columns - 1`` characters. One short of the
136
+ width on purpose: writing the last cell of the last row makes some
137
+ terminals scroll, which would take the status line with it.
138
+ """
139
+ width = max(1, columns - 1)
140
+ right = self.right if len(self.left) + len(self.right) + 2 <= width else ""
141
+ room = width - len(right)
142
+ left = self.left[: max(0, room)]
143
+ return f"{left}{' ' * (room - len(left))}{right}"
144
+
145
+
146
+ class Banner:
147
+ """The status line, and the terminal region it lives outside of."""
148
+
149
+ def __init__(self, console: Console, hub: pb_client.HubInfo) -> None:
150
+ """Prepare a status line for one hub's session.
151
+
152
+ Args:
153
+ console: The terminal, already in raw mode.
154
+ hub: The hub being talked to, for the part of the line that never
155
+ changes.
156
+ """
157
+ self._console = console
158
+ self._hub = hub
159
+ self._status: pb_client.Status | None = None
160
+ self._notice: str | None = None
161
+ self._notice_until = 0.0
162
+ self._size = (0, 0)
163
+ self._drawn: str | None = None
164
+ self._active = False
165
+
166
+ # ------------------------------------------------------------- lifecycle
167
+
168
+ def __enter__(self) -> Banner:
169
+ """Reserve the bottom row and start drawing there.
170
+
171
+ Returns:
172
+ This banner.
173
+ """
174
+ columns, rows = self._console.size()
175
+ self._size = (columns, rows)
176
+ # A fresh line first, so the row about to be reserved is not the one
177
+ # the shell's prompt is sitting on.
178
+ self._console.write(b"\r\n")
179
+
180
+ if rows >= 2:
181
+ self._write(_SET_MARGIN.format(bottom=rows - 1))
182
+ self._write(_GOTO.format(row=rows - 1))
183
+
184
+ self._active = True
185
+ self.draw()
186
+ return self
187
+
188
+ def __exit__(self, *exc: object) -> bool:
189
+ """Give the bottom row back and leave the cursor on it.
190
+
191
+ Synchronous, so no cancellation can skip it: a terminal left with a
192
+ scrolling margin set is as broken as one left in raw mode, and in the
193
+ same invisible way.
194
+
195
+ Args:
196
+ *exc: The exception being propagated, if any.
197
+
198
+ Returns:
199
+ False, so nothing is swallowed.
200
+ """
201
+ self._active = False
202
+ _columns, rows = self._size
203
+ self._write(_RESET_MARGIN)
204
+
205
+ if rows >= 1:
206
+ self._write(_GOTO.format(row=rows) + _CLEAR_LINE)
207
+
208
+ return False
209
+
210
+ # ---------------------------------------------------------------- output
211
+
212
+ def write(self, data: bytes) -> None:
213
+ """Put the hub's output on the screen, above the status line.
214
+
215
+ Args:
216
+ data: The bytes from the hub, exactly as they arrived.
217
+ """
218
+ self._console.write(data)
219
+
220
+ def line(self, text: str) -> None:
221
+ """Print one of our own lines into the session.
222
+
223
+ Args:
224
+ text: What to say. A carriage return is added as well as the
225
+ newline, since output processing is off in raw mode.
226
+ """
227
+ self._console.write(text.encode("utf-8", "replace") + b"\r\n")
228
+
229
+ # ---------------------------------------------------------------- status
230
+
231
+ def update(self, status: pb_client.Status) -> None:
232
+ """Take a new status report.
233
+
234
+ Args:
235
+ status: What the hub now says about itself.
236
+ """
237
+ self._status = status
238
+ self.draw()
239
+
240
+ def notice(self, text: str) -> None:
241
+ """Say something in the status line for a few seconds.
242
+
243
+ For the things that are worth mentioning but not worth interrupting
244
+ the session's text for: an input the hub would not take, an unknown
245
+ escape command.
246
+
247
+ Args:
248
+ text: The message.
249
+ """
250
+ self._notice = text
251
+ self._notice_until = time.monotonic() + NOTICE_SECONDS
252
+ self.draw()
253
+
254
+ def tick(self) -> None:
255
+ """Redraw if anything that is not an event has changed.
256
+
257
+ Covers the two things nothing tells us about: the window being
258
+ resized, and a passing message reaching its end. Called on a timer, so
259
+ it must be cheap and must not draw when nothing has changed.
260
+ """
261
+
262
+ if self._console.size() != self._size:
263
+ self._resize()
264
+ elif self._notice is not None and time.monotonic() >= self._notice_until:
265
+ self._notice = None
266
+ self.draw()
267
+
268
+ def _resize(self) -> None:
269
+ """Move the status line to the new bottom row."""
270
+ columns, rows = self._console.size()
271
+ self._size = (columns, rows)
272
+ self._drawn = None
273
+
274
+ if rows >= 2:
275
+ self._write(_SET_MARGIN.format(bottom=rows - 1))
276
+
277
+ self.draw()
278
+
279
+ # --------------------------------------------------------------- drawing
280
+
281
+ def compose(self) -> str:
282
+ """Build the status line's text.
283
+
284
+ Returns:
285
+ The line, before it is fitted to the terminal's width.
286
+ """
287
+ parts = [self._hub.name]
288
+
289
+ if self._hub.hub_type:
290
+ parts.append(self._hub.hub_type)
291
+
292
+ parts.append(describe_running(self._status))
293
+ warning = worst_warning(self._status)
294
+ if warning:
295
+ parts.append(warning)
296
+
297
+ if self._notice:
298
+ parts.append(self._notice)
299
+
300
+ left = _SEPARATOR.join(parts)
301
+ stop = escape.describe(escape.STOP_KEY)
302
+ # A panic key nobody knows about is not a panic key, so it is on the
303
+ # line for the whole session rather than only in the opening lines,
304
+ # which have scrolled away by the time anybody wants it. It goes with
305
+ # the help, and _Layout drops both together on a narrow terminal.
306
+ right = f"{stop} {stop} stops{_SEPARATOR}{escape.describe()} h for help"
307
+ columns, _rows = self._size
308
+ return _Layout(left, right).fit(columns)
309
+
310
+ def draw(self) -> None:
311
+ """Put the status line on the screen, if it has changed."""
312
+
313
+ if not self._active:
314
+ return
315
+
316
+ _columns, rows = self._size
317
+ if rows < 2:
318
+ # No room for a line of our own. The session still works.
319
+ return
320
+
321
+ text = self.compose()
322
+ if text == self._drawn:
323
+ return
324
+
325
+ self._drawn = text
326
+ self._write(
327
+ _SAVE_CURSOR
328
+ + _GOTO.format(row=rows)
329
+ + _CLEAR_LINE
330
+ + _REVERSE
331
+ + text
332
+ + _NORMAL
333
+ + _RESTORE_CURSOR
334
+ )
335
+
336
+ def _write(self, text: str) -> None:
337
+ """Write one of our own control sequences."""
338
+ self._console.write(text.encode("ascii", "replace"))
pb_cli/cli.py ADDED
@@ -0,0 +1,123 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The ``pb`` command line.
5
+
6
+ This module is the entry point and little else. The commands live in
7
+ :mod:`pb_cli.commands`, one module per group of the help, and importing them
8
+ here is what registers them with the app -- the same arrangement the service
9
+ uses for its RPC methods.
10
+
11
+ Three rules shape the whole package. Data goes to stdout and everything else --
12
+ progress, spinners, logs, prompts -- goes to stderr, so piping a command into
13
+ another program stays clean; :mod:`pb_cli.ui` is where that is enforced. Commands
14
+ are thin: they parse arguments, call ``pb_client``, and render, with no
15
+ protocol logic anywhere in this package. And no command catches its own
16
+ failures, because a task group would have wrapped them in an ``ExceptionGroup``
17
+ anyway -- :mod:`pb_cli.errors` does that in one place, for all of them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Annotated
25
+
26
+ import cyclopts
27
+
28
+ # Imported for the side effect: importing a command module registers it with
29
+ # the app, so nothing here reads the names. Said on each line, because a
30
+ # comment is not something a linter can read.
31
+ import pb_cli.commands.hub # pyright: ignore[reportUnusedImport]
32
+ import pb_cli.commands.program # pyright: ignore[reportUnusedImport]
33
+ import pb_cli.commands.repl # pyright: ignore[reportUnusedImport]
34
+ import pb_cli.commands.service # noqa: F401 # pyright: ignore[reportUnusedImport]
35
+ from pb_cli import ui
36
+ from pb_cli.app import app
37
+ from pb_cli.config import (
38
+ KNOWN_KEYS,
39
+ ProjectConfigError,
40
+ check_project_file,
41
+ config_sources,
42
+ find_project_file,
43
+ )
44
+ from pb_cli.errors import EXIT_OK
45
+
46
+ __all__ = ["app", "launcher", "main"]
47
+
48
+
49
+ @app.meta.default
50
+ def launcher(
51
+ *tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
52
+ directory: Annotated[
53
+ Path | None,
54
+ cyclopts.Parameter(
55
+ # The long name first, because it is what names the environment
56
+ # variable: with -C first that came out as PB_C, which says
57
+ # nothing. Both spellings work either way, and -C is the one
58
+ # anybody will type.
59
+ name=["--directory", "-C"],
60
+ show_default=False,
61
+ # The same panel --help and --version are in, rather than a
62
+ # "Parameters" panel of its own above the commands. Somebody
63
+ # reading the help is looking for the commands first.
64
+ group=cyclopts.Group("Options", sort_key=3),
65
+ ),
66
+ ] = None,
67
+ ) -> int:
68
+ """Run a command, optionally as if from somewhere else.
69
+
70
+ Args:
71
+ tokens: The command to run, and its arguments.
72
+ directory: Work as if run from here, the way ``git -C`` does. Point it
73
+ at the directory holding a project's ``pyproject.toml``, or at the
74
+ root of the source when there is no project file. It settles which
75
+ project's settings apply, what a relative path means, and where a
76
+ program's imports resolve from -- which is what makes a monorepo of
77
+ several robot projects work from any shell.
78
+
79
+ Returns:
80
+ The process exit code.
81
+ """
82
+
83
+ if directory is not None:
84
+ try:
85
+ os.chdir(directory)
86
+ except OSError as exc:
87
+ # A directory that is not there is a typo, not a crash.
88
+ reason = exc.strerror or str(exc)
89
+ return ui.usage(
90
+ f"Cannot work from {directory}: {reason.lower()}.",
91
+ "Give [bold]-C[/] a directory that exists.",
92
+ )
93
+
94
+ try:
95
+ # After the change of directory and before the command runs: a project
96
+ # file that says something unrecognised should be reported rather than
97
+ # quietly not applied, and which file that is depends on -C.
98
+ check_project_file()
99
+ # Hand cyclopts the file this found, rather than letting it search on
100
+ # its own and stop at the first pyproject.toml. See find_project_file.
101
+ app.config = config_sources(find_project_file())
102
+ except ProjectConfigError as exc:
103
+ return ui.usage(
104
+ str(exc),
105
+ # The square bracket is escaped: rich reads "[tool.pb]" as a style
106
+ # tag and swallows it, which is how this line first came out as
107
+ # "Check ...toml. understands hub."
108
+ f"Check [bold]{exc.path}[/]. [bold]\\[tool.pb][/] understands "
109
+ f"{', '.join(sorted(KNOWN_KEYS))}.",
110
+ )
111
+
112
+ result = app(tokens)
113
+ return result if isinstance(result, int) else EXIT_OK
114
+
115
+
116
+ def main() -> int:
117
+ """Run the CLI.
118
+
119
+ Returns:
120
+ The process exit code.
121
+ """
122
+ result = app.meta()
123
+ return result if isinstance(result, int) else EXIT_OK
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """The commands ``pb`` offers, grouped the way its help groups them.
5
+
6
+ One module per group -- :mod:`pb_cli.commands.hub` for finding and holding a hub,
7
+ :mod:`pb_cli.commands.program` for getting programs onto it, :mod:`pb_cli.commands.repl`
8
+ for talking to it directly, and :mod:`pb_cli.commands.service` for the thing that
9
+ keeps the connection open.
10
+
11
+ Importing a module is what registers its commands with the application in
12
+ :mod:`pb_cli.app`, so :mod:`pb_cli.cli` imports them all and there is no second list of
13
+ commands to keep in step with this one.
14
+ """
15
+
16
+ from __future__ import annotations