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