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.
- pybricks_client-1.0.0b1/LICENSE +21 -0
- pybricks_client-1.0.0b1/PKG-INFO +57 -0
- pybricks_client-1.0.0b1/README.md +26 -0
- pybricks_client-1.0.0b1/pyproject.toml +56 -0
- pybricks_client-1.0.0b1/pyproject.toml.orig +44 -0
- pybricks_client-1.0.0b1/src/pb_client/__init__.py +70 -0
- pybricks_client-1.0.0b1/src/pb_client/autostart.py +216 -0
- pybricks_client-1.0.0b1/src/pb_client/compile.py +407 -0
- pybricks_client-1.0.0b1/src/pb_client/errors.py +66 -0
- pybricks_client-1.0.0b1/src/pb_client/models.py +310 -0
- pybricks_client-1.0.0b1/src/pb_client/py.typed +0 -0
- pybricks_client-1.0.0b1/src/pb_client/service.py +742 -0
- pybricks_client-1.0.0b1/src/pb_client/stdio.py +150 -0
|
@@ -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
|