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 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