pybricks-cli 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Getting a program onto a hub and controlling it once it is there.
5
+
6
+ Compiling happens here rather than in the service, because the script is on
7
+ this machine's filesystem and the service may not be.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+ from typing import Annotated, Final
16
+
17
+ import cyclopts
18
+
19
+ import pb_client
20
+ from pb_cli import errors, session, ui
21
+ from pb_cli.app import PROGRAM_COMMANDS, Verbose, app, configure_logging
22
+ from pb_cli.errors import EXIT_OK
23
+ from pb_core import project
24
+ from pb_core.jsonrpc import ErrorCode
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ __all__ = ["check_cmd", "download_cmd", "run_cmd", "start_cmd", "stop_cmd"]
29
+
30
+ _START_HINTS: Final[Mapping[int, str]] = {
31
+ ErrorCode.HUB_INVALID_COMMAND: (
32
+ "There may be no program on the hub yet. Send one with "
33
+ "[bold]pb run[/] or [bold]pb download[/]."
34
+ ),
35
+ }
36
+ """What a refused start usually means.
37
+
38
+ The hub answers "invalid command" both for a slot with nothing in it and for
39
+ a program its firmware cannot run, and the first is far more likely.
40
+ """
41
+
42
+
43
+ @app.command(name="run", group=PROGRAM_COMMANDS, sort_key=0)
44
+ async def run_cmd(
45
+ script: Path | None = None,
46
+ /,
47
+ *,
48
+ command: Annotated[
49
+ str | None, cyclopts.Parameter(name=["-c", "--command"], show_default=False)
50
+ ] = None,
51
+ hub: str | None = None,
52
+ slot: int | None = None,
53
+ wait: bool | None = None,
54
+ verbose: Verbose = (),
55
+ ) -> int:
56
+ """Compile a script, send it to a hub, and run it.
57
+
58
+ The command stays with the program: its output appears as it is printed,
59
+ and on a terminal the keyboard goes to the hub, so Ctrl-C interrupts and
60
+ input() reads what you type. Press Escape twice to stop the program;
61
+ Ctrl-] q leaves, and Ctrl-] h lists the rest. Without a terminal -- in a
62
+ pipeline or a build -- the output still comes through, on stdout.
63
+
64
+ Args:
65
+ script: The Python file to run. Leave it out when using --command.
66
+ hub: Which hub, by name. Defaults to the one in use.
67
+ command: Python code to run, instead of a file. Like python -c.
68
+ slot: Which program slot to use.
69
+ wait: How long to stay with the program, and how much of the terminal
70
+ to use while doing it. Left out, the session takes the terminal
71
+ when there is one. --wait stays but leaves the terminal alone and
72
+ only prints the output, which is what a script wants. --no-wait
73
+ starts the program and returns, for setting a robot going and
74
+ walking away.
75
+ verbose: Increase log detail.
76
+ """
77
+
78
+ if (script is None) == (command is None):
79
+ return ui.usage(
80
+ "Give a script to run, or -c with the code to run.",
81
+ "For example [bold]pb run demo.py[/] or [bold]pb run -c 'print(1)'[/].",
82
+ )
83
+
84
+ configure_logging(verbose)
85
+ return await errors.run(_run, script, command, hub, slot, wait)
86
+
87
+
88
+ async def _run(
89
+ script: Path | None,
90
+ command: str | None,
91
+ hub: str | None,
92
+ slot: int | None,
93
+ wait: bool | None,
94
+ ) -> int:
95
+ err = ui.console(err=True)
96
+ compiled: pb_client.CompiledProgram | None = None
97
+ program = b""
98
+
99
+ with ui.spinner(err, "Compiling..."):
100
+ if script is not None:
101
+ roots = await project.module_roots(script)
102
+ compiled = await pb_client.compile_program(script, roots=roots)
103
+ else:
104
+ # Exactly one of the two is given; the command line was checked
105
+ # before any of this ran.
106
+ assert command is not None
107
+ program = await pb_client.compile_source(command)
108
+
109
+ # Sending and running are two steps to the user, and were one spinner
110
+ # that said "Downloading" for as long as the program ran.
111
+ async with pb_client.connect(autostart=False) as service:
112
+ if compiled is not None:
113
+ program = compiled.pack(legacy_main=await _legacy_main(service, hub))
114
+
115
+ with ui.spinner(err, f"Downloading {len(program)} bytes..."):
116
+ await service.download(program, hub_id=hub, slot=slot)
117
+
118
+ if wait is not False:
119
+ # The hub stays connected either way, so there is nothing to be
120
+ # gained by watching a spinner instead of the program. Asking for
121
+ # --wait by name is asking for less than that: it says stay, and
122
+ # says nothing about wanting the terminal taken over, so it is
123
+ # taken to mean the plain form.
124
+ return await _stay_with_it(service, hub, slot, terminal=wait is None)
125
+
126
+ with ui.spinner(err, "Starting..."):
127
+ await service.start_program(hub_id=hub, slot=slot, wait=False)
128
+
129
+ err.print("Program started.")
130
+ return EXIT_OK
131
+
132
+
133
+ async def _legacy_main(service: pb_client.Service, hub: str | None) -> bool:
134
+ """Ask the hub whether the program's entry module must be called __main__.
135
+
136
+ Args:
137
+ service: The connected service.
138
+ hub: Which hub, by name, or None for the one in use.
139
+
140
+ Returns:
141
+ True for a hub on a Pybricks profile before v1.5.0.
142
+
143
+ This is why compiling and packing are separate: the name depends on the
144
+ hub, and compiling happens before anything is connected to, so that a
145
+ syntax error is reported without a hub having to be found first.
146
+ """
147
+ info = await service.hub_info(hub_id=hub)
148
+ return info.legacy_main_module
149
+
150
+
151
+ @app.command(name="download", group=PROGRAM_COMMANDS, sort_key=2)
152
+ async def download_cmd(
153
+ script: Path,
154
+ /,
155
+ *,
156
+ hub: str | None = None,
157
+ slot: int | None = None,
158
+ verbose: Verbose = (),
159
+ ) -> int:
160
+ """Compile a script and send it to a hub without running it.
161
+
162
+ A program goes into whichever slot the hub has selected -- the hub
163
+ decides, and its buttons are what change the selection. Naming a slot here
164
+ says which one you expect, and the download is refused rather than put
165
+ somewhere else if the hub disagrees.
166
+
167
+ Args:
168
+ script: The Python file to send.
169
+ hub: Which hub, by name. Defaults to the one in use.
170
+ slot: The slot it should land in. Defaults to whichever the hub has
171
+ selected.
172
+ verbose: Increase log detail.
173
+ """
174
+ configure_logging(verbose)
175
+ return await errors.run(_download, script, hub, slot)
176
+
177
+
178
+ async def _download(script: Path, hub: str | None, slot: int | None) -> int:
179
+ err = ui.console(err=True)
180
+ roots = await project.module_roots(script)
181
+ compiled = await pb_client.compile_program(script, roots=roots)
182
+
183
+ async with pb_client.connect(autostart=False) as service:
184
+ program = compiled.pack(legacy_main=await _legacy_main(service, hub))
185
+ written = await service.download(program, hub_id=hub, slot=slot)
186
+
187
+ # Which slot is only worth saying on a hub that has more than one, where
188
+ # it is also the thing most worth saying: it is the hub's choice, not
189
+ # ours, and the answer to "where did my program go?".
190
+ where = f" into slot {written.slot}" if written.slots > 1 else ""
191
+ err.print(f"Downloaded {written.size} bytes{where}.")
192
+ return EXIT_OK
193
+
194
+
195
+ @app.command(name="start", group=PROGRAM_COMMANDS, sort_key=3)
196
+ async def start_cmd(
197
+ *,
198
+ hub: str | None = None,
199
+ slot: int | None = None,
200
+ wait: bool | None = None,
201
+ verbose: Verbose = (),
202
+ ) -> int:
203
+ """Start a program that is already on the hub.
204
+
205
+ Nothing is sent to the hub, so this is the quick way to run the same
206
+ program again, and the way to run one put there by another tool. What
207
+ happens after it starts is exactly what happens for pb run.
208
+
209
+ Args:
210
+ hub: Which hub, by name. Defaults to the one in use.
211
+ slot: Which program slot. Defaults to the one the hub has selected.
212
+ wait: How long to stay with the program, and how much of the terminal
213
+ to use while doing it. Left out, the session takes the terminal
214
+ when there is one and returns straight away when there is not,
215
+ since a script starting a stored program is setting a robot going.
216
+ --wait stays either way, leaving the terminal alone and printing
217
+ only the output. --no-wait always returns as soon as it starts.
218
+ verbose: Increase log detail.
219
+ """
220
+ configure_logging(verbose)
221
+ return await errors.run(_start, hub, slot, wait, hints=_START_HINTS)
222
+
223
+
224
+ async def _start(hub: str | None, slot: int | None, wait: bool | None) -> int:
225
+ err = ui.console(err=True)
226
+
227
+ if wait is None and not session.have_terminal():
228
+ # The one place this differs from pb run. Starting a program that is
229
+ # already on the hub, from something that is not a terminal, is what a
230
+ # script does to set a robot going -- so it starts it and returns,
231
+ # rather than holding the script open for as long as the robot runs.
232
+ # At a terminal the opposite is true: somebody typing pb start wants
233
+ # to see what it does.
234
+ wait = False
235
+
236
+ async with pb_client.connect(autostart=False) as service:
237
+ if wait is not False:
238
+ return await _stay_with_it(service, hub, slot, terminal=wait is None)
239
+
240
+ await service.start_program(hub_id=hub, slot=slot)
241
+
242
+ err.print("Program started.")
243
+ return EXIT_OK
244
+
245
+
246
+ async def _stay_with_it(
247
+ service: pb_client.Service, hub: str | None, slot: int | None, *, terminal: bool
248
+ ) -> int:
249
+ """Start a program and stay with it until it ends.
250
+
251
+ The same session as ``pb repl``, with a program on the other end instead
252
+ of the interpreter: output as it is printed, the keyboard going to the
253
+ hub, and the status line along the bottom. It ends when the program does,
254
+ or when the user asks to leave -- and a program this command started is
255
+ stopped on the way out, since a hub with something running cannot be found
256
+ by anything.
257
+
258
+ Where there is no terminal, or where the caller would rather it were left
259
+ alone, the same thing happens with the terminal parts left out: the output
260
+ still arrives, on stdout, and the exit code still says how the program
261
+ ended. That is what a build or a pipeline gets.
262
+
263
+ Args:
264
+ service: The connected service.
265
+ hub: Which hub.
266
+ slot: Which program slot, or None for the hub's own choice.
267
+ terminal: Whether taking the terminal over is wanted at all. A script
268
+ run from a terminal is still a script.
269
+
270
+ Returns:
271
+ The exit code: three if the program ended in an exception, since a
272
+ program that failed in front of the user is not a command that
273
+ succeeded.
274
+ """
275
+ info = await service.hub_info(hub)
276
+ running = session.Running("program", watch_for_crash=True)
277
+
278
+ async def start(announce: session.Announce) -> bool:
279
+ """Set the program going, now that its output is being listened for.
280
+
281
+ Args:
282
+ announce: Says one line to the user. Unused: the program speaks
283
+ for itself.
284
+
285
+ Returns:
286
+ True, always: this command is what started it, so leaving is what
287
+ has to stop it.
288
+ """
289
+ del announce
290
+ await service.start_program(hub_id=hub, slot=slot, wait=False)
291
+ return True
292
+
293
+ # Both have to hold: something to take over, and a reason to.
294
+ use_terminal = terminal and session.have_terminal()
295
+ stay = session.attached if use_terminal else session.streamed
296
+ return await stay(service, info, hub, replay=0, running=running, start=start)
297
+
298
+
299
+ @app.command(name="stop", group=PROGRAM_COMMANDS, sort_key=4)
300
+ async def stop_cmd(*, hub: str | None = None, verbose: Verbose = ()) -> int:
301
+ """Stop the program running on a hub.
302
+
303
+ Args:
304
+ hub: Which hub, by name. Defaults to the one in use.
305
+ verbose: Increase log detail.
306
+ """
307
+ configure_logging(verbose)
308
+ return await errors.run(_stop, hub)
309
+
310
+
311
+ async def _stop(hub: str | None) -> int:
312
+ err = ui.console(err=True)
313
+
314
+ async with pb_client.connect(autostart=False) as service:
315
+ await service.stop_program(hub)
316
+
317
+ err.print("Stopped.")
318
+ return EXIT_OK
319
+
320
+
321
+ @app.command(name="check", group=PROGRAM_COMMANDS, sort_key=5)
322
+ async def check_cmd(
323
+ script: Path | None = None,
324
+ /,
325
+ *,
326
+ command: Annotated[
327
+ str | None, cyclopts.Parameter(name=["-c", "--command"], show_default=False)
328
+ ] = None,
329
+ verbose: Verbose = (),
330
+ ) -> int:
331
+ """Check that a program compiles, without sending it anywhere.
332
+
333
+ No hub and no background service: this is the compiler on its own. Useful
334
+ while writing a program with no hub to hand, and as the fast check to put
335
+ in a commit hook or a build, where starting a Bluetooth connection to find
336
+ out about a missing bracket would be absurd.
337
+
338
+ It reports what the compiler said and how big the program came out, since
339
+ size is the other thing that can stop a program running.
340
+
341
+ Args:
342
+ script: The Python file to check. Leave it out when using --command.
343
+ command: Python code to check, instead of a file. Like python -c.
344
+ verbose: Increase log detail.
345
+ """
346
+
347
+ if (script is None) == (command is None):
348
+ return ui.usage(
349
+ "Give a script to check, or -c with the code to check.",
350
+ "For example [bold]pb check demo.py[/] or [bold]pb check -c 'print(1)'[/].",
351
+ )
352
+
353
+ configure_logging(verbose)
354
+ return await errors.run(_check, script, command)
355
+
356
+
357
+ async def _check(script: Path | None, command: str | None) -> int:
358
+ err = ui.console(err=True)
359
+
360
+ if script is not None:
361
+ roots = await project.module_roots(script)
362
+ # Packed with the modern naming, which nothing here depends on: no
363
+ # program is being sent, so the entry module's name is never read.
364
+ program = await pb_client.compile_script(script, roots=roots)
365
+ named = script.name
366
+ else:
367
+ assert command is not None
368
+ program = await pb_client.compile_source(command)
369
+ named = pb_client.SOURCE_NAME
370
+
371
+ # A failure needs no branch here: the compiler's own diagnostics come back
372
+ # as a CompileError, and errors.run prints them under one plain sentence.
373
+ err.print(f"[green]{named} compiles.[/] {len(program)} bytes.")
374
+ return EXIT_OK
@@ -0,0 +1,146 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """An interactive session with the interpreter on a hub.
5
+
6
+ The session itself lives in :mod:`pb_cli.session`, which is shared with ``pb run``
7
+ and ``pb start``. What belongs here is only what is particular to talking to
8
+ the interpreter: deciding whether to start one, join one, or refuse.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ import pb_client
16
+ from pb_cli import errors, session, ui
17
+ from pb_cli.app import PROGRAM_COMMANDS, Verbose, app, configure_logging
18
+ from pb_cli.errors import EXIT_HUB_ERROR
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ __all__ = ["repl_cmd"]
23
+
24
+
25
+ @app.command(name="repl", group=PROGRAM_COMMANDS, sort_key=1)
26
+ async def repl_cmd(
27
+ *, hub: str | None = None, replay: int = 0, verbose: Verbose = ()
28
+ ) -> int:
29
+ """Talk to a hub's interpreter, a line at a time.
30
+
31
+ Everything typed goes to the hub, Ctrl-C included -- which is how a
32
+ running program is interrupted. The one key that does not is Ctrl-],
33
+ which introduces a command for pb itself: Ctrl-] q leaves, and Ctrl-] h
34
+ lists the rest.
35
+
36
+ Escape pressed twice stops whatever the hub is running. Here that is the
37
+ interpreter, so it ends the session as well; Ctrl-C is the lighter one for
38
+ interrupting a line that is taking too long.
39
+
40
+ Ctrl-D ends the interpreter, and so ends the session. A hub already busy
41
+ with a program of its own is left alone rather than interrupted.
42
+
43
+ A status line along the bottom says which hub is being talked to, whether
44
+ anything is listening, and whether its battery is about to end the
45
+ conversation.
46
+
47
+ Args:
48
+ hub: Which hub, by name. Defaults to the one in use.
49
+ replay: How much earlier output to show first, in bytes. Useful for
50
+ joining a program that is already running and printing.
51
+ verbose: Increase log detail.
52
+ """
53
+ configure_logging(verbose)
54
+ return await errors.run(_repl, hub, replay)
55
+
56
+
57
+ async def _repl(hub: str | None, replay: int) -> int:
58
+ no_terminal = session.needs_terminal("pb repl")
59
+ if no_terminal is not None:
60
+ return no_terminal
61
+
62
+ async with pb_client.connect(autostart=False) as service:
63
+ info = await service.hub_info(hub)
64
+ busy = _refuse_if_busy(info)
65
+ if busy is not None:
66
+ return busy
67
+
68
+ joining = _is_the_interpreter(info)
69
+ running = session.Running("interpreter", arrived=joining)
70
+
71
+ async def start(announce: session.Announce) -> bool:
72
+ """Start the interpreter, or join the one already running.
73
+
74
+ Restarting an interpreter that is already going would throw away
75
+ the session it holds, so an existing one is joined instead -- and
76
+ left running afterwards, since it was not ours to stop.
77
+
78
+ Args:
79
+ announce: Says one line to the user.
80
+
81
+ Returns:
82
+ Whether an interpreter was started here, which is what decides
83
+ whether leaving should stop it again.
84
+ """
85
+
86
+ if joining:
87
+ announce("Joining the interpreter that is already running.")
88
+ return False
89
+
90
+ await service.start_repl(hub)
91
+ return True
92
+
93
+ return await session.attached(
94
+ service, info, hub, replay=replay, running=running, start=start
95
+ )
96
+
97
+
98
+ def _is_the_interpreter(info: pb_client.HubInfo) -> bool:
99
+ """Whether what the hub is running is its own interpreter.
100
+
101
+ Args:
102
+ info: The hub as it was when the session was asked for.
103
+
104
+ Returns:
105
+ True if there is an interpreter to join.
106
+ """
107
+ return bool(
108
+ info.program_running and info.running_program_id == pb_client.REPL_PROGRAM_ID
109
+ )
110
+
111
+
112
+ def _refuse_if_busy(info: pb_client.HubInfo) -> int | None:
113
+ """Refuse the session if the hub is busy with a program of its own.
114
+
115
+ A hub runs one thing at a time, so starting the interpreter would stop
116
+ whatever is running -- a poor way to answer "let me look at this hub".
117
+ Nor is there anything that could put things back afterwards: there is no
118
+ equivalent of CPython's ``-i`` here, so a program stopped to make room for
119
+ the interpreter cannot be handed back to.
120
+
121
+ Attaching and waiting for the program to end was the other possibility,
122
+ and is worse than refusing. It looks exactly like a session that has hung,
123
+ and the one thing the user asked for -- a prompt -- is the one thing it
124
+ cannot give them.
125
+
126
+ Args:
127
+ info: The hub as it was when the session was asked for.
128
+
129
+ Returns:
130
+ The exit code to use, or ``None`` if there is nothing in the way.
131
+ """
132
+
133
+ if not info.program_running or _is_the_interpreter(info):
134
+ return None
135
+
136
+ return errors.render(
137
+ ui.console(err=True),
138
+ [
139
+ errors.Problem(
140
+ "A program is running on the hub, so the interpreter cannot start.",
141
+ "Wait for it to finish, or stop it with [bold]pb stop[/] or "
142
+ "the hub's button.",
143
+ exit_code=EXIT_HUB_ERROR,
144
+ )
145
+ ],
146
+ )