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/errors.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Turning a failure into something the user can act on.
|
|
5
|
+
|
|
6
|
+
There are two kinds of failure and they are presented differently.
|
|
7
|
+
|
|
8
|
+
An **expected** failure -- the hub is switched off, nothing is connected yet,
|
|
9
|
+
the program is too big -- is something the user can do something about. It is
|
|
10
|
+
reported as one plain sentence saying what happened, plus, where there is one, a
|
|
11
|
+
suggestion of how to fix it. A traceback would only bury the sentence that
|
|
12
|
+
matters. The wording follows Pybricks Code, which pairs every alert with a
|
|
13
|
+
``suggestion`` for the same reason; where it has already phrased one of these
|
|
14
|
+
well, its phrasing is reused so that the two tools tell users the same thing.
|
|
15
|
+
|
|
16
|
+
An **unexpected** failure is a bug in this tool. Nothing is rewritten and
|
|
17
|
+
nothing is swallowed: the original exception is re-raised and Python prints the
|
|
18
|
+
whole traceback, which is the most useful thing that can be shown, both to the
|
|
19
|
+
user and to whoever reads the bug report afterwards.
|
|
20
|
+
|
|
21
|
+
The reason this module has to exist at all is ``ExceptionGroup``. A task group
|
|
22
|
+
wraps whatever its body raised, so ``except JsonRpcError`` around an
|
|
23
|
+
``async with pb_client.connect()`` block quietly stops matching -- the error
|
|
24
|
+
arrives as a group of one. Everything here therefore starts by flattening.
|
|
25
|
+
|
|
26
|
+
Dispatch is keyed on the JSON-RPC error *code*, never on message text, so the
|
|
27
|
+
service's wording and the wording here stay independently editable and rephrasing
|
|
28
|
+
a service message cannot silently turn a friendly error back into a traceback.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import logging
|
|
35
|
+
import sys
|
|
36
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import TypeVar, TypeVarTuple, cast
|
|
39
|
+
|
|
40
|
+
import anyio
|
|
41
|
+
from rich.console import Console
|
|
42
|
+
|
|
43
|
+
import pb_client
|
|
44
|
+
from pb_core.ipc import ENDPOINT_ENV_VAR, EndpointError
|
|
45
|
+
from pb_core.jsonrpc import ErrorCode, JsonRpcError, PeerClosedError
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"EXIT_ERROR",
|
|
51
|
+
"EXIT_HUB_ERROR",
|
|
52
|
+
"EXIT_INTERRUPTED",
|
|
53
|
+
"EXIT_NOTHING_CONNECTED",
|
|
54
|
+
"EXIT_OK",
|
|
55
|
+
"PERMISSION_NAME",
|
|
56
|
+
"Problem",
|
|
57
|
+
"bluetooth_advice",
|
|
58
|
+
"explain",
|
|
59
|
+
"find",
|
|
60
|
+
"flatten",
|
|
61
|
+
"render",
|
|
62
|
+
"run",
|
|
63
|
+
"serial_port_advice",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
EXIT_OK = 0
|
|
67
|
+
EXIT_ERROR = 1
|
|
68
|
+
EXIT_HUB_ERROR = 3
|
|
69
|
+
EXIT_NOTHING_CONNECTED = 4
|
|
70
|
+
EXIT_INTERRUPTED = 130
|
|
71
|
+
|
|
72
|
+
PosArgsT = TypeVarTuple("PosArgsT")
|
|
73
|
+
ExcT = TypeVar("ExcT", bound=BaseException)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Problem:
|
|
78
|
+
"""One failure, as the user should see it."""
|
|
79
|
+
|
|
80
|
+
message: str
|
|
81
|
+
"""What went wrong, as a sentence."""
|
|
82
|
+
|
|
83
|
+
hint: str | None = None
|
|
84
|
+
"""How to fix it, if there is anything useful to say."""
|
|
85
|
+
|
|
86
|
+
detail: str = ""
|
|
87
|
+
"""Verbatim output to reproduce underneath, such as compiler diagnostics."""
|
|
88
|
+
|
|
89
|
+
exit_code: int = EXIT_ERROR
|
|
90
|
+
"""What the process should exit with."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class _Rule:
|
|
95
|
+
"""How to present one JSON-RPC error code."""
|
|
96
|
+
|
|
97
|
+
hint: str | None = None
|
|
98
|
+
exit_code: int = EXIT_HUB_ERROR
|
|
99
|
+
message: str | None = None
|
|
100
|
+
"""Replaces the service's own wording. ``None`` keeps it."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_STALE = (
|
|
104
|
+
"The background service is left over from an older install. "
|
|
105
|
+
"Run [bold]pb service stop[/] and try again."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_OLD_CLIENT = (
|
|
109
|
+
"Upgrade [bold]pb[/], or run [bold]pb service stop[/] to replace the "
|
|
110
|
+
"service with one this version can talk to."
|
|
111
|
+
)
|
|
112
|
+
"""What to suggest when it is this copy of pb that is behind.
|
|
113
|
+
|
|
114
|
+
The service outlives the install that started it, so it is usually the
|
|
115
|
+
older of the two -- but not always, and telling somebody to stop the
|
|
116
|
+
service when their own pb is the old half sends them somewhere that
|
|
117
|
+
cannot help.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def serial_port_advice(platform: str = sys.platform) -> str:
|
|
122
|
+
"""Say how to get at a serial port that would not open.
|
|
123
|
+
|
|
124
|
+
The same refusal means quite different things on different systems, and the
|
|
125
|
+
difference is what decides the advice, so this is one suggestion per
|
|
126
|
+
platform rather than one covering every case at once.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
platform: What to advise for. An argument, defaulted, so that the
|
|
130
|
+
wording a user on another platform will see can be read back here
|
|
131
|
+
rather than only on that platform.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
What to suggest.
|
|
135
|
+
"""
|
|
136
|
+
if platform == "win32":
|
|
137
|
+
# Windows has no permissions to grant on a COM port: EACCES here is
|
|
138
|
+
# almost always another program holding it open.
|
|
139
|
+
return (
|
|
140
|
+
"Close anything else using the hub -- Pybricks Code, a serial "
|
|
141
|
+
"terminal -- and try again."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if platform == "darwin":
|
|
145
|
+
return "Unplug the hub and plug it in again, then try again."
|
|
146
|
+
|
|
147
|
+
# Linux, and near enough for the BSDs: the device node belongs to a group,
|
|
148
|
+
# and the fix is joining it. Stopping the service matters and is easy to
|
|
149
|
+
# miss -- a service already running keeps the groups it started with, so
|
|
150
|
+
# the hub stays out of reach until it is replaced, however many times the
|
|
151
|
+
# user logs out and back in.
|
|
152
|
+
return (
|
|
153
|
+
"Add yourself to the [bold]dialout[/] group with [bold]sudo usermod "
|
|
154
|
+
"-a -G dialout $USER[/], then log out and back in. If you have just "
|
|
155
|
+
"done that, run [bold]pb service stop[/] as well."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
PERMISSION_NAME = "Pybricks RFCOMM Service"
|
|
160
|
+
"""What macOS calls this program when it asks about Bluetooth.
|
|
161
|
+
|
|
162
|
+
pb-service's ``PERMISSION_NAME``, which it passes to aio-rfcomm's
|
|
163
|
+
``prompt_under_name()``, and so the one in the dialog and under Privacy &
|
|
164
|
+
Security. Repeated rather than imported, so that explaining an error does not
|
|
165
|
+
load the service; a test holds the two together.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
_BLUETOOTH = (
|
|
169
|
+
"Switch Bluetooth on, or plug in an adapter, and check that this "
|
|
170
|
+
"computer lets pb use Bluetooth."
|
|
171
|
+
)
|
|
172
|
+
"""What to suggest when the service could not say why Bluetooth is unusable."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def bluetooth_advice(availability: str | None, platform: str = sys.platform) -> str:
|
|
176
|
+
"""Say what to do about Bluetooth that cannot be used.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
availability: Why, as the service names it -- one word per member of
|
|
180
|
+
aio-rfcomm's ``Availability`` -- or ``None`` if it did not say.
|
|
181
|
+
platform: What to advise for. An argument for the same reason as in
|
|
182
|
+
:func:`serial_port_advice`.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
What to suggest.
|
|
186
|
+
"""
|
|
187
|
+
mac = platform == "darwin"
|
|
188
|
+
|
|
189
|
+
match availability:
|
|
190
|
+
case "switched_off":
|
|
191
|
+
return "Switch Bluetooth on, then try again."
|
|
192
|
+
case "no_adapter":
|
|
193
|
+
return "Plug in a Bluetooth adapter, then try again."
|
|
194
|
+
case "awaiting_permission":
|
|
195
|
+
# Only once the service has given up waiting: until then the CLI
|
|
196
|
+
# says this as it happens, and carries on when it is answered.
|
|
197
|
+
what = "the dialog" if mac else "the system's question"
|
|
198
|
+
return (
|
|
199
|
+
f"Answer {what} asking whether [bold]{PERMISSION_NAME}[/] may "
|
|
200
|
+
"use Bluetooth, then try again."
|
|
201
|
+
)
|
|
202
|
+
case "not_allowed" if mac:
|
|
203
|
+
# Conditional, because a profile or parental controls refuse it
|
|
204
|
+
# in the same words, and those are not the user's to undo.
|
|
205
|
+
return (
|
|
206
|
+
f"If you refused it for [bold]{PERMISSION_NAME}[/], allow it "
|
|
207
|
+
"in System Settings under Privacy & Security > Bluetooth, "
|
|
208
|
+
"then try again."
|
|
209
|
+
)
|
|
210
|
+
case "not_allowed":
|
|
211
|
+
return "Check that this computer lets pb use Bluetooth."
|
|
212
|
+
case "service_unavailable":
|
|
213
|
+
return (
|
|
214
|
+
"The system's Bluetooth service usually restarts by itself. "
|
|
215
|
+
"Wait a moment, then try again."
|
|
216
|
+
)
|
|
217
|
+
case "unsupported":
|
|
218
|
+
return "If the hub has a USB port, connect it with a cable instead."
|
|
219
|
+
case _:
|
|
220
|
+
return _BLUETOOTH
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_RULES: Mapping[int, _Rule] = {
|
|
224
|
+
# Hubs. The service's own wording is specific -- "no connected hub named
|
|
225
|
+
# 'Spare Hub'" -- so it is usually kept as the headline and only the
|
|
226
|
+
# suggestion is added here.
|
|
227
|
+
ErrorCode.HUB_NOT_FOUND: _Rule(
|
|
228
|
+
"Run [bold]pb connect[/] first, or [bold]pb list[/] to see what is connected."
|
|
229
|
+
),
|
|
230
|
+
ErrorCode.HUB_NOT_CONNECTED: _Rule(
|
|
231
|
+
"Check the hub is switched on and in range, then try again."
|
|
232
|
+
),
|
|
233
|
+
ErrorCode.HUB_ALREADY_CONNECTED: _Rule(
|
|
234
|
+
"It is connected already -- [bold]pb list[/] shows it."
|
|
235
|
+
),
|
|
236
|
+
ErrorCode.HUB_DISCONNECTED: _Rule(
|
|
237
|
+
"Run [bold]pb connect[/] to reconnect, then try again.",
|
|
238
|
+
message="The hub is no longer connected.",
|
|
239
|
+
),
|
|
240
|
+
ErrorCode.HUB_BUSY: _Rule(
|
|
241
|
+
"Wait a moment, or stop what it is doing with [bold]pb stop[/]."
|
|
242
|
+
),
|
|
243
|
+
ErrorCode.HUB_INVALID_COMMAND: _Rule(
|
|
244
|
+
"If the hub's firmware is old, installing the latest version from "
|
|
245
|
+
"code.pybricks.com may help."
|
|
246
|
+
),
|
|
247
|
+
ErrorCode.HUB_UNSUPPORTED: _Rule(
|
|
248
|
+
"This needs newer hub firmware. Install the latest version from "
|
|
249
|
+
"code.pybricks.com."
|
|
250
|
+
),
|
|
251
|
+
# Programs.
|
|
252
|
+
ErrorCode.PROGRAM_SLOT_NOT_SELECTED: _Rule(
|
|
253
|
+
"The hub chooses the slot, with the buttons on the hub itself. Select "
|
|
254
|
+
"it there, or leave [bold]--slot[/] out to accept the one it has.",
|
|
255
|
+
),
|
|
256
|
+
# A hub with one slot has no buttons to choose it with, so the suggestion
|
|
257
|
+
# above would send the user looking for something that is not there.
|
|
258
|
+
ErrorCode.PROGRAM_TOO_LARGE: _Rule(
|
|
259
|
+
"Try removing unused code, or making names and strings smaller."
|
|
260
|
+
),
|
|
261
|
+
ErrorCode.COMPILE_FAILED: _Rule(
|
|
262
|
+
"Fix the error the compiler reported and try again.",
|
|
263
|
+
exit_code=EXIT_ERROR,
|
|
264
|
+
),
|
|
265
|
+
ErrorCode.POWER_BUTTON_PRESSED: _Rule(
|
|
266
|
+
"You may release the button on the hub now.",
|
|
267
|
+
message="The hub's button interrupted this.",
|
|
268
|
+
),
|
|
269
|
+
# Transport and lifecycle.
|
|
270
|
+
# The service repeats what the system said, which is what tells a radio
|
|
271
|
+
# that is switched off from one this program may not use, and names it in
|
|
272
|
+
# a word, which is what chooses the suggestion (see bluetooth_advice).
|
|
273
|
+
ErrorCode.BLUETOOTH_UNAVAILABLE: _Rule(_BLUETOOTH, exit_code=EXIT_ERROR),
|
|
274
|
+
ErrorCode.SCAN_TIMEOUT: _Rule(
|
|
275
|
+
"Switch the hub on and press its button so that its light flashes."
|
|
276
|
+
),
|
|
277
|
+
# The service names the port and repeats what the operating system said,
|
|
278
|
+
# which is the half of this a user cannot guess; the suggestion is the
|
|
279
|
+
# other half, and it is the only one here that depends on the platform.
|
|
280
|
+
ErrorCode.SERIAL_PORT_UNAVAILABLE: _Rule(
|
|
281
|
+
serial_port_advice(), exit_code=EXIT_ERROR
|
|
282
|
+
),
|
|
283
|
+
ErrorCode.REQUEST_CANCELLED: _Rule(exit_code=EXIT_ERROR, message="Cancelled."),
|
|
284
|
+
ErrorCode.SERVICE_SHUTTING_DOWN: _Rule(
|
|
285
|
+
"Try again in a moment.",
|
|
286
|
+
exit_code=EXIT_ERROR,
|
|
287
|
+
message="The background service is shutting down.",
|
|
288
|
+
),
|
|
289
|
+
# A stale service is the one failure a user cannot guess their way out of,
|
|
290
|
+
# and it is likely whenever the tools are upgraded while a hub is being
|
|
291
|
+
# held open, so all four of its shapes name the same fix.
|
|
292
|
+
ErrorCode.NOT_AUTHENTICATED: _Rule(
|
|
293
|
+
_STALE,
|
|
294
|
+
exit_code=EXIT_ERROR,
|
|
295
|
+
message="Could not sign in to the background service.",
|
|
296
|
+
),
|
|
297
|
+
ErrorCode.HELLO_REQUIRED: _Rule(_STALE, exit_code=EXIT_ERROR),
|
|
298
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION: _Rule(_STALE, exit_code=EXIT_ERROR),
|
|
299
|
+
ErrorCode.METHOD_NOT_FOUND: _Rule(
|
|
300
|
+
_STALE,
|
|
301
|
+
exit_code=EXIT_ERROR,
|
|
302
|
+
message="The background service does not know that command.",
|
|
303
|
+
),
|
|
304
|
+
# PARSE_ERROR, INVALID_REQUEST and INVALID_PARAMS are deliberately absent:
|
|
305
|
+
# each means this CLI sent the service something it could not use, which no
|
|
306
|
+
# user can act on and every developer wants a traceback for.
|
|
307
|
+
ErrorCode.INTERNAL_ERROR: _Rule(
|
|
308
|
+
"The service log has the details; [bold]pb service status[/] names the file.",
|
|
309
|
+
exit_code=EXIT_ERROR,
|
|
310
|
+
message="The background service hit an unexpected problem.",
|
|
311
|
+
),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_LOST_CONNECTION = (
|
|
315
|
+
anyio.BrokenResourceError,
|
|
316
|
+
anyio.ClosedResourceError,
|
|
317
|
+
anyio.EndOfStream,
|
|
318
|
+
PeerClosedError,
|
|
319
|
+
)
|
|
320
|
+
"""What a service that died mid-call looks like from here.
|
|
321
|
+
|
|
322
|
+
``PeerClosedError`` is the most literal of them: the connection ended with a
|
|
323
|
+
request still waiting for its answer. It belongs here rather than among the
|
|
324
|
+
unrecognised, where it printed a traceback under "this looks like a bug in pb"
|
|
325
|
+
-- which is the wrong thing to say about a service that simply went away.
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
Recognised, but usually only the wake of a more informative failure, so it is
|
|
329
|
+
dropped whenever anything else survived the flattening.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
_CANCELLED = asyncio.CancelledError
|
|
333
|
+
"""Cancellation, as the asyncio backend spells it.
|
|
334
|
+
|
|
335
|
+
Deliberately not ``anyio.get_cancelled_exc_class()``, which needs a running
|
|
336
|
+
event loop to answer: by the time a failure is being explained, ``anyio.run``
|
|
337
|
+
has already returned and asking would itself raise. The repository pins the
|
|
338
|
+
asyncio backend, so the answer is known statically anyway.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
_BUG = (
|
|
342
|
+
"This looks like a bug in pb. Please report it, including everything below "
|
|
343
|
+
"and what you were doing at the time."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def flatten(exc: BaseException) -> list[BaseException]:
|
|
348
|
+
"""Return the leaves of a possibly nested exception group.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
exc: An exception, which may be an :exc:`ExceptionGroup`.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Every non-group exception it contains, in order. A plain exception
|
|
355
|
+
yields itself.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
if not isinstance(exc, BaseExceptionGroup):
|
|
359
|
+
return [exc]
|
|
360
|
+
|
|
361
|
+
# The cast is only about generics: isinstance cannot tell pyright what a
|
|
362
|
+
# group contains, and the answer is always "exceptions".
|
|
363
|
+
group = cast("BaseExceptionGroup[BaseException]", exc)
|
|
364
|
+
return [leaf for child in group.exceptions for leaf in flatten(child)]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def find(exc: BaseException, kind: type[ExcT]) -> ExcT | None:
|
|
368
|
+
"""Return the first leaf of ``exc`` that is an instance of ``kind``.
|
|
369
|
+
|
|
370
|
+
Commands use this when they want to treat one particular failure as a
|
|
371
|
+
normal outcome. Catching the exception type alone is not enough, because a
|
|
372
|
+
task group will have wrapped it in a group.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
exc: The exception to look inside.
|
|
376
|
+
kind: The type being looked for.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The matching exception, or ``None``.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
for leaf in flatten(exc):
|
|
383
|
+
if isinstance(leaf, kind):
|
|
384
|
+
return leaf
|
|
385
|
+
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _sentence(text: str) -> str:
|
|
390
|
+
"""Render a wire message as a sentence."""
|
|
391
|
+
text = text.strip()
|
|
392
|
+
if not text:
|
|
393
|
+
return "Something went wrong."
|
|
394
|
+
|
|
395
|
+
text = text[0].upper() + text[1:]
|
|
396
|
+
return text if text[-1] in ".!?" else text + "."
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _too_large(exc: JsonRpcError) -> str:
|
|
400
|
+
"""Say how much too big a program is, following Pybricks Code's wording."""
|
|
401
|
+
payload: object = exc.data
|
|
402
|
+
if not isinstance(payload, Mapping):
|
|
403
|
+
return _sentence(exc.message)
|
|
404
|
+
|
|
405
|
+
# Cast the original rather than the narrowed local: isinstance can say
|
|
406
|
+
# that it is a mapping, but not of what.
|
|
407
|
+
data = cast("Mapping[str, object]", exc.data)
|
|
408
|
+
size, limit = data.get("size"), data.get("limit")
|
|
409
|
+
if not isinstance(size, int) or not isinstance(limit, int):
|
|
410
|
+
return _sentence(exc.message)
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
f"This program is too big. Compiled size is {size} bytes "
|
|
414
|
+
f"but the hub can only fit {limit} bytes."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
_ONE_SLOT = "Leave [bold]--slot[/] out, or ask for slot 0."
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _slot_hint(exc: JsonRpcError, default: str | None) -> str | None:
|
|
422
|
+
"""Suggest what to do about a slot, which depends on how many there are.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
exc: The error the service sent.
|
|
426
|
+
default: The suggestion for a hub with slots to choose between.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The suggestion to print.
|
|
430
|
+
"""
|
|
431
|
+
payload: object = exc.data
|
|
432
|
+
if not isinstance(payload, Mapping):
|
|
433
|
+
return default
|
|
434
|
+
|
|
435
|
+
data = cast("Mapping[str, object]", exc.data)
|
|
436
|
+
return _ONE_SLOT if data.get("slots") == 1 else default
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _bluetooth_hint(exc: JsonRpcError) -> str:
|
|
440
|
+
"""Suggest what to do about Bluetooth, by the reason the service gave.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
exc: The error the service sent.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
The suggestion to print.
|
|
447
|
+
"""
|
|
448
|
+
payload: object = exc.data
|
|
449
|
+
if not isinstance(payload, Mapping):
|
|
450
|
+
return bluetooth_advice(None)
|
|
451
|
+
|
|
452
|
+
data = cast("Mapping[str, object]", payload)
|
|
453
|
+
availability = data.get("availability")
|
|
454
|
+
return bluetooth_advice(availability if isinstance(availability, str) else None)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _describe_rpc(exc: JsonRpcError, hints: Mapping[int, str] | None) -> Problem | None:
|
|
458
|
+
"""Present an error the service reported, or ``None`` if the code is unknown."""
|
|
459
|
+
rule = _RULES.get(exc.code)
|
|
460
|
+
if rule is None:
|
|
461
|
+
# An unknown code means the service and this CLI disagree about the
|
|
462
|
+
# protocol, which is a bug rather than something to paper over.
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
if exc.code == ErrorCode.PROGRAM_TOO_LARGE:
|
|
466
|
+
message = _too_large(exc)
|
|
467
|
+
else:
|
|
468
|
+
message = rule.message or _sentence(exc.message)
|
|
469
|
+
|
|
470
|
+
hint = rule.hint if hints is None else hints.get(exc.code, rule.hint)
|
|
471
|
+
|
|
472
|
+
if exc.code == ErrorCode.PROGRAM_SLOT_NOT_SELECTED:
|
|
473
|
+
hint = _slot_hint(exc, hint)
|
|
474
|
+
|
|
475
|
+
if exc.code == ErrorCode.BLUETOOTH_UNAVAILABLE:
|
|
476
|
+
hint = _bluetooth_hint(exc)
|
|
477
|
+
|
|
478
|
+
if exc.code == ErrorCode.UNSUPPORTED_PROTOCOL_VERSION:
|
|
479
|
+
message, hint = _version_mismatch(exc, message, hint)
|
|
480
|
+
|
|
481
|
+
return Problem(message=message, hint=hint, exit_code=rule.exit_code)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _version_mismatch(
|
|
485
|
+
exc: JsonRpcError, message: str, hint: str | None
|
|
486
|
+
) -> tuple[str, str | None]:
|
|
487
|
+
"""Say which of the two halves is the old one.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
exc: The error the service sent.
|
|
491
|
+
message: The wording to fall back on.
|
|
492
|
+
hint: The suggestion to fall back on.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
What to print. A service too old to send the numbers is itself
|
|
496
|
+
the answer to the question, so the fallback -- which blames the
|
|
497
|
+
service -- is right in exactly the case where nothing can be
|
|
498
|
+
worked out.
|
|
499
|
+
"""
|
|
500
|
+
payload: object = exc.data
|
|
501
|
+
if not isinstance(payload, Mapping):
|
|
502
|
+
return message, hint
|
|
503
|
+
|
|
504
|
+
data = cast("Mapping[str, object]", payload)
|
|
505
|
+
client = data.get("client")
|
|
506
|
+
lowest = data.get("min")
|
|
507
|
+
highest = data.get("max")
|
|
508
|
+
if not (
|
|
509
|
+
isinstance(client, int) and isinstance(lowest, int) and isinstance(highest, int)
|
|
510
|
+
):
|
|
511
|
+
return message, hint
|
|
512
|
+
|
|
513
|
+
served = f"{lowest}" if lowest == highest else f"{lowest} to {highest}"
|
|
514
|
+
# No markup: a headline is printed literally, because it can carry a name
|
|
515
|
+
# or a path that somebody wrote square brackets into.
|
|
516
|
+
said = (
|
|
517
|
+
f"This pb speaks protocol {client} and the background service speaks {served}."
|
|
518
|
+
)
|
|
519
|
+
return said, (_OLD_CLIENT if client < lowest else _STALE)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _describe(exc: BaseException, hints: Mapping[int, str] | None) -> Problem | None:
|
|
523
|
+
"""Present one leaf exception, or ``None`` if it is not one we know."""
|
|
524
|
+
|
|
525
|
+
if isinstance(exc, JsonRpcError):
|
|
526
|
+
return _describe_rpc(exc, hints)
|
|
527
|
+
|
|
528
|
+
if isinstance(exc, pb_client.CompileError):
|
|
529
|
+
# The compiler's own diagnostics name the offending line, which is a
|
|
530
|
+
# better suggestion than anything that could be written here.
|
|
531
|
+
return Problem(_sentence(str(exc)), detail=exc.output.rstrip())
|
|
532
|
+
|
|
533
|
+
if isinstance(exc, pb_client.ServiceUnavailable):
|
|
534
|
+
return Problem(
|
|
535
|
+
"No hub is connected.",
|
|
536
|
+
"Run [bold]pb connect[/] first.",
|
|
537
|
+
exit_code=EXIT_NOTHING_CONNECTED,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if isinstance(exc, pb_client.ServiceStartError):
|
|
541
|
+
return Problem(
|
|
542
|
+
"The background service would not start.",
|
|
543
|
+
f"Look in {exc.log_file} for what went wrong.",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
if isinstance(exc, _LOST_CONNECTION):
|
|
547
|
+
return Problem(
|
|
548
|
+
"Lost the connection to the background service.",
|
|
549
|
+
"Run [bold]pb service status[/] to check on it, then try again.",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if isinstance(exc, EndpointError):
|
|
553
|
+
return Problem(
|
|
554
|
+
_sentence(str(exc)),
|
|
555
|
+
f"{ENDPOINT_ENV_VAR} should look like tcp://127.0.0.1:47823, "
|
|
556
|
+
"unix:///path/to/socket or pipe://name. Unset it to use the "
|
|
557
|
+
"default.",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
561
|
+
return Problem("Interrupted.", exit_code=EXIT_INTERRUPTED)
|
|
562
|
+
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def explain(
|
|
567
|
+
exc: BaseException, hints: Mapping[int, str] | None = None
|
|
568
|
+
) -> list[Problem] | None:
|
|
569
|
+
"""Describe a failure for the user.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
exc: What was raised, possibly an exception group.
|
|
573
|
+
hints: Suggestions replacing the default one, for particular JSON-RPC
|
|
574
|
+
error codes. A command knows something the table cannot: which fix
|
|
575
|
+
to suggest for a hub that was not found depends on whether the user
|
|
576
|
+
was trying to connect to one or to use one already connected.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
One :class:`Problem` per distinct failure, or ``None`` if any of them
|
|
580
|
+
is unrecognised -- in which case the caller must re-raise, so that the
|
|
581
|
+
traceback is not lost.
|
|
582
|
+
"""
|
|
583
|
+
# Cancellation is control flow rather than failure: a task group cancels
|
|
584
|
+
# its remaining children as part of unwinding, so these are always noise.
|
|
585
|
+
leaves = [leaf for leaf in flatten(exc) if not isinstance(leaf, _CANCELLED)]
|
|
586
|
+
if not leaves:
|
|
587
|
+
# Noise all the way down, which means the whole command was cancelled
|
|
588
|
+
# rather than any part of it failing -- Ctrl-C, in a command that was
|
|
589
|
+
# inside a task group at the time. The interrupt reaches the top as
|
|
590
|
+
# cancellation rather than as KeyboardInterrupt, so without this it
|
|
591
|
+
# was reported as a bug in pb, under a message asking the user to
|
|
592
|
+
# report "everything below" with nothing below it to report.
|
|
593
|
+
return [Problem("Interrupted.", exit_code=EXIT_INTERRUPTED)]
|
|
594
|
+
|
|
595
|
+
interesting = [leaf for leaf in leaves if not isinstance(leaf, _LOST_CONNECTION)]
|
|
596
|
+
leaves = interesting or leaves
|
|
597
|
+
|
|
598
|
+
problems: list[Problem] = []
|
|
599
|
+
seen: set[tuple[type[BaseException], str]] = set()
|
|
600
|
+
|
|
601
|
+
for leaf in leaves:
|
|
602
|
+
# Task groups routinely produce several copies of one failure, one for
|
|
603
|
+
# each task that noticed it.
|
|
604
|
+
key = (type(leaf), str(leaf))
|
|
605
|
+
if key in seen:
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
seen.add(key)
|
|
609
|
+
problem = _describe(leaf, hints)
|
|
610
|
+
if problem is None:
|
|
611
|
+
logger.debug("no friendly wording for %r", leaf)
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
problems.append(problem)
|
|
615
|
+
|
|
616
|
+
return problems
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def render(err: Console, problems: list[Problem]) -> int:
|
|
620
|
+
"""Print problems to stderr and return the exit code to use.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
err: A console writing to stderr. Nothing about a failure belongs on
|
|
624
|
+
stdout, which is reserved for data.
|
|
625
|
+
problems: What to print, most important first.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
The exit code of the first problem.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
for problem in problems:
|
|
632
|
+
# Messages can carry text from the service or a file path, so markup is
|
|
633
|
+
# off for them; suggestions are our own literals and may use it.
|
|
634
|
+
err.print(problem.message, style="red", markup=False, highlight=False)
|
|
635
|
+
|
|
636
|
+
if problem.detail:
|
|
637
|
+
err.print(problem.detail, markup=False, highlight=False)
|
|
638
|
+
|
|
639
|
+
if problem.hint:
|
|
640
|
+
err.print(problem.hint, style="dim")
|
|
641
|
+
|
|
642
|
+
return problems[0].exit_code
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def run(
|
|
646
|
+
func: Callable[[*PosArgsT], Awaitable[int]],
|
|
647
|
+
*args: *PosArgsT,
|
|
648
|
+
hints: Mapping[int, str] | None = None,
|
|
649
|
+
) -> int:
|
|
650
|
+
"""Run a command body, reporting expected failures kindly.
|
|
651
|
+
|
|
652
|
+
This is where every command meets the outside world, and the only place a
|
|
653
|
+
broad ``except`` belongs: anything :func:`explain` does not recognise is
|
|
654
|
+
re-raised untouched, so that Python prints its traceback.
|
|
655
|
+
|
|
656
|
+
Async, and it starts no event loop of its own. Cyclopts runs an ``async
|
|
657
|
+
def`` command itself, so the whole program enters its loop exactly once, at
|
|
658
|
+
the outermost edge. A second entry point here would be a second boundary,
|
|
659
|
+
and every sync-to-async boundary turns up later as a helper thread, a
|
|
660
|
+
blocking portal, or a test that cannot be written the obvious way.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
func: The async body of the command.
|
|
664
|
+
*args: Passed to ``func``.
|
|
665
|
+
hints: Per-code suggestions, as for :func:`explain`.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
The process exit code.
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
return await func(*args)
|
|
673
|
+
except BaseException as exc:
|
|
674
|
+
problems = explain(exc, hints)
|
|
675
|
+
if problems is None:
|
|
676
|
+
logger.debug("unhandled error", exc_info=exc)
|
|
677
|
+
Console(stderr=True).print(_BUG, style="yellow")
|
|
678
|
+
raise
|
|
679
|
+
|
|
680
|
+
return render(Console(stderr=True), problems)
|