pybricks-client 1.0.0b1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Pybricks Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybricks-client
3
+ Version: 1.0.0b1
4
+ Summary: Client library for the Pybricks service
5
+ Keywords: pybricks,lego,micropython,bluetooth,robotics
6
+ Author: The Pybricks Authors
7
+ Author-email: The Pybricks Authors <dev@pybricks.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: pybricks-core>=1.0.0b1
18
+ Requires-Dist: anyio>=4.14
19
+ Requires-Dist: mpy-cross-v6>=1.1.1
20
+ Requires-Dist: typing-extensions>=4.15
21
+ Maintainer: David Lechner
22
+ Maintainer-email: David Lechner <david@pybricks.com>
23
+ Requires-Python: >=3.11
24
+ Project-URL: homepage, https://pybricks.com
25
+ Project-URL: repository, https://github.com/pybricks/pybricks-tools
26
+ Project-URL: documentation, https://pybricks.com/pybricks-tools/reference/
27
+ Project-URL: Changelog, https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-client/CHANGELOG.md
28
+ Project-URL: Support, https://github.com/pybricks/support/discussions
29
+ Project-URL: Issues, https://github.com/pybricks/support/issues
30
+ Description-Content-Type: text/markdown
31
+
32
+ # pybricks-client
33
+
34
+ Talk to a [Pybricks](https://pybricks.com) hub from your own Python program.
35
+
36
+ ```python
37
+ import anyio
38
+ import pb_client
39
+
40
+
41
+ async def main() -> None:
42
+ async with pb_client.connect() as pb:
43
+ hub = await pb.connect_hub(name="Rover")
44
+ print(f"connected to {hub.name}, firmware {hub.firmware}")
45
+
46
+
47
+ anyio.run(main)
48
+ ```
49
+
50
+ The connection is held by a background service that outlives your program, so
51
+ the next one to run finds the hub already there. It is async throughout, and
52
+ everything it opens is closed for you when the `async with` block ends.
53
+
54
+ **Looking for the command line?** That is `pybricks-cli`, which installs `pb`
55
+ and uses this library underneath.
56
+
57
+ > **Status:** beta. Commands and APIs may still change before 1.0.0.
@@ -0,0 +1,26 @@
1
+ # pybricks-client
2
+
3
+ Talk to a [Pybricks](https://pybricks.com) hub from your own Python program.
4
+
5
+ ```python
6
+ import anyio
7
+ import pb_client
8
+
9
+
10
+ async def main() -> None:
11
+ async with pb_client.connect() as pb:
12
+ hub = await pb.connect_hub(name="Rover")
13
+ print(f"connected to {hub.name}, firmware {hub.firmware}")
14
+
15
+
16
+ anyio.run(main)
17
+ ```
18
+
19
+ The connection is held by a background service that outlives your program, so
20
+ the next one to run finds the hub already there. It is async throughout, and
21
+ everything it opens is closed for you when the `async with` block ends.
22
+
23
+ **Looking for the command line?** That is `pybricks-cli`, which installs `pb`
24
+ and uses this library underneath.
25
+
26
+ > **Status:** beta. Commands and APIs may still change before 1.0.0.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "pybricks-client"
3
+ version = "1.0.0b1"
4
+ description = "Client library for the Pybricks service"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ keywords = [
10
+ "pybricks",
11
+ "lego",
12
+ "micropython",
13
+ "bluetooth",
14
+ "robotics",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Education",
20
+ "Natural Language :: English",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Software Development :: Embedded Systems",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "pybricks-core>=1.0.0b1",
27
+ "anyio>=4.14",
28
+ "mpy-cross-v6>=1.1.1",
29
+ "typing-extensions>=4.15",
30
+ ]
31
+
32
+ [[project.authors]]
33
+ name = "The Pybricks Authors"
34
+ email = "dev@pybricks.com"
35
+
36
+ [[project.maintainers]]
37
+ name = "David Lechner"
38
+ email = "david@pybricks.com"
39
+
40
+ [project.urls]
41
+ homepage = "https://pybricks.com"
42
+ repository = "https://github.com/pybricks/pybricks-tools"
43
+ documentation = "https://pybricks.com/pybricks-tools/reference/"
44
+ Changelog = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-client/CHANGELOG.md"
45
+ Support = "https://github.com/pybricks/support/discussions"
46
+ Issues = "https://github.com/pybricks/support/issues"
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.12.7,<0.13.0"]
50
+ build-backend = "uv_build"
51
+
52
+ [tool.uv.build-backend]
53
+ module-name = "pb_client"
54
+
55
+ [tool.uv.sources.pybricks-core]
56
+ workspace = true
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "pybricks-client"
3
+ version = "1.0.0b1"
4
+ description = "Client library for the Pybricks service"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "The Pybricks Authors", email = "dev@pybricks.com" }]
9
+ maintainers = [{ name = "David Lechner", email = "david@pybricks.com" }]
10
+ requires-python = ">=3.11"
11
+ keywords = ["pybricks", "lego", "micropython", "bluetooth", "robotics"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: Education",
16
+ "Natural Language :: English",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Embedded Systems",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "pybricks-core>=1.0.0b1",
23
+ "anyio>=4.14",
24
+ "mpy-cross-v6>=1.1.1",
25
+ "typing-extensions>=4.15",
26
+ ]
27
+
28
+ [project.urls]
29
+ homepage = "https://pybricks.com"
30
+ repository = "https://github.com/pybricks/pybricks-tools"
31
+ documentation = "https://pybricks.com/pybricks-tools/reference/"
32
+ "Changelog" = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-client/CHANGELOG.md"
33
+ "Support" = "https://github.com/pybricks/support/discussions"
34
+ "Issues" = "https://github.com/pybricks/support/issues"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.12.7,<0.13.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.build-backend]
41
+ module-name = "pb_client"
42
+
43
+ [tool.uv.sources]
44
+ pybricks-core = { workspace = true }
@@ -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
+ ]
@@ -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