isb-sdk 0.2.0__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,8 @@
1
+ /dist/
2
+ /build/
3
+ /.venv/
4
+ /src/isb/_bin/
5
+ __pycache__/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .ruff_cache/
isb_sdk-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Execution Associates
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.
isb_sdk-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.5
2
+ Name: isb-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for isb: declarative incus sandboxes (containers and VMs)
5
+ Project-URL: Homepage, https://github.com/execution-associates/isb
6
+ Project-URL: Repository, https://github.com/execution-associates/isb
7
+ Project-URL: Documentation, https://github.com/execution-associates/isb/tree/main/sdk/python
8
+ Author-email: Stephan Fitzpatrick <stephan@orangecountyai.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: containers,incus,lxc,sandbox,virtual-machines
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+
28
+ # isb for Python
29
+
30
+ An asyncio SDK for [isb](https://github.com/execution-associates/isb):
31
+ declarative incus sandboxes (containers and VMs). It is a thin client of
32
+ `isb rpc`, a line-delimited JSON protocol over the isb binary's stdin and stdout
33
+ ([docs/rpc.md](https://github.com/execution-associates/isb/blob/main/docs/rpc.md)).
34
+ All the work (planning, reconciling, readiness, exec) happens in isb; this
35
+ package starts it, sends requests and maps the answers to Python types.
36
+
37
+ - Python 3.10 or later, Linux.
38
+ - No runtime dependencies (standard library only).
39
+ - Typed (`py.typed`), with TypedDicts for the spec generated from `isb schema`.
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ pip install isb-sdk
45
+ ```
46
+
47
+ Platform wheels (x86_64 and aarch64 Linux) bundle a static isb binary at
48
+ `isb/_bin/isb`, so nothing else is needed. The pure wheel and the sdist do not;
49
+ they use an isb binary from elsewhere.
50
+
51
+ The binary is looked up in this order:
52
+
53
+ 1. `Client(isb_bin="/path/to/isb")`
54
+ 2. the `ISB_BIN` environment variable
55
+ 3. the bundled binary
56
+ 4. `isb` on `PATH`
57
+
58
+ It must be a version with the `rpc` command (protocol 1). The client refuses a
59
+ server that announces any other protocol version.
60
+
61
+ isb needs access to the incus socket (`$INCUS_SOCKET`, else
62
+ `$INCUS_DIR/unix.socket`, else `/var/lib/incus/unix.socket`), which usually
63
+ means membership in `incus-admin`. That access is root-equivalent on the host.
64
+
65
+ ## Quickstart
66
+
67
+ ```python
68
+ import asyncio
69
+ import isb
70
+ from isb import PortBinding, Sandbox, Volume
71
+
72
+
73
+ async def main() -> None:
74
+ async with isb.Client() as client:
75
+ sb = await Sandbox.connect_or_create(
76
+ "dev-web",
77
+ image="dev-base",
78
+ client=client,
79
+ cpus=4,
80
+ memory="4GiB",
81
+ idmap="auto",
82
+ labels={"app": "web"},
83
+ volumes={
84
+ "/home/dev/src": Volume.bind("./src", device="src"),
85
+ "/home/dev/.cache": Volume.named("dev-cache", owner="dev"),
86
+ },
87
+ ports=[
88
+ PortBinding.host(
89
+ "tcp:127.0.0.1:5173",
90
+ "tcp:127.0.0.1:5173",
91
+ name="vite",
92
+ search=20,
93
+ ),
94
+ ],
95
+ ready=["running", "default_route", {"user_exists": "dev"}],
96
+ exec={"user": "dev", "cwd": "/home/dev/src"},
97
+ on_progress=print,
98
+ )
99
+ print(sb.last_report)
100
+
101
+ # Captured output. argv is never joined into a shell string.
102
+ out = await sb.exec("printf", ["[%s]", "a b", "$HOME"])
103
+ assert out.stdout_text == "[a b][$HOME]"
104
+
105
+ # Streaming output, as it is produced.
106
+ script = "for i in 1 2 3; do echo $i; sleep 1; done"
107
+ proc = await sb.exec_stream(["sh", "-c", script])
108
+ async with proc:
109
+ async for event in proc:
110
+ print(event.kind, event.text, end="")
111
+ print("exit code:", await proc.wait())
112
+
113
+ await sb.remove(force=True)
114
+
115
+
116
+ asyncio.run(main())
117
+ ```
118
+
119
+ ### Compose files
120
+
121
+ ```python
122
+ project = await isb.Project.load("isb.yaml", vars={"WORKTREE": "/srv/wt"})
123
+
124
+ for plan in await project.plan():
125
+ print(plan.name, plan.status, plan.actions)
126
+
127
+ for service, report in await project.up(on_progress=print):
128
+ print(service, report.created, report.ports)
129
+
130
+ # A sandbox from the project carries its service's exec defaults.
131
+ web = project.sandbox("web")
132
+ await web.exec(["bun", "install"])
133
+
134
+ await project.down(volumes=True)
135
+ ```
136
+
137
+ ## API overview
138
+
139
+ ### Client
140
+
141
+ `Client(isb_bin=None, socket=None, project=None, create_timeout=None)` owns one
142
+ `isb rpc` subprocess, started on first use (or `await client.start()`) and
143
+ stopped by `await client.close()` or `async with`. `socket`, `project` and
144
+ `create_timeout` are passed as the global flags `--socket`, `--project` and
145
+ `--create-timeout`. Requests run concurrently over the one process.
146
+
147
+ Every function takes an optional `client=`. Without one it uses
148
+ `isb.default_client()`, created lazily with default settings (one per event
149
+ loop). `client.call(method, params)` sends any protocol method directly.
150
+
151
+ A client belongs to the event loop it started on. If the subprocess exits,
152
+ every pending and later request fails with `ProcessError`, whose message
153
+ includes the tail of isb's stderr.
154
+
155
+ The server's working directory is fixed when it starts, so the SDK resolves
156
+ relative paths itself: `base_dir` for bind mounts defaults to the current
157
+ directory at the time of the call, and compose file paths are made absolute.
158
+
159
+ ### Sandbox
160
+
161
+ | | |
162
+ |---|---|
163
+ | `await Sandbox.create(name, *, image, **spec)` | Create. `AlreadyExistsError` if the name is taken. |
164
+ | `await Sandbox.connect_or_create(name, *, image, prune_devices=False, **spec)` | Create or reconcile (only what differs changes). The report is on `sb.last_report`. Also `Sandbox.ensure`. |
165
+ | `await Sandbox.plan(name, *, image, **spec)` | What `connect_or_create` would do, as a `Plan`. |
166
+ | `await Sandbox.get(name)` | Handle on an existing sandbox. `NotFoundError` if missing. |
167
+ | `await Sandbox.list_with(labels)` / `Sandbox.list()` | `list[SandboxInfo]`. `labels` is `{"k": "v", "k2": None}` or `["k=v", "k2"]`. |
168
+ | `await Sandbox.remove(name, force=False)` | Delete. A running sandbox needs `force`. |
169
+
170
+ `**spec` are the `SandboxSpec` fields of the compose format
171
+ ([docs/spec.md](https://github.com/execution-associates/isb/blob/main/docs/spec.md)):
172
+ `cpus`, `memory`, `storage`, `type` (`"container"`, `"virtual-machine"` or
173
+ `"vm"`), `privileged`, `idmap`, `profiles`, `labels`, `env`, `volumes`, `ports`,
174
+ `ready`, `ready_timeout`, `exec`, `raw_config`, `raw_devices`. A full spec dict
175
+ can be passed as `spec=` instead. The server rejects unknown fields
176
+ (`InvalidError`). The create-style methods also take `base_dir`, `wait_ready`
177
+ (default true), `named_volumes` (top-level named volume definitions, as in a
178
+ compose file's `volumes:`) and `on_progress` (called with each progress line).
179
+
180
+ On an instance: `info()`, `labels()`, `start()`, `stop(force=False, timeout="30s")`,
181
+ `restart()`, `wait_ready(ready=None, ready_timeout=None)`, `remove(force=False)`,
182
+ `add_port(port)` (returns the listen address in use), `remove_device(name)`
183
+ (returns whether it existed).
184
+
185
+ A handle from `create`, `connect_or_create` or `Project.sandbox` carries the
186
+ spec's `exec` defaults (user, cwd, env, login) and sends them with every exec;
187
+ per-call arguments override them.
188
+
189
+ ### Exec
190
+
191
+ ```python
192
+ out = await sb.exec(
193
+ cmd,
194
+ args=None,
195
+ *,
196
+ cwd=None,
197
+ user=None,
198
+ env=None,
199
+ login=None,
200
+ timeout=None,
201
+ stdin=None,
202
+ tty=False,
203
+ )
204
+ ```
205
+
206
+ `cmd` is a program plus `args`, or a full argv list. Returns
207
+ `ExecOutput(exit_code, stdout: bytes, stderr: bytes)` with `stdout_text`,
208
+ `stderr_text` and `success`. A non-zero exit is not an exception. `stdin` is
209
+ bytes or str, sent followed by EOF. `timeout` is seconds or a duration string
210
+ (`"90s"`); when it passes, the command is killed and `IsbTimeoutError` is
211
+ raised. With `tty=True`, all output arrives as stdout.
212
+
213
+ ```python
214
+ p = await sb.exec_stream(cmd, args=None, *, ..., stdin=None | "piped" | bytes)
215
+ ```
216
+
217
+ returns an `ExecProcess`: iterate it for `ExecEvent(kind, data)` chunks in
218
+ order, and drive it with `await p.write(b)`, `await p.close_stdin()`,
219
+ `await p.signal(15)`, `await p.resize(w, h)`. `await p.wait()` returns the exit
220
+ code, `await p.collect()` gathers the rest into an `ExecOutput`. Used as
221
+ `async with`, it kills the command on exit if it is still running.
222
+
223
+ ### Builders
224
+
225
+ These return plain dicts for the spec:
226
+
227
+ - `Volume.bind(host_path, *, readonly=False, device=None, options=None)`
228
+ - `Volume.named(name, *, mode=NamedVolumeMode.ENSURE_EXISTS, owner=None, readonly=False, pool=None, device=None)`;
229
+ `NamedVolumeMode.EXISTING` means the volume must exist (`external: true`).
230
+ - `PortBinding.host(listen, connect, *, name=None, search=None)`: listen on the
231
+ host, connect in the guest.
232
+ - `PortBinding.guest(listen, connect, *, name=None)`: listen in the guest,
233
+ connect on the host.
234
+
235
+ ### Project
236
+
237
+ `await Project.load(files=None, *, env_files=None, project_name=None, vars=None)`
238
+ loads and resolves compose files (default `./isb.yaml`, else `./isb.yml`).
239
+ `vars` win over the environment for `${VAR}`. The result has `name`,
240
+ `base_dir`, `files`, `file` (the resolved file, every sandbox named) and
241
+ `services`. Then `up(services=None, *, prune_devices=False, wait_ready=True)`
242
+ returns `(service, ApplyReport)` pairs, `plan(...)` returns `list[Plan]`,
243
+ `down(services=None, *, volumes=False)` deletes, and `sandbox("web")` returns a
244
+ handle with that service's exec defaults.
245
+
246
+ ### Volumes and prune
247
+
248
+ `isb.volumes.list(pool=None)`, `get(name, pool=None)`,
249
+ `create(name, pool=None, *, config=None)` (returns `{"created", "pool"}`, a
250
+ no-op if it exists), `remove(name, pool=None)`. `isb.Volumes(client)` has the
251
+ same methods bound to one client. `await isb.prune(label, dry_run=True)` lists
252
+ (or, with `dry_run=False`, deletes) sandboxes whose `label` value is a host path
253
+ that no longer exists.
254
+
255
+ ### Types
256
+
257
+ `SandboxInfo`, `ApplyReport`, `Plan`, `VolumeInfo`, `PruneResult`, `ExecOutput`
258
+ and `ExecEvent` are dataclasses. Plan actions are dicts tagged by `action`.
259
+ The spec types (`SandboxSpec`, `VolumeSpec`, `PortSpec`, `ReadyCheck`,
260
+ `ExecDefaults`, `IdmapSpec`, `NamedVolumeSpec`, `ComposeFile`) are TypedDicts in
261
+ `isb._spec`, generated from the JSON Schema by `scripts/gen_types.py`:
262
+
263
+ ```sh
264
+ ISB_BIN=/path/to/isb python3 scripts/gen_types.py # regenerate
265
+ ISB_BIN=/path/to/isb python3 scripts/gen_types.py --check # fail if stale
266
+ ```
267
+
268
+ ## Errors
269
+
270
+ Every error is an `IsbError` with `code` (the protocol's stable code),
271
+ `message` and `data`:
272
+
273
+ | class | codes |
274
+ |---|---|
275
+ | `NotFoundError` | `not_found` |
276
+ | `AlreadyExistsError` | `already_exists` |
277
+ | `NotReadyError` | `not_ready` |
278
+ | `IsbTimeoutError` | `request_timeout`, `operation_timeout`, `exec_timeout` |
279
+ | `InvalidError` | `invalid`, `interpolation`, `parse` |
280
+ | `ConnectError` | `connect` (isb cannot reach incusd) |
281
+ | `ApiError` | `api`, `operation_failed` |
282
+ | `ProtocolError` | `protocol` (also unknown method), `bad_request`, a bad hello |
283
+ | `ProcessError` | `process`: the subprocess could not start or exited |
284
+ | `BinaryNotFoundError` | `binary_not_found` (a `ProcessError`) |
285
+
286
+ Other codes (`websocket`, `io`, `json`) raise `IsbError` itself.
287
+ `IsbTimeoutError` does not derive from the built-in `TimeoutError`.
288
+
289
+ ## Development
290
+
291
+ Unit tests need only an isb binary with `rpc` (they use a socket that does not
292
+ exist, and fake servers for failure paths); integration tests need incusd and a
293
+ local image with a `dev` user at uid 1000 and python3 (`ISB_TEST_IMAGE`,
294
+ default `dev-base`). Both use the standard library's `unittest`, so no install
295
+ is needed:
296
+
297
+ ```sh
298
+ # from the repository root
299
+ cargo build
300
+ ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
301
+ ISB_INTEGRATION=1 ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
302
+ ```
303
+
304
+ Integration tests name everything `isb-test-py-<pid>-...`, label it
305
+ `isb-test=py`, and remove it afterwards.
306
+
307
+ Lint, type check and build (in `sdk/python`):
308
+
309
+ ```sh
310
+ uv sync --group dev
311
+ uv run ruff check . && uv run ruff format --check .
312
+ uv run mypy
313
+ uv build # pure wheel and sdist
314
+ ISB_WHEEL_BINARY=../../target/x86_64-unknown-linux-musl/release/isb \
315
+ ISB_WHEEL_PLAT=manylinux_2_17_x86_64.musllinux_1_2_x86_64 \
316
+ uv build --wheel # platform wheel with the binary
317
+ ```
318
+
319
+ ## License
320
+
321
+ MIT
@@ -0,0 +1,294 @@
1
+ # isb for Python
2
+
3
+ An asyncio SDK for [isb](https://github.com/execution-associates/isb):
4
+ declarative incus sandboxes (containers and VMs). It is a thin client of
5
+ `isb rpc`, a line-delimited JSON protocol over the isb binary's stdin and stdout
6
+ ([docs/rpc.md](https://github.com/execution-associates/isb/blob/main/docs/rpc.md)).
7
+ All the work (planning, reconciling, readiness, exec) happens in isb; this
8
+ package starts it, sends requests and maps the answers to Python types.
9
+
10
+ - Python 3.10 or later, Linux.
11
+ - No runtime dependencies (standard library only).
12
+ - Typed (`py.typed`), with TypedDicts for the spec generated from `isb schema`.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pip install isb-sdk
18
+ ```
19
+
20
+ Platform wheels (x86_64 and aarch64 Linux) bundle a static isb binary at
21
+ `isb/_bin/isb`, so nothing else is needed. The pure wheel and the sdist do not;
22
+ they use an isb binary from elsewhere.
23
+
24
+ The binary is looked up in this order:
25
+
26
+ 1. `Client(isb_bin="/path/to/isb")`
27
+ 2. the `ISB_BIN` environment variable
28
+ 3. the bundled binary
29
+ 4. `isb` on `PATH`
30
+
31
+ It must be a version with the `rpc` command (protocol 1). The client refuses a
32
+ server that announces any other protocol version.
33
+
34
+ isb needs access to the incus socket (`$INCUS_SOCKET`, else
35
+ `$INCUS_DIR/unix.socket`, else `/var/lib/incus/unix.socket`), which usually
36
+ means membership in `incus-admin`. That access is root-equivalent on the host.
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ import asyncio
42
+ import isb
43
+ from isb import PortBinding, Sandbox, Volume
44
+
45
+
46
+ async def main() -> None:
47
+ async with isb.Client() as client:
48
+ sb = await Sandbox.connect_or_create(
49
+ "dev-web",
50
+ image="dev-base",
51
+ client=client,
52
+ cpus=4,
53
+ memory="4GiB",
54
+ idmap="auto",
55
+ labels={"app": "web"},
56
+ volumes={
57
+ "/home/dev/src": Volume.bind("./src", device="src"),
58
+ "/home/dev/.cache": Volume.named("dev-cache", owner="dev"),
59
+ },
60
+ ports=[
61
+ PortBinding.host(
62
+ "tcp:127.0.0.1:5173",
63
+ "tcp:127.0.0.1:5173",
64
+ name="vite",
65
+ search=20,
66
+ ),
67
+ ],
68
+ ready=["running", "default_route", {"user_exists": "dev"}],
69
+ exec={"user": "dev", "cwd": "/home/dev/src"},
70
+ on_progress=print,
71
+ )
72
+ print(sb.last_report)
73
+
74
+ # Captured output. argv is never joined into a shell string.
75
+ out = await sb.exec("printf", ["[%s]", "a b", "$HOME"])
76
+ assert out.stdout_text == "[a b][$HOME]"
77
+
78
+ # Streaming output, as it is produced.
79
+ script = "for i in 1 2 3; do echo $i; sleep 1; done"
80
+ proc = await sb.exec_stream(["sh", "-c", script])
81
+ async with proc:
82
+ async for event in proc:
83
+ print(event.kind, event.text, end="")
84
+ print("exit code:", await proc.wait())
85
+
86
+ await sb.remove(force=True)
87
+
88
+
89
+ asyncio.run(main())
90
+ ```
91
+
92
+ ### Compose files
93
+
94
+ ```python
95
+ project = await isb.Project.load("isb.yaml", vars={"WORKTREE": "/srv/wt"})
96
+
97
+ for plan in await project.plan():
98
+ print(plan.name, plan.status, plan.actions)
99
+
100
+ for service, report in await project.up(on_progress=print):
101
+ print(service, report.created, report.ports)
102
+
103
+ # A sandbox from the project carries its service's exec defaults.
104
+ web = project.sandbox("web")
105
+ await web.exec(["bun", "install"])
106
+
107
+ await project.down(volumes=True)
108
+ ```
109
+
110
+ ## API overview
111
+
112
+ ### Client
113
+
114
+ `Client(isb_bin=None, socket=None, project=None, create_timeout=None)` owns one
115
+ `isb rpc` subprocess, started on first use (or `await client.start()`) and
116
+ stopped by `await client.close()` or `async with`. `socket`, `project` and
117
+ `create_timeout` are passed as the global flags `--socket`, `--project` and
118
+ `--create-timeout`. Requests run concurrently over the one process.
119
+
120
+ Every function takes an optional `client=`. Without one it uses
121
+ `isb.default_client()`, created lazily with default settings (one per event
122
+ loop). `client.call(method, params)` sends any protocol method directly.
123
+
124
+ A client belongs to the event loop it started on. If the subprocess exits,
125
+ every pending and later request fails with `ProcessError`, whose message
126
+ includes the tail of isb's stderr.
127
+
128
+ The server's working directory is fixed when it starts, so the SDK resolves
129
+ relative paths itself: `base_dir` for bind mounts defaults to the current
130
+ directory at the time of the call, and compose file paths are made absolute.
131
+
132
+ ### Sandbox
133
+
134
+ | | |
135
+ |---|---|
136
+ | `await Sandbox.create(name, *, image, **spec)` | Create. `AlreadyExistsError` if the name is taken. |
137
+ | `await Sandbox.connect_or_create(name, *, image, prune_devices=False, **spec)` | Create or reconcile (only what differs changes). The report is on `sb.last_report`. Also `Sandbox.ensure`. |
138
+ | `await Sandbox.plan(name, *, image, **spec)` | What `connect_or_create` would do, as a `Plan`. |
139
+ | `await Sandbox.get(name)` | Handle on an existing sandbox. `NotFoundError` if missing. |
140
+ | `await Sandbox.list_with(labels)` / `Sandbox.list()` | `list[SandboxInfo]`. `labels` is `{"k": "v", "k2": None}` or `["k=v", "k2"]`. |
141
+ | `await Sandbox.remove(name, force=False)` | Delete. A running sandbox needs `force`. |
142
+
143
+ `**spec` are the `SandboxSpec` fields of the compose format
144
+ ([docs/spec.md](https://github.com/execution-associates/isb/blob/main/docs/spec.md)):
145
+ `cpus`, `memory`, `storage`, `type` (`"container"`, `"virtual-machine"` or
146
+ `"vm"`), `privileged`, `idmap`, `profiles`, `labels`, `env`, `volumes`, `ports`,
147
+ `ready`, `ready_timeout`, `exec`, `raw_config`, `raw_devices`. A full spec dict
148
+ can be passed as `spec=` instead. The server rejects unknown fields
149
+ (`InvalidError`). The create-style methods also take `base_dir`, `wait_ready`
150
+ (default true), `named_volumes` (top-level named volume definitions, as in a
151
+ compose file's `volumes:`) and `on_progress` (called with each progress line).
152
+
153
+ On an instance: `info()`, `labels()`, `start()`, `stop(force=False, timeout="30s")`,
154
+ `restart()`, `wait_ready(ready=None, ready_timeout=None)`, `remove(force=False)`,
155
+ `add_port(port)` (returns the listen address in use), `remove_device(name)`
156
+ (returns whether it existed).
157
+
158
+ A handle from `create`, `connect_or_create` or `Project.sandbox` carries the
159
+ spec's `exec` defaults (user, cwd, env, login) and sends them with every exec;
160
+ per-call arguments override them.
161
+
162
+ ### Exec
163
+
164
+ ```python
165
+ out = await sb.exec(
166
+ cmd,
167
+ args=None,
168
+ *,
169
+ cwd=None,
170
+ user=None,
171
+ env=None,
172
+ login=None,
173
+ timeout=None,
174
+ stdin=None,
175
+ tty=False,
176
+ )
177
+ ```
178
+
179
+ `cmd` is a program plus `args`, or a full argv list. Returns
180
+ `ExecOutput(exit_code, stdout: bytes, stderr: bytes)` with `stdout_text`,
181
+ `stderr_text` and `success`. A non-zero exit is not an exception. `stdin` is
182
+ bytes or str, sent followed by EOF. `timeout` is seconds or a duration string
183
+ (`"90s"`); when it passes, the command is killed and `IsbTimeoutError` is
184
+ raised. With `tty=True`, all output arrives as stdout.
185
+
186
+ ```python
187
+ p = await sb.exec_stream(cmd, args=None, *, ..., stdin=None | "piped" | bytes)
188
+ ```
189
+
190
+ returns an `ExecProcess`: iterate it for `ExecEvent(kind, data)` chunks in
191
+ order, and drive it with `await p.write(b)`, `await p.close_stdin()`,
192
+ `await p.signal(15)`, `await p.resize(w, h)`. `await p.wait()` returns the exit
193
+ code, `await p.collect()` gathers the rest into an `ExecOutput`. Used as
194
+ `async with`, it kills the command on exit if it is still running.
195
+
196
+ ### Builders
197
+
198
+ These return plain dicts for the spec:
199
+
200
+ - `Volume.bind(host_path, *, readonly=False, device=None, options=None)`
201
+ - `Volume.named(name, *, mode=NamedVolumeMode.ENSURE_EXISTS, owner=None, readonly=False, pool=None, device=None)`;
202
+ `NamedVolumeMode.EXISTING` means the volume must exist (`external: true`).
203
+ - `PortBinding.host(listen, connect, *, name=None, search=None)`: listen on the
204
+ host, connect in the guest.
205
+ - `PortBinding.guest(listen, connect, *, name=None)`: listen in the guest,
206
+ connect on the host.
207
+
208
+ ### Project
209
+
210
+ `await Project.load(files=None, *, env_files=None, project_name=None, vars=None)`
211
+ loads and resolves compose files (default `./isb.yaml`, else `./isb.yml`).
212
+ `vars` win over the environment for `${VAR}`. The result has `name`,
213
+ `base_dir`, `files`, `file` (the resolved file, every sandbox named) and
214
+ `services`. Then `up(services=None, *, prune_devices=False, wait_ready=True)`
215
+ returns `(service, ApplyReport)` pairs, `plan(...)` returns `list[Plan]`,
216
+ `down(services=None, *, volumes=False)` deletes, and `sandbox("web")` returns a
217
+ handle with that service's exec defaults.
218
+
219
+ ### Volumes and prune
220
+
221
+ `isb.volumes.list(pool=None)`, `get(name, pool=None)`,
222
+ `create(name, pool=None, *, config=None)` (returns `{"created", "pool"}`, a
223
+ no-op if it exists), `remove(name, pool=None)`. `isb.Volumes(client)` has the
224
+ same methods bound to one client. `await isb.prune(label, dry_run=True)` lists
225
+ (or, with `dry_run=False`, deletes) sandboxes whose `label` value is a host path
226
+ that no longer exists.
227
+
228
+ ### Types
229
+
230
+ `SandboxInfo`, `ApplyReport`, `Plan`, `VolumeInfo`, `PruneResult`, `ExecOutput`
231
+ and `ExecEvent` are dataclasses. Plan actions are dicts tagged by `action`.
232
+ The spec types (`SandboxSpec`, `VolumeSpec`, `PortSpec`, `ReadyCheck`,
233
+ `ExecDefaults`, `IdmapSpec`, `NamedVolumeSpec`, `ComposeFile`) are TypedDicts in
234
+ `isb._spec`, generated from the JSON Schema by `scripts/gen_types.py`:
235
+
236
+ ```sh
237
+ ISB_BIN=/path/to/isb python3 scripts/gen_types.py # regenerate
238
+ ISB_BIN=/path/to/isb python3 scripts/gen_types.py --check # fail if stale
239
+ ```
240
+
241
+ ## Errors
242
+
243
+ Every error is an `IsbError` with `code` (the protocol's stable code),
244
+ `message` and `data`:
245
+
246
+ | class | codes |
247
+ |---|---|
248
+ | `NotFoundError` | `not_found` |
249
+ | `AlreadyExistsError` | `already_exists` |
250
+ | `NotReadyError` | `not_ready` |
251
+ | `IsbTimeoutError` | `request_timeout`, `operation_timeout`, `exec_timeout` |
252
+ | `InvalidError` | `invalid`, `interpolation`, `parse` |
253
+ | `ConnectError` | `connect` (isb cannot reach incusd) |
254
+ | `ApiError` | `api`, `operation_failed` |
255
+ | `ProtocolError` | `protocol` (also unknown method), `bad_request`, a bad hello |
256
+ | `ProcessError` | `process`: the subprocess could not start or exited |
257
+ | `BinaryNotFoundError` | `binary_not_found` (a `ProcessError`) |
258
+
259
+ Other codes (`websocket`, `io`, `json`) raise `IsbError` itself.
260
+ `IsbTimeoutError` does not derive from the built-in `TimeoutError`.
261
+
262
+ ## Development
263
+
264
+ Unit tests need only an isb binary with `rpc` (they use a socket that does not
265
+ exist, and fake servers for failure paths); integration tests need incusd and a
266
+ local image with a `dev` user at uid 1000 and python3 (`ISB_TEST_IMAGE`,
267
+ default `dev-base`). Both use the standard library's `unittest`, so no install
268
+ is needed:
269
+
270
+ ```sh
271
+ # from the repository root
272
+ cargo build
273
+ ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
274
+ ISB_INTEGRATION=1 ISB_BIN=target/debug/isb PYTHONPATH=sdk/python/src python3 -m unittest discover -s sdk/python/tests -v
275
+ ```
276
+
277
+ Integration tests name everything `isb-test-py-<pid>-...`, label it
278
+ `isb-test=py`, and remove it afterwards.
279
+
280
+ Lint, type check and build (in `sdk/python`):
281
+
282
+ ```sh
283
+ uv sync --group dev
284
+ uv run ruff check . && uv run ruff format --check .
285
+ uv run mypy
286
+ uv build # pure wheel and sdist
287
+ ISB_WHEEL_BINARY=../../target/x86_64-unknown-linux-musl/release/isb \
288
+ ISB_WHEEL_PLAT=manylinux_2_17_x86_64.musllinux_1_2_x86_64 \
289
+ uv build --wheel # platform wheel with the binary
290
+ ```
291
+
292
+ ## License
293
+
294
+ MIT