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 +70 -0
- pb_client/autostart.py +216 -0
- pb_client/compile.py +407 -0
- pb_client/errors.py +66 -0
- pb_client/models.py +310 -0
- pb_client/py.typed +0 -0
- pb_client/service.py +742 -0
- pb_client/stdio.py +150 -0
- pybricks_client-1.0.0b1.dist-info/METADATA +57 -0
- pybricks_client-1.0.0b1.dist-info/RECORD +12 -0
- pybricks_client-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_client-1.0.0b1.dist-info/licenses/LICENSE +21 -0
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()
|