instabox-sdk 0.3.1__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,11 @@
1
+ /node-agent
2
+ /instaboxctl
3
+ /guest-agent
4
+ /instabox-node
5
+ /instabox-guest-agent
6
+ *.ext4
7
+ /out/
8
+ /instabox
9
+ __pycache__/
10
+ /internal/termbin/bin/instabox-term-*
11
+ .instabox-part-*
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.5
2
+ Name: instabox-sdk
3
+ Version: 0.3.1
4
+ Summary: Python SDK for instabox sandboxes
5
+ Project-URL: Homepage, https://instabox.sh
6
+ Project-URL: Source, https://github.com/InsForge/instabox/tree/main/sdk/python
7
+ License: Apache-2.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: httpx<1,>=0.24
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy<3,>=1.8; extra == 'dev'
19
+ Requires-Dist: pytest<10,>=7; extra == 'dev'
20
+ Requires-Dist: ruff<0.18,>=0.5; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # instabox Python SDK
24
+
25
+ A small, typed Python client for [instabox](https://instabox.sh) sandboxes: create a
26
+ sandbox, run commands in it, move files, pause and resume it, snapshot it and delete it.
27
+ Python 3.9-3.13, one dependency (`httpx`). It installs as `instabox-sdk` and imports as
28
+ `instabox` (the `instabox` project on PyPI is someone else's):
29
+
30
+ ```sh
31
+ pip install instabox-sdk
32
+ ```
33
+
34
+ A version bump in `pyproject.toml` merged to main publishes it to PyPI
35
+ (`.github/workflows/sdk-python-release.yml`; see [Publishing](#publishing) for the one-time
36
+ PyPI setup it needs). Until the first release, install it from this repository (that needs
37
+ read access to it, since the repository is private for now):
38
+
39
+ ```sh
40
+ pip install "git+https://github.com/InsForge/instabox#subdirectory=sdk/python"
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ Create an API key on **API keys** in the [dashboard](https://dashboard.instabox.sh)
46
+ (**Full access**, or a restricted key with `machines:write` and `machines:delete`), then:
47
+
48
+ ```sh
49
+ export INSTABOX_API_KEY=ibk_...
50
+ ```
51
+
52
+ ```python
53
+ from instabox import Client
54
+
55
+ client = Client() # reads INSTABOX_API_KEY (and INSTABOX_API_URL, default https://api.instabox.sh)
56
+
57
+ box = client.machines.create(name="my-box") # template agent-browser; waits until ready
58
+ try:
59
+ r = client.machines.exec(box.id, "uname -a")
60
+ print(r.stdout, end="")
61
+
62
+ r = client.machines.exec(box.id, ["sh", "-c", "echo oops >&2; exit 3"])
63
+ print(r.exit_code, r.stderr, end="") # 3 oops
64
+ finally:
65
+ client.machines.delete(box.id)
66
+ ```
67
+
68
+ A sandbox pauses after 30 idle minutes by default. To pick one up later, whatever state it is
69
+ in, and keep working:
70
+
71
+ ```python
72
+ box = client.machines.ensure_running("my-box") # starts or resumes it if needed, waits until ready
73
+ client.machines.exec(box.id, "ls /data/home")
74
+ client.machines.update(box.id, metadata={"stage": "review"}, auto_suspend_after_seconds=600)
75
+ client.machines.delete(box.id, confirm="my-box") # refuses to delete anything but my-box
76
+ ```
77
+
78
+ ## Calls
79
+
80
+ `client = Client(api_key=None, base_url=None, timeout=30.0, http_client=None)`
81
+
82
+ | Call | Returns |
83
+ |---|---|
84
+ | `client.machines.create(name=None, *, template=None, snapshot_id=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, metadata=None, org_id=None, auto_suspend_after_seconds=None, archive_seconds=None, delete_after_seconds=None, desired_state=None, fail_fast=False, wait=True, timeout=600)` | `Machine`, running and ready when `wait` (created, when `desired_state="stopped"`) |
85
+ | `client.machines.list(*, metadata=None)` | every `Machine` of your orgs (all pages) |
86
+ | `client.machines.list_page(*, cursor=None, limit=None, metadata=None)` | `MachinePage(machines, next_cursor)` |
87
+ | `client.machines.get(machine)` | `Machine` (by ID or name) |
88
+ | `client.machines.update(machine, *, name=None, metadata=None, auto_suspend_after_seconds=None, max_run_minutes=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, idempotency_key=None)` | `Machine`, updated (`PATCH`, synchronous) |
89
+ | `client.machines.exec(machine, command, *, cwd=None, env=None, stdin=None, timeout=60)` | `ExecResult(stdout, stderr, exit_code, timed_out, truncated, signal)` |
90
+ | `client.machines.start(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
91
+ | `client.machines.stop(machine, *, shutdown_timeout=None, wait=True, timeout=600)` | `Operation` |
92
+ | `client.machines.restore(machine, snapshot=None, *, stopped=False, wait=True, timeout=600)` | `Operation`; the sandbox's disks are back to the snapshot's (default: the one it was created from), same ID and URLs; with `wait`, running and ready unless `stopped` |
93
+ | `client.machines.delete(machine, *, confirm=None, wait=True, timeout=600)` | `Operation` |
94
+ | `client.machines.pause(machine, *, wait=True, timeout=600)` | `Operation`; the sandbox is `suspended`, memory and programs kept |
95
+ | `client.machines.resume(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
96
+ | `client.machines.wake(machine)` | `Machine`; an idle-paused sandbox is resumed first (up to 45 s) |
97
+ | `client.machines.ensure_running(machine, *, timeout=600)` | `Machine`, running and ready; a stopped or paused one is started first (one still stopping or pausing is let finish, then started; a start someone else began is watched, not raised) |
98
+ | `client.machines.wait_ready(machine, *, timeout=300)` | `Machine` once running and ready |
99
+ | `client.operations.get(operation_id)` / `client.operations.wait(operation_id, *, timeout=600)` | `Operation` |
100
+ | `client.templates.list()` | `[Template]` |
101
+ | `client.snapshots.list()` / `get(snapshot_id)` | `[Snapshot]` / `Snapshot` |
102
+ | `client.snapshots.create(machine, name, *, description=None, root_only=False)` | `Snapshot` (`pending` while a running sandbox's copy uploads) |
103
+ | `client.snapshots.delete(snapshot_id, *, confirm=None)` | `None` |
104
+ | `client.files.write(machine, path, data, *, mode=None, parents=True, timeout=300)` | `FileWritten(path, size, sha256, mode, created)` |
105
+ | `client.files.read(machine, path)` / `read_bytes(machine, path)` | `str` (UTF-8) / `bytes` |
106
+ | `client.files.list(machine, path)` | `[FileEntry(name, type, size, mode, mod_time, target)]` |
107
+ | `client.files.exists(machine, path)` / `remove(machine, path)` | `bool` / `None` (`rm -rf`) |
108
+
109
+ - `machine` is a sandbox ID (`m_...`) or its name. `create` without a name picks `py-<random>`.
110
+ Without `template` or `snapshot_id` it uses `agent-browser`, the default template; `template`
111
+ may pin a version as `id@version` (`agent-browser@2026-10-15.4`).
112
+ - `create` options: `auto_suspend_after_seconds` is the idle pause (default 30 minutes; a paid
113
+ sandbox takes 300-1800 in whole minutes or 0 for never, where the plan allows; the free sandbox is fixed at
114
+ 1800). `archive_seconds` stops and `delete_after_seconds` deletes the sandbox that many
115
+ seconds (60-86400) after creation, so it cannot outlive a client that died.
116
+ `desired_state="stopped"` creates it stopped (nothing to wait for). `fail_fast=True` has a
117
+ create no host has room for refused at once (`503 no_capacity`, `ServerError`, nothing
118
+ created) instead of returning an operation that then fails (`OperationFailedError`).
119
+ - `update` is `PATCH /v1/machines/{id}`: `name`, `metadata` (replaces it; `{}` clears) and
120
+ `auto_suspend_after_seconds` apply in any state; `vcpus`/`mem_mib` (another of the
121
+ workspace's sizes) need the sandbox stopped and apply at its next start;
122
+ `persistent_disk_gib` grows a running sandbox's disk online (a stopped one's at its next
123
+ start) and shrinks only while stopped: `resize` wraps it. Absent fields are unchanged.
124
+ - `persistent_disk_gib` is sent as the API's `persistentDiskGiB`. On api.instabox.sh the disk (`/data`) comes with the size, so leave it out: another size is `400 spec_not_allowed` (`BadRequestError`). Resize a paid sandbox's disk later with `client.machines.resize(m, disk_gib, restart=False)`, which sends `PATCH /v1/machines/{id}` `{"persistentDiskGiB": N}` (1-1024 GiB; `update(m, persistent_disk_gib=N)` sends that field as is): a running sandbox grows online; shrinking needs it stopped (`restart=True` stops and restarts it) and the data plus 10% (at least 1 GiB) to fit, else `ConflictError` with code `disk_too_small`. The system disk is the template's: `rootDiskGiB` from anyone but an admin is `400 spec_not_allowed`.
125
+ - `vcpus` and `mem_mib` (the API's `vcpus` and `memMiB`) name one of the org's sizes at create. A paid sandbox changes size later, while stopped, with `client.machines.update(m, vcpus=N, mem_mib=M)`, which sends `PATCH /v1/machines/{id}` `{"vcpus": N, "memMiB": M}` (the CLI's `instabox resize <sandbox> --vcpus N --mem-mib M`; on a running or paused sandbox add `--restart`, from outside it), applied at its next start (which fails with `no_capacity` if the host has no room for it; the sandbox stays stopped at the new size). `client.machines.resize` takes only the disk. Running or paused is `409 operation_conflict` (`ConflictError`; stop a running sandbox first, start then stop a paused one); another size, or the free sandbox, `400 spec_not_allowed`.
126
+ - `exec` takes a shell string, run with `bash -lc` as the sandbox's user (`dev`, HOME
127
+ `/data/home`), or an argv list, run with no shell (the API's `argv`: each element reaches
128
+ the program exactly as sent, no `$` expansion, globbing or word splitting; a program that is
129
+ not found exits 127). A non-zero exit code is a result, not an error. When `timeout`
130
+ (seconds, at most 600) passes, the command is killed and the result has `timed_out=True`
131
+ and `exit_code=124`. `exec` is not idempotent, so it is never retried. The sandbox must be
132
+ running (`409 machine_not_running`, `ConflictError`): `ensure_running` first when it may
133
+ have been paused or stopped.
134
+ - `wake` is for a sandbox that pauses itself when idle: it records activity on a running one
135
+ or waits for the automatic pause and its resume to finish, so the next `exec` finds it
136
+ running. A sandbox paused or stopped on purpose is not woken; `start` (or `ensure_running`)
137
+ does that.
138
+ - `delete(confirm=...)` sends `X-Confirm-Delete` with the sandbox's ID or exact name; any
139
+ other value is `409 delete_confirmation_required` (`ConflictError`) and nothing is deleted.
140
+ Without `confirm`, `delete` sends the `machine` it was given (ID or name).
141
+ `snapshots.delete` takes the same `confirm`.
142
+ - `create`, `start`, `stop`, `delete`, `update` and the snapshot calls send an `Idempotency-Key`
143
+ (random per call). Pass `idempotency_key=` to retry a call safely: the same key and request
144
+ return the original response, so a retried create returns the same sandbox.
145
+ - `files` paths are absolute. `write` and `read` stream any size (up to 5 GiB) as the
146
+ sandbox's user, `write` replaces the file atomically and `parents` creates missing
147
+ directories; a missing file is `NotFoundError`, a directory `ConflictError` (`not_a_file`).
148
+ - `Machine` has `id`, `name`, `state` (`creating`, `starting`, `running`, `stopping`,
149
+ `stopped`, `suspending`, `suspended`, `resuming`, `deleting`, `deleted`, `error`), `ready`
150
+ and `running` (running and ready), `template_id`, `template_version`, `snapshot_id`,
151
+ `spec`, `metadata`, `auto_suspend_after_seconds` (`None`: never; sent as `0`, reported as `None`), `max_run_minutes`,
152
+ `archive_at`, `delete_at`, `guest_user`, `processes_preserved` (after a start or resume:
153
+ `False` when the paused programs could not be brought back), `free`, `org_id`,
154
+ `operation_id`, `last_error`, `created_at` and `updated_at`.
155
+ - Every model keeps the full JSON response in `.raw`.
156
+
157
+ ## Errors
158
+
159
+ Every error is an `instabox.InstaboxError`. API errors are `instabox.APIError` subclasses
160
+ with the fields of the API's error body: `code`, `message`, `request_id`, `retryable`,
161
+ `operation_id`, plus `status` (HTTP) and `retry_after` (seconds to wait before retrying: the
162
+ `Retry-After` header, else the body's `retryAfterSeconds`, which is all a failed operation
163
+ has; set on `rate_limited`, retryable `no_capacity` and `machine_cooldown`).
164
+
165
+ | Status | Class | Example `code` |
166
+ |---|---|---|
167
+ | 400 | `BadRequestError` | `bad_request`, `spec_not_allowed` |
168
+ | 401 | `AuthenticationError` | `unauthenticated`, `api_key_revoked`; `missing_api_key` when no key is set (raised by `Client()`) |
169
+ | 403 | `PermissionDeniedError` | `insufficient_scope`, `template_not_allowed` |
170
+ | 404 | `NotFoundError` | `not_found` |
171
+ | 409 | `ConflictError` | `machine_not_running`, `operation_conflict`, `idempotency_conflict` |
172
+ | 429 | `RateLimitError` | `quota_exceeded`, `rate_limited` |
173
+ | 5xx | `ServerError` | `no_capacity`, `host_unavailable` |
174
+
175
+ An operation that ends `failed` raises `OperationFailedError` (an `APIError` carrying the
176
+ operation's error). A wait that runs out raises `WaitTimeoutError`. A request without a
177
+ response (network error, or the client `timeout`) raises `APIConnectionError`.
178
+
179
+ ```python
180
+ import instabox
181
+
182
+ try:
183
+ instabox.Client(api_key="ibk_wrong").machines.list()
184
+ except instabox.AuthenticationError as e:
185
+ print(
186
+ repr(e)
187
+ ) # AuthenticationError(code='unauthenticated', message=..., status=401, request_id=..., ...)
188
+ ```
189
+
190
+ ## Development
191
+
192
+ ```sh
193
+ cd sdk/python
194
+ python -m venv .venv && . .venv/bin/activate
195
+ pip install -e '.[dev]'
196
+ ruff check . && ruff format --check . && mypy --strict src/instabox && pytest
197
+ ```
198
+
199
+ `tests/test_live.py` mirrors the TypeScript SDK's live test against the real API (create,
200
+ exec, files, update, pause, resume, list, delete); it is skipped unless `INSTABOX_LIVE=1`:
201
+
202
+ ```sh
203
+ INSTABOX_LIVE=1 INSTABOX_API_KEY=ibk_... pytest -v tests/test_live.py # INSTABOX_LIVE_TEMPLATE=t3 on a paid plan
204
+ ```
205
+
206
+ ## Publishing
207
+
208
+ `.github/workflows/sdk-python-release.yml` publishes `sdk/python` to PyPI as `instabox-sdk` when a
209
+ merge to main changes the version in `pyproject.toml`. It authenticates with PyPI trusted
210
+ publishing (OIDC), which needs a one-time setup on PyPI by a maintainer with a PyPI account
211
+ (the first publish of 0.2.0 failed for want of it):
212
+
213
+ 1. On <https://pypi.org/manage/account/publishing/>, add a **pending publisher** for the
214
+ project name `instabox-sdk` (or, once the project exists, a publisher on its *Publishing*
215
+ page) with owner `InsForge`, repository `instabox`, workflow `sdk-python-release.yml` and
216
+ environment `pypi`.
217
+ 2. In the GitHub repository, make sure the `pypi` environment exists (*Settings* →
218
+ *Environments*).
219
+ 3. Re-run the failed `sdk-python release` run from *Actions* (`workflow_dispatch`), or merge
220
+ the next version bump.
221
+
222
+ Without trusted publishing, a PyPI API token in the repository secret `PYPI_API_TOKEN`
223
+ (scoped to the `instabox-sdk` project) is used instead; leave it unset to use OIDC.
@@ -0,0 +1,201 @@
1
+ # instabox Python SDK
2
+
3
+ A small, typed Python client for [instabox](https://instabox.sh) sandboxes: create a
4
+ sandbox, run commands in it, move files, pause and resume it, snapshot it and delete it.
5
+ Python 3.9-3.13, one dependency (`httpx`). It installs as `instabox-sdk` and imports as
6
+ `instabox` (the `instabox` project on PyPI is someone else's):
7
+
8
+ ```sh
9
+ pip install instabox-sdk
10
+ ```
11
+
12
+ A version bump in `pyproject.toml` merged to main publishes it to PyPI
13
+ (`.github/workflows/sdk-python-release.yml`; see [Publishing](#publishing) for the one-time
14
+ PyPI setup it needs). Until the first release, install it from this repository (that needs
15
+ read access to it, since the repository is private for now):
16
+
17
+ ```sh
18
+ pip install "git+https://github.com/InsForge/instabox#subdirectory=sdk/python"
19
+ ```
20
+
21
+ ## Quickstart
22
+
23
+ Create an API key on **API keys** in the [dashboard](https://dashboard.instabox.sh)
24
+ (**Full access**, or a restricted key with `machines:write` and `machines:delete`), then:
25
+
26
+ ```sh
27
+ export INSTABOX_API_KEY=ibk_...
28
+ ```
29
+
30
+ ```python
31
+ from instabox import Client
32
+
33
+ client = Client() # reads INSTABOX_API_KEY (and INSTABOX_API_URL, default https://api.instabox.sh)
34
+
35
+ box = client.machines.create(name="my-box") # template agent-browser; waits until ready
36
+ try:
37
+ r = client.machines.exec(box.id, "uname -a")
38
+ print(r.stdout, end="")
39
+
40
+ r = client.machines.exec(box.id, ["sh", "-c", "echo oops >&2; exit 3"])
41
+ print(r.exit_code, r.stderr, end="") # 3 oops
42
+ finally:
43
+ client.machines.delete(box.id)
44
+ ```
45
+
46
+ A sandbox pauses after 30 idle minutes by default. To pick one up later, whatever state it is
47
+ in, and keep working:
48
+
49
+ ```python
50
+ box = client.machines.ensure_running("my-box") # starts or resumes it if needed, waits until ready
51
+ client.machines.exec(box.id, "ls /data/home")
52
+ client.machines.update(box.id, metadata={"stage": "review"}, auto_suspend_after_seconds=600)
53
+ client.machines.delete(box.id, confirm="my-box") # refuses to delete anything but my-box
54
+ ```
55
+
56
+ ## Calls
57
+
58
+ `client = Client(api_key=None, base_url=None, timeout=30.0, http_client=None)`
59
+
60
+ | Call | Returns |
61
+ |---|---|
62
+ | `client.machines.create(name=None, *, template=None, snapshot_id=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, metadata=None, org_id=None, auto_suspend_after_seconds=None, archive_seconds=None, delete_after_seconds=None, desired_state=None, fail_fast=False, wait=True, timeout=600)` | `Machine`, running and ready when `wait` (created, when `desired_state="stopped"`) |
63
+ | `client.machines.list(*, metadata=None)` | every `Machine` of your orgs (all pages) |
64
+ | `client.machines.list_page(*, cursor=None, limit=None, metadata=None)` | `MachinePage(machines, next_cursor)` |
65
+ | `client.machines.get(machine)` | `Machine` (by ID or name) |
66
+ | `client.machines.update(machine, *, name=None, metadata=None, auto_suspend_after_seconds=None, max_run_minutes=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, idempotency_key=None)` | `Machine`, updated (`PATCH`, synchronous) |
67
+ | `client.machines.exec(machine, command, *, cwd=None, env=None, stdin=None, timeout=60)` | `ExecResult(stdout, stderr, exit_code, timed_out, truncated, signal)` |
68
+ | `client.machines.start(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
69
+ | `client.machines.stop(machine, *, shutdown_timeout=None, wait=True, timeout=600)` | `Operation` |
70
+ | `client.machines.restore(machine, snapshot=None, *, stopped=False, wait=True, timeout=600)` | `Operation`; the sandbox's disks are back to the snapshot's (default: the one it was created from), same ID and URLs; with `wait`, running and ready unless `stopped` |
71
+ | `client.machines.delete(machine, *, confirm=None, wait=True, timeout=600)` | `Operation` |
72
+ | `client.machines.pause(machine, *, wait=True, timeout=600)` | `Operation`; the sandbox is `suspended`, memory and programs kept |
73
+ | `client.machines.resume(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
74
+ | `client.machines.wake(machine)` | `Machine`; an idle-paused sandbox is resumed first (up to 45 s) |
75
+ | `client.machines.ensure_running(machine, *, timeout=600)` | `Machine`, running and ready; a stopped or paused one is started first (one still stopping or pausing is let finish, then started; a start someone else began is watched, not raised) |
76
+ | `client.machines.wait_ready(machine, *, timeout=300)` | `Machine` once running and ready |
77
+ | `client.operations.get(operation_id)` / `client.operations.wait(operation_id, *, timeout=600)` | `Operation` |
78
+ | `client.templates.list()` | `[Template]` |
79
+ | `client.snapshots.list()` / `get(snapshot_id)` | `[Snapshot]` / `Snapshot` |
80
+ | `client.snapshots.create(machine, name, *, description=None, root_only=False)` | `Snapshot` (`pending` while a running sandbox's copy uploads) |
81
+ | `client.snapshots.delete(snapshot_id, *, confirm=None)` | `None` |
82
+ | `client.files.write(machine, path, data, *, mode=None, parents=True, timeout=300)` | `FileWritten(path, size, sha256, mode, created)` |
83
+ | `client.files.read(machine, path)` / `read_bytes(machine, path)` | `str` (UTF-8) / `bytes` |
84
+ | `client.files.list(machine, path)` | `[FileEntry(name, type, size, mode, mod_time, target)]` |
85
+ | `client.files.exists(machine, path)` / `remove(machine, path)` | `bool` / `None` (`rm -rf`) |
86
+
87
+ - `machine` is a sandbox ID (`m_...`) or its name. `create` without a name picks `py-<random>`.
88
+ Without `template` or `snapshot_id` it uses `agent-browser`, the default template; `template`
89
+ may pin a version as `id@version` (`agent-browser@2026-10-15.4`).
90
+ - `create` options: `auto_suspend_after_seconds` is the idle pause (default 30 minutes; a paid
91
+ sandbox takes 300-1800 in whole minutes or 0 for never, where the plan allows; the free sandbox is fixed at
92
+ 1800). `archive_seconds` stops and `delete_after_seconds` deletes the sandbox that many
93
+ seconds (60-86400) after creation, so it cannot outlive a client that died.
94
+ `desired_state="stopped"` creates it stopped (nothing to wait for). `fail_fast=True` has a
95
+ create no host has room for refused at once (`503 no_capacity`, `ServerError`, nothing
96
+ created) instead of returning an operation that then fails (`OperationFailedError`).
97
+ - `update` is `PATCH /v1/machines/{id}`: `name`, `metadata` (replaces it; `{}` clears) and
98
+ `auto_suspend_after_seconds` apply in any state; `vcpus`/`mem_mib` (another of the
99
+ workspace's sizes) need the sandbox stopped and apply at its next start;
100
+ `persistent_disk_gib` grows a running sandbox's disk online (a stopped one's at its next
101
+ start) and shrinks only while stopped: `resize` wraps it. Absent fields are unchanged.
102
+ - `persistent_disk_gib` is sent as the API's `persistentDiskGiB`. On api.instabox.sh the disk (`/data`) comes with the size, so leave it out: another size is `400 spec_not_allowed` (`BadRequestError`). Resize a paid sandbox's disk later with `client.machines.resize(m, disk_gib, restart=False)`, which sends `PATCH /v1/machines/{id}` `{"persistentDiskGiB": N}` (1-1024 GiB; `update(m, persistent_disk_gib=N)` sends that field as is): a running sandbox grows online; shrinking needs it stopped (`restart=True` stops and restarts it) and the data plus 10% (at least 1 GiB) to fit, else `ConflictError` with code `disk_too_small`. The system disk is the template's: `rootDiskGiB` from anyone but an admin is `400 spec_not_allowed`.
103
+ - `vcpus` and `mem_mib` (the API's `vcpus` and `memMiB`) name one of the org's sizes at create. A paid sandbox changes size later, while stopped, with `client.machines.update(m, vcpus=N, mem_mib=M)`, which sends `PATCH /v1/machines/{id}` `{"vcpus": N, "memMiB": M}` (the CLI's `instabox resize <sandbox> --vcpus N --mem-mib M`; on a running or paused sandbox add `--restart`, from outside it), applied at its next start (which fails with `no_capacity` if the host has no room for it; the sandbox stays stopped at the new size). `client.machines.resize` takes only the disk. Running or paused is `409 operation_conflict` (`ConflictError`; stop a running sandbox first, start then stop a paused one); another size, or the free sandbox, `400 spec_not_allowed`.
104
+ - `exec` takes a shell string, run with `bash -lc` as the sandbox's user (`dev`, HOME
105
+ `/data/home`), or an argv list, run with no shell (the API's `argv`: each element reaches
106
+ the program exactly as sent, no `$` expansion, globbing or word splitting; a program that is
107
+ not found exits 127). A non-zero exit code is a result, not an error. When `timeout`
108
+ (seconds, at most 600) passes, the command is killed and the result has `timed_out=True`
109
+ and `exit_code=124`. `exec` is not idempotent, so it is never retried. The sandbox must be
110
+ running (`409 machine_not_running`, `ConflictError`): `ensure_running` first when it may
111
+ have been paused or stopped.
112
+ - `wake` is for a sandbox that pauses itself when idle: it records activity on a running one
113
+ or waits for the automatic pause and its resume to finish, so the next `exec` finds it
114
+ running. A sandbox paused or stopped on purpose is not woken; `start` (or `ensure_running`)
115
+ does that.
116
+ - `delete(confirm=...)` sends `X-Confirm-Delete` with the sandbox's ID or exact name; any
117
+ other value is `409 delete_confirmation_required` (`ConflictError`) and nothing is deleted.
118
+ Without `confirm`, `delete` sends the `machine` it was given (ID or name).
119
+ `snapshots.delete` takes the same `confirm`.
120
+ - `create`, `start`, `stop`, `delete`, `update` and the snapshot calls send an `Idempotency-Key`
121
+ (random per call). Pass `idempotency_key=` to retry a call safely: the same key and request
122
+ return the original response, so a retried create returns the same sandbox.
123
+ - `files` paths are absolute. `write` and `read` stream any size (up to 5 GiB) as the
124
+ sandbox's user, `write` replaces the file atomically and `parents` creates missing
125
+ directories; a missing file is `NotFoundError`, a directory `ConflictError` (`not_a_file`).
126
+ - `Machine` has `id`, `name`, `state` (`creating`, `starting`, `running`, `stopping`,
127
+ `stopped`, `suspending`, `suspended`, `resuming`, `deleting`, `deleted`, `error`), `ready`
128
+ and `running` (running and ready), `template_id`, `template_version`, `snapshot_id`,
129
+ `spec`, `metadata`, `auto_suspend_after_seconds` (`None`: never; sent as `0`, reported as `None`), `max_run_minutes`,
130
+ `archive_at`, `delete_at`, `guest_user`, `processes_preserved` (after a start or resume:
131
+ `False` when the paused programs could not be brought back), `free`, `org_id`,
132
+ `operation_id`, `last_error`, `created_at` and `updated_at`.
133
+ - Every model keeps the full JSON response in `.raw`.
134
+
135
+ ## Errors
136
+
137
+ Every error is an `instabox.InstaboxError`. API errors are `instabox.APIError` subclasses
138
+ with the fields of the API's error body: `code`, `message`, `request_id`, `retryable`,
139
+ `operation_id`, plus `status` (HTTP) and `retry_after` (seconds to wait before retrying: the
140
+ `Retry-After` header, else the body's `retryAfterSeconds`, which is all a failed operation
141
+ has; set on `rate_limited`, retryable `no_capacity` and `machine_cooldown`).
142
+
143
+ | Status | Class | Example `code` |
144
+ |---|---|---|
145
+ | 400 | `BadRequestError` | `bad_request`, `spec_not_allowed` |
146
+ | 401 | `AuthenticationError` | `unauthenticated`, `api_key_revoked`; `missing_api_key` when no key is set (raised by `Client()`) |
147
+ | 403 | `PermissionDeniedError` | `insufficient_scope`, `template_not_allowed` |
148
+ | 404 | `NotFoundError` | `not_found` |
149
+ | 409 | `ConflictError` | `machine_not_running`, `operation_conflict`, `idempotency_conflict` |
150
+ | 429 | `RateLimitError` | `quota_exceeded`, `rate_limited` |
151
+ | 5xx | `ServerError` | `no_capacity`, `host_unavailable` |
152
+
153
+ An operation that ends `failed` raises `OperationFailedError` (an `APIError` carrying the
154
+ operation's error). A wait that runs out raises `WaitTimeoutError`. A request without a
155
+ response (network error, or the client `timeout`) raises `APIConnectionError`.
156
+
157
+ ```python
158
+ import instabox
159
+
160
+ try:
161
+ instabox.Client(api_key="ibk_wrong").machines.list()
162
+ except instabox.AuthenticationError as e:
163
+ print(
164
+ repr(e)
165
+ ) # AuthenticationError(code='unauthenticated', message=..., status=401, request_id=..., ...)
166
+ ```
167
+
168
+ ## Development
169
+
170
+ ```sh
171
+ cd sdk/python
172
+ python -m venv .venv && . .venv/bin/activate
173
+ pip install -e '.[dev]'
174
+ ruff check . && ruff format --check . && mypy --strict src/instabox && pytest
175
+ ```
176
+
177
+ `tests/test_live.py` mirrors the TypeScript SDK's live test against the real API (create,
178
+ exec, files, update, pause, resume, list, delete); it is skipped unless `INSTABOX_LIVE=1`:
179
+
180
+ ```sh
181
+ INSTABOX_LIVE=1 INSTABOX_API_KEY=ibk_... pytest -v tests/test_live.py # INSTABOX_LIVE_TEMPLATE=t3 on a paid plan
182
+ ```
183
+
184
+ ## Publishing
185
+
186
+ `.github/workflows/sdk-python-release.yml` publishes `sdk/python` to PyPI as `instabox-sdk` when a
187
+ merge to main changes the version in `pyproject.toml`. It authenticates with PyPI trusted
188
+ publishing (OIDC), which needs a one-time setup on PyPI by a maintainer with a PyPI account
189
+ (the first publish of 0.2.0 failed for want of it):
190
+
191
+ 1. On <https://pypi.org/manage/account/publishing/>, add a **pending publisher** for the
192
+ project name `instabox-sdk` (or, once the project exists, a publisher on its *Publishing*
193
+ page) with owner `InsForge`, repository `instabox`, workflow `sdk-python-release.yml` and
194
+ environment `pypi`.
195
+ 2. In the GitHub repository, make sure the `pypi` environment exists (*Settings* →
196
+ *Environments*).
197
+ 3. Re-run the failed `sdk-python release` run from *Actions* (`workflow_dispatch`), or merge
198
+ the next version bump.
199
+
200
+ Without trusted publishing, a PyPI API token in the repository secret `PYPI_API_TOKEN`
201
+ (scoped to the `instabox-sdk` project) is used instead; leave it unset to use OIDC.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "instabox-sdk"
7
+ version = "0.3.1"
8
+ description = "Python SDK for instabox sandboxes"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ dependencies = ["httpx>=0.24,<1"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7,<10", "ruff>=0.5,<0.18", "mypy>=1.8,<3"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://instabox.sh"
28
+ Source = "https://github.com/InsForge/instabox/tree/main/sdk/python"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/instabox"]
32
+
33
+ [tool.ruff]
34
+ target-version = "py39"
35
+ line-length = 100
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "I", "UP", "B"]
39
+ # typing.List/Optional: Machines.list and friends would shadow the builtin in annotations.
40
+ ignore = ["UP006", "UP007", "UP035", "UP045"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ markers = ["live: runs against a real API (INSTABOX_LIVE=1 and INSTABOX_API_KEY)"]
@@ -0,0 +1,57 @@
1
+ from ._client import DEFAULT_BASE_URL, DEFAULT_TEMPLATE, Client
2
+ from ._errors import (
3
+ APIConnectionError,
4
+ APIError,
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ ConflictError,
8
+ InstaboxError,
9
+ NotFoundError,
10
+ OperationFailedError,
11
+ PermissionDeniedError,
12
+ RateLimitError,
13
+ ServerError,
14
+ WaitTimeoutError,
15
+ )
16
+ from ._types import (
17
+ ErrorInfo,
18
+ ExecResult,
19
+ FileEntry,
20
+ FileWritten,
21
+ Machine,
22
+ MachinePage,
23
+ MachineSpec,
24
+ Operation,
25
+ Snapshot,
26
+ Template,
27
+ )
28
+
29
+ __version__ = "0.3.1"
30
+
31
+ __all__ = [
32
+ "DEFAULT_BASE_URL",
33
+ "DEFAULT_TEMPLATE",
34
+ "APIConnectionError",
35
+ "APIError",
36
+ "AuthenticationError",
37
+ "BadRequestError",
38
+ "Client",
39
+ "ConflictError",
40
+ "ErrorInfo",
41
+ "ExecResult",
42
+ "FileEntry",
43
+ "FileWritten",
44
+ "InstaboxError",
45
+ "Machine",
46
+ "MachinePage",
47
+ "MachineSpec",
48
+ "NotFoundError",
49
+ "Operation",
50
+ "OperationFailedError",
51
+ "PermissionDeniedError",
52
+ "RateLimitError",
53
+ "ServerError",
54
+ "Snapshot",
55
+ "Template",
56
+ "WaitTimeoutError",
57
+ ]