isb-sdk 0.2.0__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.
- isb/__init__.py +99 -0
- isb/_builders.py +113 -0
- isb/_client.py +429 -0
- isb/_errors.py +95 -0
- isb/_project.py +149 -0
- isb/_sandbox.py +550 -0
- isb/_spec.py +261 -0
- isb/_types.py +163 -0
- isb/_util.py +33 -0
- isb/py.typed +0 -0
- isb/volumes.py +73 -0
- isb_sdk-0.2.0.dist-info/METADATA +321 -0
- isb_sdk-0.2.0.dist-info/RECORD +15 -0
- isb_sdk-0.2.0.dist-info/WHEEL +4 -0
- isb_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
isb/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Python SDK for isb: declarative incus sandboxes.
|
|
2
|
+
|
|
3
|
+
A thin asyncio client of `isb rpc` (docs/rpc.md). Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
import asyncio, isb
|
|
6
|
+
|
|
7
|
+
async def main():
|
|
8
|
+
async with isb.Client() as client:
|
|
9
|
+
sb = await isb.Sandbox.create("demo", image="dev-base", client=client)
|
|
10
|
+
out = await sb.exec("uname", ["-a"])
|
|
11
|
+
print(out.stdout_text)
|
|
12
|
+
await sb.remove(force=True)
|
|
13
|
+
|
|
14
|
+
asyncio.run(main())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from . import volumes
|
|
18
|
+
from ._builders import NamedVolumeMode, PortBinding, Volume
|
|
19
|
+
from ._client import PROTOCOL, Client, EventHandler, default_client, find_binary, set_default_client
|
|
20
|
+
from ._errors import (
|
|
21
|
+
AlreadyExistsError,
|
|
22
|
+
ApiError,
|
|
23
|
+
BinaryNotFoundError,
|
|
24
|
+
ConnectError,
|
|
25
|
+
InvalidError,
|
|
26
|
+
IsbError,
|
|
27
|
+
IsbTimeoutError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
NotReadyError,
|
|
30
|
+
ProcessError,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
)
|
|
33
|
+
from ._project import Project
|
|
34
|
+
from ._sandbox import ExecProcess, ProgressCallback, Sandbox, prune
|
|
35
|
+
from ._spec import (
|
|
36
|
+
ComposeFile,
|
|
37
|
+
ExecDefaults,
|
|
38
|
+
IdmapSpec,
|
|
39
|
+
NamedVolumeSpec,
|
|
40
|
+
PortSpec,
|
|
41
|
+
ReadyCheck,
|
|
42
|
+
SandboxSpec,
|
|
43
|
+
SandboxSpecFields,
|
|
44
|
+
VolumeSpec,
|
|
45
|
+
)
|
|
46
|
+
from ._types import Action, ApplyReport, ExecEvent, ExecOutput, Plan, PruneResult, SandboxInfo, VolumeInfo
|
|
47
|
+
from ._util import Duration
|
|
48
|
+
from .volumes import Volumes
|
|
49
|
+
|
|
50
|
+
__version__ = "0.2.0"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"PROTOCOL",
|
|
54
|
+
"Action",
|
|
55
|
+
"AlreadyExistsError",
|
|
56
|
+
"ApiError",
|
|
57
|
+
"ApplyReport",
|
|
58
|
+
"BinaryNotFoundError",
|
|
59
|
+
"Client",
|
|
60
|
+
"ComposeFile",
|
|
61
|
+
"ConnectError",
|
|
62
|
+
"Duration",
|
|
63
|
+
"EventHandler",
|
|
64
|
+
"ExecDefaults",
|
|
65
|
+
"ExecEvent",
|
|
66
|
+
"ExecOutput",
|
|
67
|
+
"ExecProcess",
|
|
68
|
+
"IdmapSpec",
|
|
69
|
+
"InvalidError",
|
|
70
|
+
"IsbError",
|
|
71
|
+
"IsbTimeoutError",
|
|
72
|
+
"NamedVolumeMode",
|
|
73
|
+
"NamedVolumeSpec",
|
|
74
|
+
"NotFoundError",
|
|
75
|
+
"NotReadyError",
|
|
76
|
+
"Plan",
|
|
77
|
+
"PortBinding",
|
|
78
|
+
"PortSpec",
|
|
79
|
+
"ProcessError",
|
|
80
|
+
"ProgressCallback",
|
|
81
|
+
"Project",
|
|
82
|
+
"ProtocolError",
|
|
83
|
+
"PruneResult",
|
|
84
|
+
"ReadyCheck",
|
|
85
|
+
"Sandbox",
|
|
86
|
+
"SandboxInfo",
|
|
87
|
+
"SandboxSpec",
|
|
88
|
+
"SandboxSpecFields",
|
|
89
|
+
"Volume",
|
|
90
|
+
"VolumeInfo",
|
|
91
|
+
"VolumeSpec",
|
|
92
|
+
"Volumes",
|
|
93
|
+
"__version__",
|
|
94
|
+
"default_client",
|
|
95
|
+
"find_binary",
|
|
96
|
+
"prune",
|
|
97
|
+
"set_default_client",
|
|
98
|
+
"volumes",
|
|
99
|
+
]
|
isb/_builders.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Builders for spec fragments. They return plain dicts (the spec TypedDicts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
from ._spec import PortSpec, Scalar, VolumeSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NamedVolumeMode(str, Enum):
|
|
12
|
+
"""How a named volume mount treats a missing volume."""
|
|
13
|
+
|
|
14
|
+
ENSURE_EXISTS = "ensure_exists"
|
|
15
|
+
"""Create the volume if it is missing (the default)."""
|
|
16
|
+
EXISTING = "existing"
|
|
17
|
+
"""The volume must already exist (`external: true`)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Volume:
|
|
21
|
+
"""Mount builders for the `volumes` spec field (keyed by guest path)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None: # pragma: no cover
|
|
24
|
+
raise TypeError("use Volume.bind(...) or Volume.named(...)")
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def bind(
|
|
28
|
+
host_path: str,
|
|
29
|
+
*,
|
|
30
|
+
readonly: bool = False,
|
|
31
|
+
device: Optional[str] = None,
|
|
32
|
+
options: Optional[Mapping[str, Scalar]] = None,
|
|
33
|
+
) -> VolumeSpec:
|
|
34
|
+
"""Bind-mount a host path. Relative paths resolve against `base_dir`
|
|
35
|
+
(default: the current directory at the time of the call)."""
|
|
36
|
+
v: VolumeSpec = {"bind": str(host_path)}
|
|
37
|
+
if readonly:
|
|
38
|
+
v["readonly"] = True
|
|
39
|
+
if device is not None:
|
|
40
|
+
v["device"] = device
|
|
41
|
+
if options:
|
|
42
|
+
v["options"] = dict(options)
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def named(
|
|
47
|
+
name: str,
|
|
48
|
+
*,
|
|
49
|
+
mode: NamedVolumeMode = NamedVolumeMode.ENSURE_EXISTS,
|
|
50
|
+
owner: Union[str, int, None] = None,
|
|
51
|
+
readonly: bool = False,
|
|
52
|
+
pool: Optional[str] = None,
|
|
53
|
+
device: Optional[str] = None,
|
|
54
|
+
options: Optional[Mapping[str, Scalar]] = None,
|
|
55
|
+
) -> VolumeSpec:
|
|
56
|
+
"""Mount a named custom volume, chowning the mount point to `owner` if set."""
|
|
57
|
+
v: VolumeSpec = {"named": name}
|
|
58
|
+
if NamedVolumeMode(mode) is NamedVolumeMode.EXISTING:
|
|
59
|
+
v["external"] = True
|
|
60
|
+
if owner is not None:
|
|
61
|
+
v["owner"] = owner
|
|
62
|
+
if readonly:
|
|
63
|
+
v["readonly"] = True
|
|
64
|
+
if pool is not None:
|
|
65
|
+
v["pool"] = pool
|
|
66
|
+
if device is not None:
|
|
67
|
+
v["device"] = device
|
|
68
|
+
if options:
|
|
69
|
+
v["options"] = dict(options)
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PortBinding:
|
|
74
|
+
"""Proxy device builders for the `ports` spec field."""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None: # pragma: no cover
|
|
77
|
+
raise TypeError("use PortBinding.host(...) or PortBinding.guest(...)")
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def host(
|
|
81
|
+
listen: str,
|
|
82
|
+
connect: str,
|
|
83
|
+
*,
|
|
84
|
+
name: Optional[str] = None,
|
|
85
|
+
search: Optional[int] = None,
|
|
86
|
+
options: Optional[Mapping[str, Scalar]] = None,
|
|
87
|
+
) -> PortSpec:
|
|
88
|
+
"""Listen on the host, connect in the guest (publish a guest port).
|
|
89
|
+
`search`: if the listen port is taken, try up to this many ports past it."""
|
|
90
|
+
p: PortSpec = {"bind": "host", "listen": listen, "connect": connect}
|
|
91
|
+
if name is not None:
|
|
92
|
+
p["name"] = name
|
|
93
|
+
if search is not None:
|
|
94
|
+
p["search"] = search
|
|
95
|
+
if options:
|
|
96
|
+
p["options"] = dict(options)
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def guest(
|
|
101
|
+
listen: str,
|
|
102
|
+
connect: str,
|
|
103
|
+
*,
|
|
104
|
+
name: Optional[str] = None,
|
|
105
|
+
options: Optional[Mapping[str, Scalar]] = None,
|
|
106
|
+
) -> PortSpec:
|
|
107
|
+
"""Listen in the guest, connect on the host (reach a host service)."""
|
|
108
|
+
p: PortSpec = {"bind": "guest", "listen": listen, "connect": connect}
|
|
109
|
+
if name is not None:
|
|
110
|
+
p["name"] = name
|
|
111
|
+
if options:
|
|
112
|
+
p["options"] = dict(options)
|
|
113
|
+
return p
|
isb/_client.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""The protocol client: one long-lived `isb rpc` subprocess per Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union
|
|
13
|
+
|
|
14
|
+
from ._errors import BinaryNotFoundError, IsbError, ProcessError, ProtocolError, error_from_json
|
|
15
|
+
from ._util import Duration, duration
|
|
16
|
+
|
|
17
|
+
PROTOCOL = 1
|
|
18
|
+
"""The protocol version this SDK speaks."""
|
|
19
|
+
|
|
20
|
+
EventHandler = Callable[[str, Any], None]
|
|
21
|
+
"""Called with (event name, data) for each event line of a request."""
|
|
22
|
+
|
|
23
|
+
# One reply can carry a whole exec's output (base64), so lines may be large.
|
|
24
|
+
_LINE_LIMIT = 1 << 30
|
|
25
|
+
_STDERR_TAIL = 16 * 1024
|
|
26
|
+
_HELLO_TIMEOUT = 30.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_binary(isb_bin: Union[str, "os.PathLike[str]", None] = None) -> str:
|
|
30
|
+
"""Locate the isb binary: `isb_bin`, else `$ISB_BIN`, else the binary bundled
|
|
31
|
+
in this package (`isb/_bin/isb`), else `isb` on PATH."""
|
|
32
|
+
if isb_bin:
|
|
33
|
+
return os.fspath(isb_bin)
|
|
34
|
+
env = os.environ.get("ISB_BIN")
|
|
35
|
+
if env:
|
|
36
|
+
return env
|
|
37
|
+
bundled = Path(__file__).resolve().parent / "_bin" / "isb"
|
|
38
|
+
if bundled.is_file() and os.access(bundled, os.X_OK):
|
|
39
|
+
return str(bundled)
|
|
40
|
+
found = shutil.which("isb")
|
|
41
|
+
if found:
|
|
42
|
+
return found
|
|
43
|
+
raise BinaryNotFoundError(
|
|
44
|
+
"binary_not_found",
|
|
45
|
+
"no isb binary: pass isb_bin=, set ISB_BIN, install a wheel that bundles it, or put isb on PATH",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Pending:
|
|
50
|
+
__slots__ = ("future", "method", "on_event")
|
|
51
|
+
|
|
52
|
+
def __init__(self, future: "asyncio.Future[Any]", on_event: Optional[EventHandler], method: str) -> None:
|
|
53
|
+
self.future = future
|
|
54
|
+
self.on_event = on_event
|
|
55
|
+
self.method = method
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Client:
|
|
59
|
+
"""A connection to `isb rpc`.
|
|
60
|
+
|
|
61
|
+
The subprocess starts on first use and lives until `close()`. Requests run
|
|
62
|
+
concurrently over it; replies and events are matched by request id.
|
|
63
|
+
|
|
64
|
+
`socket`, `project` and `create_timeout` become the global flags
|
|
65
|
+
`--socket`, `--project` and `--create-timeout` of `isb rpc`.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
isb_bin: Union[str, "os.PathLike[str]", None] = None,
|
|
71
|
+
socket: Optional[str] = None,
|
|
72
|
+
project: Optional[str] = None,
|
|
73
|
+
create_timeout: Optional[Duration] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._isb_bin = isb_bin
|
|
76
|
+
self.socket = socket
|
|
77
|
+
self.project = project
|
|
78
|
+
self.create_timeout = create_timeout
|
|
79
|
+
self._proc: Optional[asyncio.subprocess.Process] = None
|
|
80
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
81
|
+
self._reader: Optional["asyncio.Task[None]"] = None
|
|
82
|
+
self._stderr_task: Optional["asyncio.Task[None]"] = None
|
|
83
|
+
self._stderr = bytearray()
|
|
84
|
+
self._pending: Dict[int, _Pending] = {}
|
|
85
|
+
self._ids = itertools.count(1)
|
|
86
|
+
self._start_lock: Optional[asyncio.Lock] = None
|
|
87
|
+
self._write_lock: Optional[asyncio.Lock] = None
|
|
88
|
+
self._hello: Optional[Dict[str, Any]] = None
|
|
89
|
+
self._dead: Optional[IsbError] = None
|
|
90
|
+
self._closed = False
|
|
91
|
+
|
|
92
|
+
# -- lifecycle ---------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def argv(self) -> List[str]:
|
|
95
|
+
"""The command line this client runs."""
|
|
96
|
+
argv = [find_binary(self._isb_bin)]
|
|
97
|
+
if self.socket:
|
|
98
|
+
argv += ["--socket", str(self.socket)]
|
|
99
|
+
if self.project:
|
|
100
|
+
argv += ["--project", self.project]
|
|
101
|
+
ct = duration(self.create_timeout)
|
|
102
|
+
if ct:
|
|
103
|
+
argv += ["--create-timeout", ct]
|
|
104
|
+
return [*argv, "rpc"]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def server_version(self) -> Optional[str]:
|
|
108
|
+
"""The isb version from the server's hello line (None before start)."""
|
|
109
|
+
return None if self._hello is None else str(self._hello.get("isb"))
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def closed(self) -> bool:
|
|
113
|
+
return self._closed
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def running(self) -> bool:
|
|
117
|
+
"""True while the subprocess is up and answering."""
|
|
118
|
+
return self._proc is not None and self._dead is None and not self._closed
|
|
119
|
+
|
|
120
|
+
async def start(self) -> None:
|
|
121
|
+
"""Start the subprocess now instead of on the first request."""
|
|
122
|
+
if self._closed:
|
|
123
|
+
raise ProcessError("process", "client is closed")
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
if self._loop is not None and self._loop is not loop:
|
|
126
|
+
raise ProcessError(
|
|
127
|
+
"process",
|
|
128
|
+
"this Client was started on another event loop; create one Client per loop",
|
|
129
|
+
)
|
|
130
|
+
if self._start_lock is None:
|
|
131
|
+
self._start_lock = asyncio.Lock()
|
|
132
|
+
async with self._start_lock:
|
|
133
|
+
if self._dead is not None:
|
|
134
|
+
raise self._dead
|
|
135
|
+
if self._proc is not None:
|
|
136
|
+
return
|
|
137
|
+
await self._spawn(loop)
|
|
138
|
+
|
|
139
|
+
async def _spawn(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
140
|
+
argv = self.argv()
|
|
141
|
+
try:
|
|
142
|
+
proc = await asyncio.create_subprocess_exec(
|
|
143
|
+
*argv,
|
|
144
|
+
stdin=asyncio.subprocess.PIPE,
|
|
145
|
+
stdout=asyncio.subprocess.PIPE,
|
|
146
|
+
stderr=asyncio.subprocess.PIPE,
|
|
147
|
+
limit=_LINE_LIMIT,
|
|
148
|
+
)
|
|
149
|
+
except OSError as e:
|
|
150
|
+
raise ProcessError("process", f"cannot start {argv[0]}: {e}") from e
|
|
151
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
152
|
+
self._loop = loop
|
|
153
|
+
self._stderr_task = loop.create_task(self._read_stderr(proc.stderr))
|
|
154
|
+
try:
|
|
155
|
+
line = await asyncio.wait_for(proc.stdout.readline(), _HELLO_TIMEOUT)
|
|
156
|
+
except asyncio.TimeoutError:
|
|
157
|
+
await self._kill(proc)
|
|
158
|
+
raise ProcessError("process", f"no hello from {' '.join(argv)} within {_HELLO_TIMEOUT:g}s") from None
|
|
159
|
+
if not line:
|
|
160
|
+
code = await self._reap(proc)
|
|
161
|
+
raise ProcessError(
|
|
162
|
+
"process",
|
|
163
|
+
f"{' '.join(argv)} exited (status {code}) before its hello line; "
|
|
164
|
+
f"does this isb have the `rpc` command?{self._stderr_suffix()}",
|
|
165
|
+
{"stderr": self._stderr_text(), "exit_code": code},
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
hello = json.loads(line)
|
|
169
|
+
proto = hello["protocol"]
|
|
170
|
+
except (ValueError, KeyError, TypeError):
|
|
171
|
+
await self._kill(proc)
|
|
172
|
+
raise ProtocolError("protocol", f"unexpected hello line from isb: {line[:200]!r}") from None
|
|
173
|
+
if proto != PROTOCOL:
|
|
174
|
+
await self._kill(proc)
|
|
175
|
+
raise ProtocolError(
|
|
176
|
+
"protocol",
|
|
177
|
+
f"isb {hello.get('isb')} speaks protocol {proto}; this SDK speaks {PROTOCOL}",
|
|
178
|
+
{"hello": hello},
|
|
179
|
+
)
|
|
180
|
+
self._hello = hello
|
|
181
|
+
self._proc = proc
|
|
182
|
+
self._write_lock = asyncio.Lock()
|
|
183
|
+
self._reader = loop.create_task(self._read_loop(proc))
|
|
184
|
+
|
|
185
|
+
async def close(self, timeout: float = 10.0) -> None:
|
|
186
|
+
"""Close stdin so the server exits, wait up to `timeout` seconds, then kill it.
|
|
187
|
+
|
|
188
|
+
Pending requests fail with ProcessError."""
|
|
189
|
+
if self._closed:
|
|
190
|
+
return
|
|
191
|
+
self._closed = True
|
|
192
|
+
proc = self._proc
|
|
193
|
+
if proc is None:
|
|
194
|
+
if self._stderr_task is not None:
|
|
195
|
+
self._stderr_task.cancel()
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
if proc.stdin is not None and not proc.stdin.is_closing():
|
|
199
|
+
proc.stdin.close()
|
|
200
|
+
except Exception: # noqa: BLE001
|
|
201
|
+
pass
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.wait_for(proc.wait(), timeout)
|
|
204
|
+
except asyncio.TimeoutError:
|
|
205
|
+
await self._kill(proc)
|
|
206
|
+
if self._reader is not None:
|
|
207
|
+
try:
|
|
208
|
+
await asyncio.wait_for(self._reader, 5)
|
|
209
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
210
|
+
pass
|
|
211
|
+
self._fail_all(ProcessError("process", "client closed"))
|
|
212
|
+
if self._stderr_task is not None:
|
|
213
|
+
self._stderr_task.cancel()
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> "Client":
|
|
216
|
+
await self.start()
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def __aexit__(
|
|
220
|
+
self,
|
|
221
|
+
exc_type: Optional[Type[BaseException]],
|
|
222
|
+
exc: Optional[BaseException],
|
|
223
|
+
tb: Optional[TracebackType],
|
|
224
|
+
) -> None:
|
|
225
|
+
await self.close()
|
|
226
|
+
|
|
227
|
+
async def _kill(self, proc: asyncio.subprocess.Process) -> None:
|
|
228
|
+
try:
|
|
229
|
+
if proc.stdin is not None:
|
|
230
|
+
proc.stdin.close()
|
|
231
|
+
except Exception: # noqa: BLE001
|
|
232
|
+
pass
|
|
233
|
+
try:
|
|
234
|
+
proc.kill()
|
|
235
|
+
except ProcessLookupError:
|
|
236
|
+
pass
|
|
237
|
+
try:
|
|
238
|
+
await asyncio.wait_for(proc.wait(), 5)
|
|
239
|
+
except asyncio.TimeoutError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
async def _reap(self, proc: asyncio.subprocess.Process) -> Optional[int]:
|
|
243
|
+
try:
|
|
244
|
+
if proc.stdin is not None:
|
|
245
|
+
proc.stdin.close()
|
|
246
|
+
except Exception: # noqa: BLE001
|
|
247
|
+
pass
|
|
248
|
+
code: Optional[int]
|
|
249
|
+
try:
|
|
250
|
+
code = await asyncio.wait_for(proc.wait(), 5)
|
|
251
|
+
except asyncio.TimeoutError:
|
|
252
|
+
await self._kill(proc)
|
|
253
|
+
code = proc.returncode
|
|
254
|
+
if self._stderr_task is not None:
|
|
255
|
+
try:
|
|
256
|
+
await asyncio.wait_for(asyncio.shield(self._stderr_task), 1)
|
|
257
|
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
258
|
+
pass
|
|
259
|
+
return code
|
|
260
|
+
|
|
261
|
+
async def _read_stderr(self, stream: asyncio.StreamReader) -> None:
|
|
262
|
+
while True:
|
|
263
|
+
chunk = await stream.read(4096)
|
|
264
|
+
if not chunk:
|
|
265
|
+
return
|
|
266
|
+
self._stderr += chunk
|
|
267
|
+
if len(self._stderr) > _STDERR_TAIL:
|
|
268
|
+
del self._stderr[: len(self._stderr) - _STDERR_TAIL]
|
|
269
|
+
|
|
270
|
+
def _stderr_text(self) -> str:
|
|
271
|
+
return self._stderr.decode(errors="replace").strip()
|
|
272
|
+
|
|
273
|
+
def _stderr_suffix(self) -> str:
|
|
274
|
+
t = self._stderr_text()
|
|
275
|
+
return f"\nstderr: {t}" if t else ""
|
|
276
|
+
|
|
277
|
+
async def _read_loop(self, proc: asyncio.subprocess.Process) -> None:
|
|
278
|
+
assert proc.stdout is not None
|
|
279
|
+
reason: Optional[IsbError] = None
|
|
280
|
+
try:
|
|
281
|
+
while True:
|
|
282
|
+
line = await proc.stdout.readline()
|
|
283
|
+
if not line:
|
|
284
|
+
break
|
|
285
|
+
try:
|
|
286
|
+
msg = json.loads(line)
|
|
287
|
+
except ValueError:
|
|
288
|
+
continue
|
|
289
|
+
if isinstance(msg, dict):
|
|
290
|
+
self._dispatch(msg)
|
|
291
|
+
except asyncio.CancelledError:
|
|
292
|
+
reason = ProcessError("process", "client closed")
|
|
293
|
+
raise
|
|
294
|
+
except Exception as e: # noqa: BLE001
|
|
295
|
+
reason = ProcessError("process", f"reading from isb rpc failed: {e}")
|
|
296
|
+
finally:
|
|
297
|
+
if reason is None:
|
|
298
|
+
if self._closed:
|
|
299
|
+
reason = ProcessError("process", "client closed")
|
|
300
|
+
else:
|
|
301
|
+
code = await self._reap(proc)
|
|
302
|
+
reason = ProcessError(
|
|
303
|
+
"process",
|
|
304
|
+
f"isb rpc exited (status {code}){self._stderr_suffix()}",
|
|
305
|
+
{"stderr": self._stderr_text(), "exit_code": code},
|
|
306
|
+
)
|
|
307
|
+
self._dead = reason
|
|
308
|
+
self._fail_all(reason)
|
|
309
|
+
|
|
310
|
+
def _dispatch(self, msg: Dict[str, Any]) -> None:
|
|
311
|
+
rid = msg.get("id")
|
|
312
|
+
if not isinstance(rid, int):
|
|
313
|
+
return # `bad_request` with id null: nothing to match.
|
|
314
|
+
p = self._pending.get(rid)
|
|
315
|
+
if p is None:
|
|
316
|
+
return # the caller gave up (cancelled) on this request
|
|
317
|
+
if "event" in msg:
|
|
318
|
+
if p.on_event is not None:
|
|
319
|
+
try:
|
|
320
|
+
p.on_event(str(msg["event"]), msg.get("data"))
|
|
321
|
+
except Exception: # noqa: BLE001
|
|
322
|
+
pass # a callback must never take down the reader
|
|
323
|
+
return
|
|
324
|
+
del self._pending[rid]
|
|
325
|
+
if p.future.done():
|
|
326
|
+
return
|
|
327
|
+
if "error" in msg:
|
|
328
|
+
p.future.set_exception(error_from_json(msg["error"]))
|
|
329
|
+
else:
|
|
330
|
+
p.future.set_result(msg.get("result"))
|
|
331
|
+
|
|
332
|
+
def _fail_all(self, err: IsbError) -> None:
|
|
333
|
+
pending, self._pending = self._pending, {}
|
|
334
|
+
for p in pending.values():
|
|
335
|
+
if not p.future.done():
|
|
336
|
+
p.future.set_exception(type(err)(err.code, f"{p.method}: {err.message}", err.data))
|
|
337
|
+
|
|
338
|
+
# -- requests ----------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
async def send(
|
|
341
|
+
self,
|
|
342
|
+
method: str,
|
|
343
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
344
|
+
*,
|
|
345
|
+
on_event: Optional[EventHandler] = None,
|
|
346
|
+
) -> Tuple[int, "asyncio.Future[Any]"]:
|
|
347
|
+
"""Send a request and return its id and a future for the final result.
|
|
348
|
+
|
|
349
|
+
Most callers want `call()`; this is for requests that are driven while
|
|
350
|
+
they run (streaming exec)."""
|
|
351
|
+
if self._proc is None or self._loop is not asyncio.get_running_loop():
|
|
352
|
+
await self.start()
|
|
353
|
+
if self._dead is not None:
|
|
354
|
+
raise self._dead
|
|
355
|
+
if self._closed:
|
|
356
|
+
raise ProcessError("process", "client is closed")
|
|
357
|
+
proc = self._proc
|
|
358
|
+
assert proc is not None and proc.stdin is not None and self._write_lock is not None
|
|
359
|
+
rid = next(self._ids)
|
|
360
|
+
req: Dict[str, Any] = {"id": rid, "method": method}
|
|
361
|
+
if params:
|
|
362
|
+
req["params"] = {k: v for k, v in params.items() if v is not None}
|
|
363
|
+
fut: "asyncio.Future[Any]" = asyncio.get_running_loop().create_future()
|
|
364
|
+
self._pending[rid] = _Pending(fut, on_event, method)
|
|
365
|
+
data = (json.dumps(req, separators=(",", ":")) + "\n").encode()
|
|
366
|
+
try:
|
|
367
|
+
async with self._write_lock:
|
|
368
|
+
proc.stdin.write(data)
|
|
369
|
+
await proc.stdin.drain()
|
|
370
|
+
except (BrokenPipeError, ConnectionResetError, RuntimeError) as e:
|
|
371
|
+
self._pending.pop(rid, None)
|
|
372
|
+
if self._dead is not None:
|
|
373
|
+
raise self._dead from e
|
|
374
|
+
raise ProcessError("process", f"{method}: cannot write to isb rpc: {e}") from e
|
|
375
|
+
return rid, fut
|
|
376
|
+
|
|
377
|
+
async def call(
|
|
378
|
+
self,
|
|
379
|
+
method: str,
|
|
380
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
381
|
+
*,
|
|
382
|
+
on_event: Optional[EventHandler] = None,
|
|
383
|
+
) -> Any:
|
|
384
|
+
"""Send a request and wait for its result. Raises the mapped IsbError.
|
|
385
|
+
|
|
386
|
+
Params whose value is None are omitted. If the caller is cancelled the
|
|
387
|
+
request keeps running in the server and its reply is ignored."""
|
|
388
|
+
rid, fut = await self.send(method, params, on_event=on_event)
|
|
389
|
+
try:
|
|
390
|
+
return await fut
|
|
391
|
+
finally:
|
|
392
|
+
if not fut.done():
|
|
393
|
+
self._pending.pop(rid, None)
|
|
394
|
+
|
|
395
|
+
async def version(self) -> Dict[str, Any]:
|
|
396
|
+
"""`{isb, protocol}` of the server."""
|
|
397
|
+
r = await self.call("version")
|
|
398
|
+
return dict(r)
|
|
399
|
+
|
|
400
|
+
async def schema(self) -> Dict[str, Any]:
|
|
401
|
+
"""The JSON Schema of the compose format."""
|
|
402
|
+
r = await self.call("schema")
|
|
403
|
+
return dict(r)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
_default: Optional[Client] = None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def default_client() -> Client:
|
|
410
|
+
"""The module-level client used when a function gets no `client=`.
|
|
411
|
+
|
|
412
|
+
Created lazily with default settings (binary from ISB_BIN, the bundled
|
|
413
|
+
binary, or PATH). A new one is made when the previous one was closed or
|
|
414
|
+
belongs to another event loop."""
|
|
415
|
+
global _default
|
|
416
|
+
try:
|
|
417
|
+
loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
|
|
418
|
+
except RuntimeError:
|
|
419
|
+
loop = None
|
|
420
|
+
c = _default
|
|
421
|
+
if c is None or c.closed or (loop is not None and c._loop is not None and c._loop is not loop):
|
|
422
|
+
c = _default = Client()
|
|
423
|
+
return c
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def set_default_client(client: Optional[Client]) -> None:
|
|
427
|
+
"""Replace the module-level default client (None resets it)."""
|
|
428
|
+
global _default
|
|
429
|
+
_default = client
|