pybricks-client 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_client/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Client library for the Pybricks service.
5
+
6
+ The public surface is deliberately shaped so that structured concurrency cannot
7
+ be violated from outside: anything producing ongoing events is an async context
8
+ manager yielding an async iterator, and no function takes a ``timeout``
9
+ argument. Callers scope cancellation with
10
+ [fail_after][anyio.fail_after] or
11
+ [move_on_after][anyio.move_on_after] instead.
12
+ """
13
+
14
+ __version__ = "1.0.0b1"
15
+
16
+ from pb_client.compile import (
17
+ SOURCE_NAME,
18
+ CompiledProgram,
19
+ CompileError,
20
+ compile_program,
21
+ compile_script,
22
+ compile_source,
23
+ describe_compiler,
24
+ )
25
+ from pb_client.errors import (
26
+ PbClientError,
27
+ ServiceStartError,
28
+ ServiceTooOld,
29
+ ServiceUnavailable,
30
+ )
31
+ from pb_client.models import (
32
+ Choice,
33
+ DeviceInfo,
34
+ Downloaded,
35
+ HubInfo,
36
+ ServiceInfo,
37
+ UnusableAdapter,
38
+ )
39
+ from pb_client.service import Service, connect
40
+ from pb_client.stdio import REPL_PROGRAM_ID, HubEvent, Output, State, Status
41
+ from pb_core.jsonrpc import JsonRpcError
42
+
43
+ __all__ = [
44
+ "REPL_PROGRAM_ID",
45
+ "SOURCE_NAME",
46
+ "Choice",
47
+ "CompileError",
48
+ "CompiledProgram",
49
+ "DeviceInfo",
50
+ "Downloaded",
51
+ "HubEvent",
52
+ "HubInfo",
53
+ "JsonRpcError",
54
+ "Output",
55
+ "PbClientError",
56
+ "Service",
57
+ "ServiceInfo",
58
+ "ServiceStartError",
59
+ "ServiceTooOld",
60
+ "ServiceUnavailable",
61
+ "State",
62
+ "Status",
63
+ "UnusableAdapter",
64
+ "__version__",
65
+ "compile_program",
66
+ "compile_script",
67
+ "compile_source",
68
+ "connect",
69
+ "describe_compiler",
70
+ ]
pb_client/autostart.py ADDED
@@ -0,0 +1,216 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Starting a service on demand.
5
+
6
+ Two clients running at once may both find no service and both start one. That is
7
+ deliberately not coordinated: one wins the single-instance lock, the loser exits
8
+ quietly, and both clients' readiness loops then connect to the winner. There are
9
+ no PID files to go stale and no startup handshake to get wrong.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from collections.abc import AsyncGenerator
22
+ from contextlib import AsyncExitStack, asynccontextmanager
23
+ from pathlib import Path
24
+ from typing import Any, cast
25
+
26
+ import anyio
27
+ from anyio.abc import ByteStream
28
+
29
+ from pb_client.errors import ServiceStartError, ServiceUnavailable
30
+ from pb_core import ipc, paths
31
+ from pb_core.ipc import Endpoint
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ __all__ = [
36
+ "SERVICE_COMMAND_ENV_VAR",
37
+ "open_connection",
38
+ "spawn_service",
39
+ ]
40
+
41
+ SERVICE_COMMAND_ENV_VAR = "PB_SERVICE_CMD"
42
+
43
+
44
+ def neutral_directory() -> Path:
45
+ """Return where the service should be started from: the filesystem root.
46
+
47
+ Computed rather than a constant so that it cannot depend on what the
48
+ working directory happened to be when this module was imported -- ``pb -C``
49
+ may have moved to another drive since.
50
+
51
+ Returns:
52
+ The root of the current drive, which is always a directory that exists.
53
+ """
54
+ return Path(Path.cwd().anchor)
55
+
56
+
57
+ READY_TIMEOUT = 15.0
58
+ """How long to keep polling for a service we just started.
59
+
60
+ Deliberately a module constant rather than a parameter. The repository's async
61
+ rules say public APIs take no ``timeout`` argument -- callers scope cancellation
62
+ with ``anyio.fail_after`` instead. This is not a caller-facing deadline but an
63
+ internal safety bound, whose real job is to turn "hung forever" into a message
64
+ naming the service's log file.
65
+ """
66
+ _FIRST_DELAY = 0.02
67
+ _MAX_DELAY = 0.25
68
+ _BACKOFF = 1.6
69
+
70
+ _NOT_LISTENING = (ConnectionRefusedError, FileNotFoundError)
71
+ """What "no service there" looks like.
72
+
73
+ On Windows, connecting to a pipe that does not exist raises FileNotFoundError
74
+ straight away rather than waiting, so the retry loop is what supplies the
75
+ patience.
76
+ """
77
+
78
+
79
+ def _service_command() -> list[str]:
80
+ """Return the command that starts a service.
81
+
82
+ The fallback to ``-m pb_service`` is why the CLI package depends on the service
83
+ package even though it never imports it: installing the CLI alone must
84
+ still give a working autostart.
85
+ """
86
+ override = os.environ.get(SERVICE_COMMAND_ENV_VAR)
87
+ if override:
88
+ # posix=False on Windows, or shlex treats the backslashes in a path
89
+ # like C:\Python\python.exe as escapes and silently mangles it. That
90
+ # mode leaves quotes attached, so strip them.
91
+ if sys.platform == "win32":
92
+ return [token.strip('"') for token in shlex.split(override, posix=False)]
93
+
94
+ return shlex.split(override)
95
+
96
+ found = shutil.which("pb-service")
97
+ if found:
98
+ return [found]
99
+
100
+ return [sys.executable, "-m", "pb_service"]
101
+
102
+
103
+ _spawned: list[subprocess.Popen[bytes]] = []
104
+ """Handles for services this process started.
105
+
106
+ Kept only so that Popen objects are not garbage collected while the service is
107
+ still running, which would emit a ResourceWarning. The service is meant to
108
+ outlive us, so it is never waited on; Popen is simply not the right abstraction
109
+ for a process we deliberately do not manage.
110
+ """
111
+
112
+
113
+ def spawn_service(endpoint: Endpoint) -> None:
114
+ """Start a service in the background and return immediately.
115
+
116
+ Args:
117
+ endpoint: Where the service should listen.
118
+ """
119
+ command = [*_service_command(), "--endpoint", str(endpoint)]
120
+ logger.info("starting a service: %s", " ".join(command))
121
+
122
+ common: dict[str, Any] = {
123
+ "stdin": subprocess.DEVNULL,
124
+ "stdout": subprocess.DEVNULL,
125
+ "stderr": subprocess.DEVNULL,
126
+ "close_fds": True,
127
+ # The filesystem root, which is what a daemon has always done and for
128
+ # the same reason: a running process holds its working directory, so
129
+ # inheriting this one would stop that directory being renamed or
130
+ # deleted for as long as the service lives -- which is well past the
131
+ # shell that started it, and on Windows is enforced rather than
132
+ # merely untidy. The service never uses a relative path; everything
133
+ # it writes comes from pb_core.paths, which is absolute.
134
+ "cwd": neutral_directory(),
135
+ }
136
+
137
+ if sys.platform == "win32":
138
+ # CREATE_NO_WINDOW, emphatically not DETACHED_PROCESS. Both are
139
+ # described as freeing a child from the parent's console, and only one
140
+ # of them does it quietly: measured on Windows 11, a console
141
+ # application started with DETACHED_PROCESS has a *new* console
142
+ # allocated for it, and that console is visible. It is the terminal
143
+ # that used to flash up whenever a command started the service.
144
+ # Adding CREATE_NO_WINDOW alongside does not help -- it is documented
145
+ # as ignored in combination with DETACHED_PROCESS, and measurement
146
+ # agrees.
147
+ #
148
+ # On its own, CREATE_NO_WINDOW leaves the service with no console
149
+ # window of the parent's or of its own, so no console control event
150
+ # can reach it -- including the one Windows sends when the terminal
151
+ # that started it is closed. CREATE_NEW_PROCESS_GROUP keeps Ctrl+C out
152
+ # as well. Shutting down deliberately goes through service.shutdown.
153
+ child = subprocess.Popen(
154
+ command,
155
+ creationflags=subprocess.CREATE_NO_WINDOW
156
+ | subprocess.CREATE_NEW_PROCESS_GROUP,
157
+ **common,
158
+ )
159
+ else:
160
+ # Its own session, so it survives this terminal closing.
161
+ child = subprocess.Popen(command, start_new_session=True, **common)
162
+
163
+ # The shared options go in as **Any, so pyright picks the first Popen
164
+ # overload that matches -- the text-mode one -- although nothing here
165
+ # asks for text mode and every stream is DEVNULL. Say which it is.
166
+ _spawned.append(cast("subprocess.Popen[bytes]", child))
167
+
168
+
169
+ async def _enter_with_retry(stack: AsyncExitStack, endpoint: Endpoint) -> ByteStream:
170
+ """Keep trying to connect until the service is up, or give up."""
171
+ deadline = time.monotonic() + READY_TIMEOUT
172
+ delay = _FIRST_DELAY
173
+
174
+ while True:
175
+ try:
176
+ return await stack.enter_async_context(ipc.connect(endpoint))
177
+ except _NOT_LISTENING:
178
+ if time.monotonic() >= deadline:
179
+ raise ServiceStartError(str(endpoint), paths.log_file()) from None
180
+
181
+ await anyio.sleep(delay)
182
+ delay = min(delay * _BACKOFF, _MAX_DELAY)
183
+
184
+
185
+ @asynccontextmanager
186
+ async def open_connection(
187
+ endpoint: Endpoint, *, autostart: bool = True
188
+ ) -> AsyncGenerator[ByteStream, None]:
189
+ """Connect to the service, starting one if necessary.
190
+
191
+ Args:
192
+ endpoint: Where the service is, or should be.
193
+ autostart: Whether to start a service when none is running.
194
+
195
+ Yields:
196
+ The connected stream.
197
+
198
+ Raises:
199
+ ServiceUnavailable: If no service is running and ``autostart`` is false.
200
+ ServiceStartError: If a service was started but never became reachable.
201
+ """
202
+
203
+ async with AsyncExitStack() as stack:
204
+ try:
205
+ stream = await stack.enter_async_context(ipc.connect(endpoint))
206
+ except _NOT_LISTENING:
207
+ if not autostart:
208
+ raise ServiceUnavailable(
209
+ f"the background service is not running ({endpoint})"
210
+ ) from None
211
+
212
+ logger.debug("no service at %s; starting one", endpoint)
213
+ spawn_service(endpoint)
214
+ stream = await _enter_with_retry(stack, endpoint)
215
+
216
+ yield stream
pb_client/compile.py ADDED
@@ -0,0 +1,407 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Compiling Python for a hub.
5
+
6
+ Compilation happens on the client rather than in the service, for two reasons.
7
+ The script is on the caller's filesystem, so the service would otherwise need to
8
+ read files belonging to whoever asked -- which stops being reasonable the moment
9
+ the service is on another machine. And it keeps the compiler, which is a native
10
+ binary, out of the service entirely.
11
+
12
+ The compiler is a subprocess, so it runs in a worker thread rather than on the
13
+ event loop.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import subprocess
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import NamedTuple
24
+
25
+ import anyio
26
+ import anyio.to_thread
27
+ import mpy_cross_v6
28
+
29
+ from pb_client.errors import PbClientError
30
+ from pb_core.modules import MAIN_MODULE, Module, RelativeImportError, discover
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ __all__ = [
35
+ "MAIN_MODULE",
36
+ "MPY_ABI_VERSION",
37
+ "SOURCE_NAME",
38
+ "CompileError",
39
+ "CompiledModule",
40
+ "CompiledProgram",
41
+ "compile_program",
42
+ "compile_script",
43
+ "compile_source",
44
+ "compiler_version",
45
+ "describe_compiler",
46
+ "pack_program",
47
+ ]
48
+
49
+ MPY_ABI_VERSION = 6
50
+ """The ``.mpy`` format this compiler produces, and the hub must accept.
51
+
52
+ The byte after the magic in every ``.mpy`` file. It is checked on a module
53
+ that arrives already compiled, because the firmware checks it too and refuses
54
+ the whole program -- so it is much better said here, naming the file.
55
+ """
56
+
57
+
58
+ class CompileError(PbClientError):
59
+ """The program could not be compiled."""
60
+
61
+ def __init__(self, message: str, output: str = "") -> None:
62
+ """Record the compiler's own diagnostics.
63
+
64
+ Args:
65
+ message: Short description.
66
+ output: The compiler's stderr, which names the offending line.
67
+ """
68
+ super().__init__(message)
69
+ self.output = output
70
+
71
+
72
+ class CompiledModule(NamedTuple):
73
+ """One compiled module of a program."""
74
+
75
+ name: str
76
+ """What the hub will call it."""
77
+
78
+ mpy: bytes
79
+ """Its compiled form."""
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class CompiledProgram:
84
+ """A compiled program, not yet in the form the hub stores.
85
+
86
+ Compiling and packing are separate because the entry module's *name*
87
+ depends on the hub, and the hub is not known when compiling starts.
88
+ Nothing else does: the compiler is told the file's name, for tracebacks,
89
+ and never the module's. So a program can be compiled before anything is
90
+ connected to -- which keeps a syntax error a local failure, reported
91
+ without a hub having to be found first -- and named once the hub has said
92
+ which profile it speaks.
93
+ """
94
+
95
+ modules: tuple[CompiledModule, ...]
96
+ """Every module, entry first.
97
+
98
+ Position is what current firmware uses to choose the program to run, so
99
+ the order is part of the value rather than an incidental detail.
100
+ """
101
+
102
+ def pack(self, *, legacy_main: bool = False) -> bytes:
103
+ """Build the bytes to store on the hub.
104
+
105
+ Args:
106
+ legacy_main: Whether the hub needs the entry module to be called
107
+ ``__main__``. True for Pybricks profiles before v1.5.0, which
108
+ look the entry module up by that name; from v1.5.0 the first
109
+ module is run whatever it is called, and its name is what the
110
+ hub shows as the program's.
111
+
112
+ Returns:
113
+ The packed program.
114
+ """
115
+ modules = self.modules
116
+
117
+ if legacy_main:
118
+ modules = (modules[0]._replace(name=MAIN_MODULE), *modules[1:])
119
+
120
+ packed = pack_program([(module.name, module.mpy) for module in modules])
121
+ logger.info(
122
+ "packed %d module(s) into %d bytes, starting at %s",
123
+ len(modules),
124
+ len(packed),
125
+ modules[0].name,
126
+ )
127
+ return packed
128
+
129
+
130
+ def describe_compiler() -> str:
131
+ """Return the compiler path and its architecture, for the startup log.
132
+
133
+ Worth logging because the published Windows ARM64 wheel currently contains
134
+ an x86-64 binary, so on that platform the compiler runs under emulation.
135
+ Recording it turns a puzzling failure into an obvious one.
136
+
137
+ Returns:
138
+ A short description of the compiler in use.
139
+ """
140
+ path = Path(mpy_cross_v6.MPY_CROSS_PATH)
141
+ machine = "unknown"
142
+
143
+ try:
144
+ header = path.read_bytes()[:0x400]
145
+ if header[:2] == b"MZ":
146
+ offset = int.from_bytes(header[0x3C:0x40], "little")
147
+ value = int.from_bytes(header[offset + 4 : offset + 6], "little")
148
+ machine = {0x8664: "x86-64", 0xAA64: "arm64", 0x14C: "x86"}.get(
149
+ value, hex(value)
150
+ )
151
+ elif header[:4] == b"\x7fELF":
152
+ machine = {0x3E: "x86-64", 0xB7: "arm64"}.get(header[18], hex(header[18]))
153
+ else:
154
+ machine = "native"
155
+ except OSError: # pragma: no cover - diagnostics only
156
+ pass
157
+
158
+ return f"{path} ({machine})"
159
+
160
+
161
+ async def compiler_version() -> str:
162
+ """Return the compiler's version string.
163
+
164
+ Returns:
165
+ What ``mpy-cross --version`` reports.
166
+ """
167
+ return await anyio.to_thread.run_sync(mpy_cross_v6.mpy_cross_version)
168
+
169
+
170
+ def _compile_sync(name: str, source: str) -> bytes:
171
+ completed, mpy = mpy_cross_v6.mpy_cross_compile(name, source)
172
+ if completed.returncode != 0 or mpy is None:
173
+ output = completed.stderr.decode("utf-8", "replace") if completed.stderr else ""
174
+ raise CompileError(f"could not compile {name}", output)
175
+
176
+ return mpy
177
+
178
+
179
+ SOURCE_NAME = "<string>"
180
+ """The file name to record for source that did not come from a file.
181
+
182
+ The compiled module carries a name, which is what the hub prints in a
183
+ traceback, and there is no file to name. CPython prints ``<string>`` for
184
+ ``python -c``, so a traceback from ``pb run -c`` reads the way a user who knows
185
+ that already expects it to.
186
+ """
187
+
188
+
189
+ def pack_program(modules: Sequence[tuple[str, bytes]]) -> bytes:
190
+ """Pack compiled modules into the container the hub stores.
191
+
192
+ The hub reads stored program data as a run of modules, each one a 32-bit
193
+ little-endian size, a zero-terminated module name, and that module's
194
+ ``.mpy`` bytes -- with no padding or alignment between them. There is no
195
+ bare-``.mpy`` alternative: the firmware parses this header before looking
196
+ at the MicroPython magic, so a bare ``.mpy`` is read as a garbage header
197
+ and refused.
198
+
199
+ Args:
200
+ modules: Name and compiled bytes for each module. **The one the hub
201
+ starts goes first**: current firmware runs whichever module comes
202
+ first, whatever it is called, and takes the program's displayed
203
+ name from it. Before Pybricks profile v1.5.0 it was found by the
204
+ name ``__main__`` instead, so a hub that old needs the first one
205
+ called that -- which is what
206
+ [CompiledProgram.pack][pb_client.compile.CompiledProgram.pack]
207
+ decides.
208
+
209
+ Returns:
210
+ The bytes to store on the hub.
211
+ """
212
+ parts: list[bytes] = []
213
+
214
+ for name, mpy in modules:
215
+ parts.append(len(mpy).to_bytes(4, "little"))
216
+ parts.append(name.encode() + b"\x00")
217
+ parts.append(mpy)
218
+
219
+ return b"".join(parts)
220
+
221
+
222
+ async def _compile(source: str, *, name: str) -> bytes:
223
+ """Compile one module.
224
+
225
+ Args:
226
+ source: The Python code.
227
+ name: What to call it in compiler diagnostics and in tracebacks from
228
+ the hub. A file name, where there is a file.
229
+
230
+ Returns:
231
+ Its ``.mpy`` bytes, unpacked.
232
+
233
+ Raises:
234
+ CompileError: If the compiler rejects it.
235
+ """
236
+ logger.info("compiling %s (%d bytes of source)", name, len(source))
237
+
238
+ try:
239
+ mpy = await anyio.to_thread.run_sync(_compile_sync, name, source)
240
+ except subprocess.SubprocessError as exc: # pragma: no cover - environment
241
+ raise CompileError(f"could not run the compiler: {exc}") from exc
242
+
243
+ logger.info("compiled %s to %d bytes", name, len(mpy))
244
+ return mpy
245
+
246
+
247
+ async def compile_source(source: str, *, name: str = SOURCE_NAME) -> bytes:
248
+ """Compile Python source into a program the hub can run.
249
+
250
+ For source that did not come from a file, so there is nothing to discover:
251
+ a program typed at a shell cannot import a module of its own, since there
252
+ is no directory it came from. The one module is called ``__main__``, which
253
+ every firmware runs -- there is no file name to call it instead, and
254
+ ``__main__`` is also what the hub would then display.
255
+
256
+ Args:
257
+ source: The Python code.
258
+ name: What to call it in compiler diagnostics and in tracebacks from
259
+ the hub. Defaults to
260
+ [SOURCE_NAME][pb_client.compile.SOURCE_NAME].
261
+
262
+ Returns:
263
+ The compiled program, ready to download.
264
+
265
+ Raises:
266
+ CompileError: If the compiler rejects it.
267
+ """
268
+ return pack_program([(MAIN_MODULE, await _compile(source, name=name))])
269
+
270
+
271
+ async def _read(path: Path) -> str:
272
+ """Read a source file, blaming it by name if that fails.
273
+
274
+ Args:
275
+ path: The file.
276
+
277
+ Returns:
278
+ Its text.
279
+
280
+ Raises:
281
+ CompileError: If it cannot be read.
282
+ """
283
+
284
+ try:
285
+ # Off the event loop: reading a file is blocking, and a script can
286
+ # live on a slow or remote filesystem.
287
+ return await anyio.Path(path).read_text(encoding="utf-8")
288
+ except OSError as exc:
289
+ # strerror rather than str(exc), which repeats the file name and the
290
+ # errno: "could not read demo.py: [Errno 2] No such file or
291
+ # directory: 'demo.py'" says one thing three times.
292
+ reason = exc.strerror or str(exc)
293
+ raise CompileError(f"could not read {path}: {reason.lower()}") from exc
294
+
295
+
296
+ async def _already_compiled(module: Module) -> CompiledModule:
297
+ """Take a module that arrives compiled, checking it will be accepted.
298
+
299
+ Args:
300
+ module: The module, whose file is a ``.mpy``.
301
+
302
+ Returns:
303
+ It, ready to pack.
304
+
305
+ Raises:
306
+ CompileError: If it cannot be read, or is not a ``.mpy`` this hub
307
+ firmware would accept.
308
+ """
309
+
310
+ try:
311
+ mpy = await anyio.Path(module.path).read_bytes()
312
+ except OSError as exc:
313
+ reason = exc.strerror or str(exc)
314
+ raise CompileError(f"could not read {module.path}: {reason.lower()}") from exc
315
+
316
+ # The same two checks the firmware makes before it will run anything. It
317
+ # refuses the whole program without saying which module was wrong, so
318
+ # they are much more useful here.
319
+ if not mpy.startswith(b"M"):
320
+ raise CompileError(f"cannot use {module.path}: it is not a .mpy file")
321
+
322
+ if mpy[1] != MPY_ABI_VERSION:
323
+ raise CompileError(
324
+ f"cannot use {module.path}: it is .mpy version {mpy[1]}, but the "
325
+ f"hub needs version {MPY_ABI_VERSION}"
326
+ )
327
+
328
+ logger.info("%s arrived compiled, %d bytes", module.name, len(mpy))
329
+ return CompiledModule(module.name, mpy)
330
+
331
+
332
+ async def compile_program(
333
+ entry: Path, *, roots: Sequence[Path] | None = None
334
+ ) -> CompiledProgram:
335
+ """Compile a script and every module of its own that it imports.
336
+
337
+ Args:
338
+ entry: The script the program starts at.
339
+ roots: The directories module names are looked for in, in order.
340
+ Defaults to the script's own directory, which is right for a
341
+ program whose modules sit beside it;
342
+ ``pb_core.project.module_roots`` is what works the list out for a
343
+ project that keeps its source somewhere else.
344
+
345
+ Returns:
346
+ The compiled modules, entry first, ready to be packed once the hub's
347
+ profile is known.
348
+
349
+ Raises:
350
+ CompileError: If a file cannot be read, the compiler rejects one, a
351
+ module that arrives compiled is not one the hub could run, or a
352
+ module imports relative to its own package -- which the hub cannot
353
+ do, so it is refused here where the line can be named.
354
+ """
355
+
356
+ try:
357
+ found = await discover(entry, roots if roots is not None else (entry.parent,))
358
+ except OSError as exc:
359
+ # Which file could not be read matters: with several modules, "no such
360
+ # file" on its own would not say which import was wrong.
361
+ reason = exc.strerror or str(exc)
362
+ raise CompileError(
363
+ f"could not read {exc.filename or entry}: {reason.lower()}"
364
+ ) from exc
365
+ except RelativeImportError as exc:
366
+ # A user error rather than a bug, so it gets the same friendly
367
+ # treatment as a syntax error rather than a traceback.
368
+ raise CompileError(str(exc)) from exc
369
+
370
+ compiled: list[CompiledModule] = []
371
+
372
+ for module in found:
373
+ if module.precompiled:
374
+ compiled.append(await _already_compiled(module))
375
+ continue
376
+
377
+ source = await _read(module.path)
378
+ # The file's name rather than the module's: this is what the hub
379
+ # prints in a traceback, and a traceback should name a file somebody
380
+ # can open.
381
+ mpy = await _compile(source, name=module.path.name)
382
+ compiled.append(CompiledModule(module.name, mpy))
383
+
384
+ return CompiledProgram(tuple(compiled))
385
+
386
+
387
+ async def compile_script(path: Path, *, roots: Sequence[Path] | None = None) -> bytes:
388
+ """Compile a script into a program, for a hub whose profile is not known.
389
+
390
+ Packs immediately with the modern naming, so it is for callers with no hub
391
+ in hand -- ``pb check``, which only wants to know that a program builds
392
+ and how large it comes out. Anything about to send a program to a hub
393
+ should use [compile_program][pb_client.compile.compile_program] and pack
394
+ once the hub has said which profile it speaks.
395
+
396
+ Args:
397
+ path: The script to compile.
398
+ roots: The directories module names are looked for in.
399
+
400
+ Returns:
401
+ The compiled program.
402
+
403
+ Raises:
404
+ CompileError: If the file cannot be read or the compiler rejects it.
405
+ """
406
+ program = await compile_program(path, roots=roots)
407
+ return program.pack()