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.
- instabox_sdk-0.3.1/.gitignore +11 -0
- instabox_sdk-0.3.1/PKG-INFO +223 -0
- instabox_sdk-0.3.1/README.md +201 -0
- instabox_sdk-0.3.1/pyproject.toml +44 -0
- instabox_sdk-0.3.1/src/instabox/__init__.py +57 -0
- instabox_sdk-0.3.1/src/instabox/_client.py +906 -0
- instabox_sdk-0.3.1/src/instabox/_errors.py +155 -0
- instabox_sdk-0.3.1/src/instabox/_types.py +289 -0
- instabox_sdk-0.3.1/src/instabox/py.typed +0 -0
- instabox_sdk-0.3.1/tests/test_client.py +1429 -0
- instabox_sdk-0.3.1/tests/test_live.py +80 -0
|
@@ -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
|
+
]
|