pybricks-cli 1.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pb_cli/__init__.py +8 -0
- pb_cli/__main__.py +7 -0
- pb_cli/app.py +127 -0
- pb_cli/banner.py +338 -0
- pb_cli/cli.py +123 -0
- pb_cli/commands/__init__.py +16 -0
- pb_cli/commands/hub.py +637 -0
- pb_cli/commands/program.py +374 -0
- pb_cli/commands/repl.py +146 -0
- pb_cli/commands/service.py +369 -0
- pb_cli/config.py +213 -0
- pb_cli/console/__init__.py +117 -0
- pb_cli/console/posix.py +125 -0
- pb_cli/console/windows.py +586 -0
- pb_cli/errors.py +680 -0
- pb_cli/escape.py +258 -0
- pb_cli/keys.py +332 -0
- pb_cli/picker.py +124 -0
- pb_cli/py.typed +0 -0
- pb_cli/session.py +813 -0
- pb_cli/ui.py +454 -0
- pybricks_cli-1.0.0b1.dist-info/METADATA +59 -0
- pybricks_cli-1.0.0b1.dist-info/RECORD +26 -0
- pybricks_cli-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_cli-1.0.0b1.dist-info/entry_points.txt +3 -0
- pybricks_cli-1.0.0b1.dist-info/licenses/LICENSE +21 -0
pb_cli/__init__.py
ADDED
pb_cli/__main__.py
ADDED
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
|