Metadata-Version: 2.5
Name: instabox-sdk
Version: 0.3.2
Summary: Python SDK for instabox sandboxes
Project-URL: Homepage, https://instabox.sh
Project-URL: Source, https://github.com/InsForge/instabox/tree/main/sdk/python
License: Apache-2.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: mypy<3,>=1.8; extra == 'dev'
Requires-Dist: pytest<10,>=7; extra == 'dev'
Requires-Dist: ruff<0.18,>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# instabox Python SDK

A small, typed Python client for [instabox](https://instabox.sh) sandboxes: create a
sandbox, run commands in it, move files, pause and resume it, snapshot it and delete it.
Python 3.9-3.13, one dependency (`httpx`). It installs as `instabox-sdk` and imports as
`instabox` (the `instabox` project on PyPI is someone else's):

```sh
pip install instabox-sdk
```

It is on PyPI as [`instabox-sdk`](https://pypi.org/project/instabox-sdk/). A version bump in
`pyproject.toml` merged to main publishes a new release
(`.github/workflows/sdk-python-release.yml`; see [Publishing](#publishing)).

## Quickstart

Create an API key on **API keys** in the [dashboard](https://dashboard.instabox.sh)
(**Full access**, or a restricted key with `machines:write` and `machines:delete`, which is
enough for the quickstart; the `snapshots` calls also need `images:write` (create),
`images:read` (list and get) and `images:delete` (delete)), then:

```sh
export INSTABOX_API_KEY=ibk_...
```

```python
from instabox import Client

client = Client()  # reads INSTABOX_API_KEY (and INSTABOX_API_URL, default https://api.instabox.sh)

box = client.machines.create(name="my-box")  # template agent-browser; waits until ready
try:
    r = client.machines.exec(box.id, "uname -a")
    print(r.stdout, end="")

    r = client.machines.exec(box.id, ["sh", "-c", "echo oops >&2; exit 3"])
    print(r.exit_code, r.stderr, end="")  # 3 oops
finally:
    client.machines.delete(box.id)
```

A sandbox pauses after 30 idle minutes by default. To pick one up later, whatever state it is
in, and keep working (this block runs on its own: it makes `my-box` first, as an earlier run
would have, and deletes it at the end):

```python
from instabox import Client

client = Client()
client.machines.create(name="my-box")  # stands in for a sandbox made earlier
try:
    box = client.machines.ensure_running("my-box")  # starts or resumes it, waits until ready
    print(client.machines.exec(box.id, "ls /data/home").stdout, end="")
    client.machines.update(box.id, metadata={"stage": "review"})  # labels: any plan
    # Paid plans only; on the free plan another idle time is 403 spec_not_allowed:
    # client.machines.update(box.id, auto_suspend_after_seconds=600)
finally:
    client.machines.delete("my-box", confirm="my-box")  # refuses to delete anything but my-box
```

## Calls

`client = Client(api_key=None, *, base_url=None, timeout=30.0, http_client=None)`

| Call | Returns |
|---|---|
| `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"`) |
| `client.machines.list(*, metadata=None)` | every `Machine` of your orgs (all pages) |
| `client.machines.list_page(*, cursor=None, limit=None, metadata=None)` | `MachinePage(machines, next_cursor)` |
| `client.machines.get(machine)` | `Machine` (by ID or name) |
| `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) |
| `client.machines.exec(machine, command, *, cwd=None, env=None, stdin=None, timeout=60)` | `ExecResult(stdout, stderr, exit_code, timed_out, truncated, signal)` |
| `client.machines.start(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
| `client.machines.stop(machine, *, shutdown_timeout=None, wait=True, timeout=600)` | `Operation` |
| `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` |
| `client.machines.delete(machine, *, confirm=None, wait=True, timeout=600)` | `Operation` |
| `client.machines.pause(machine, *, wait=True, timeout=600)` | `Operation`; the sandbox is `suspended`, memory and programs kept |
| `client.machines.resume(machine, *, wait=True, timeout=600)` | `Operation`; with `wait`, the sandbox is running and ready |
| `client.machines.wake(machine)` | `Machine`; an idle-paused sandbox is resumed first (up to 45 s) |
| `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) |
| `client.machines.wait_ready(machine, *, timeout=300)` | `Machine` once running and ready |
| `client.operations.get(operation_id)` / `client.operations.wait(operation_id, *, timeout=600)` | `Operation` |
| `client.templates.list()` | `[Template]` |
| `client.snapshots.list()` / `get(snapshot_id)` | `[Snapshot]` / `Snapshot` |
| `client.snapshots.create(machine, name, *, description=None, root_only=False)` | `Snapshot`; `Snapshot.status` is `pending` while a running sandbox's copy uploads, then `ready` (or `failed`, with `error`): poll `get` until it is `ready` |
| `client.snapshots.delete(snapshot_id, *, confirm=None)` | `None` |
| `client.files.write(machine, path, data, *, mode=None, parents=True, timeout=300)` | `FileWritten(path, size, sha256, mode, created)` |
| `client.files.read(machine, path)` / `read_bytes(machine, path)` | `str` (UTF-8) / `bytes` |
| `client.files.list(machine, path)` | `[FileEntry(name, type, size, mode, mod_time, target)]` |
| `client.files.exists(machine, path)` / `remove(machine, path)` | `bool` / `None` (`rm -rf`) |

- `machine` is a sandbox ID (`m_...`) or its name. `create` without a name picks `py-<random>`.
  Without `template` or `snapshot_id` it uses `agent-browser`, the default template; `template`
  may pin a version as `id@version` (`agent-browser@2026-10-15.4`).
- `create` options: `auto_suspend_after_seconds` is the idle pause (default 30 minutes; a paid
  sandbox takes 300-1800 in whole minutes or 0 for never, where the plan allows; the free sandbox is fixed at
  1800, and another value is `403 spec_not_allowed`, `PermissionDeniedError`). `archive_seconds`
  stops and `delete_after_seconds` deletes the sandbox that many seconds (60-86400) after creation, so it cannot outlive a client that died.
  `desired_state="stopped"` creates it stopped (nothing to wait for). `fail_fast=True` has a
  create no host has room for refused at once (`503 no_capacity`, `ServerError`, nothing
  created) instead of returning an operation that then fails (`OperationFailedError`).
- `update` is `PATCH /v1/machines/{id}`: `name`, `metadata` (replaces it; `{}` clears) and
  `auto_suspend_after_seconds` apply in any state; `vcpus`/`mem_mib` (another of the
  workspace's sizes) need the sandbox stopped and apply at its next start;
  `persistent_disk_gib` grows a running sandbox's disk online (a stopped one's at its next
  start) and shrinks only while stopped: `resize` wraps it. Absent fields are unchanged.
- `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`.
- `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`.
- `exec` takes a shell string, run with `bash -lc` as the sandbox's user (`dev`, HOME
  `/data/home`), or an argv list, run with no shell (the API's `argv`: each element reaches
  the program exactly as sent, no `$` expansion, globbing or word splitting; a program that is
  not found exits 127). A non-zero exit code is a result, not an error. When `timeout`
  (seconds, at most 600) passes, the command is killed and the result has `timed_out=True`
  and `exit_code=124`. `exec` is not idempotent, so it is never retried. The sandbox must be
  running (`409 machine_not_running`, `ConflictError`): `ensure_running` first when it may
  have been paused or stopped.
- `wake` is for a sandbox that pauses itself when idle: it records activity on a running one
  or waits for the automatic pause and its resume to finish, so the next `exec` finds it
  running. A sandbox paused or stopped on purpose is not woken; `start` (or `ensure_running`)
  does that.
- `delete(confirm=...)` sends `X-Confirm-Delete` with the sandbox's ID or exact name; any
  other value is `409 delete_confirmation_required` (`ConflictError`) and nothing is deleted.
  Without `confirm`, `delete` sends the `machine` it was given (ID or name).
  `snapshots.delete` takes the same `confirm`.
- `create`, `start`, `stop`, `delete`, `update` and the snapshot calls send an `Idempotency-Key`
  (random per call). Pass `idempotency_key=` to retry a call safely: the same key and request
  return the original response, so a retried create returns the same sandbox.
- `files` paths are absolute. `write` and `read` stream any size (up to 5 GiB) as the
  sandbox's user, `write` replaces the file atomically and `parents` creates missing
  directories; a missing file is `NotFoundError`, a directory `ConflictError` (`not_a_file`).
- `Machine` has `id`, `name`, `state` (`creating`, `starting`, `running`, `stopping`,
  `stopped`, `suspending`, `suspended`, `resuming`, `deleting`, `deleted`, `error`), `ready`
  and `running` (running and ready), `template_id`, `template_version`, `snapshot_id`,
  `spec`, `metadata`, `auto_suspend_after_seconds` (`None`: never; sent as `0`, reported as `None`), `max_run_minutes`,
  `archive_at`, `delete_at`, `guest_user`, `processes_preserved` (after a start or resume:
  `False` when the paused programs could not be brought back), `free`, `org_id`,
  `operation_id`, `last_error`, `created_at` and `updated_at`.
- `Snapshot` has `id`, `name`, `status` (`pending`, `ready`, `failed`), `source_machine_id`,
  `template_id`, `org_id`, `description`, `error` (why it failed) and `created_at`.
- Every model keeps the full JSON response in `.raw`.

## Errors

Every error is an `instabox.InstaboxError`. API errors are `instabox.APIError` subclasses
with the fields of the API's error body: `code`, `message`, `request_id`, `retryable`,
`operation_id`, plus `status` (HTTP) and `retry_after` (seconds to wait before retrying: the
`Retry-After` header, else the body's `retryAfterSeconds`, which is all a failed operation
has; set on `rate_limited`, retryable `no_capacity` and `machine_cooldown`).

| Status | Class | Example `code` |
|---|---|---|
| 400 | `BadRequestError` | `bad_request`, `spec_not_allowed` (a size or disk the plan does not offer) |
| 401 | `AuthenticationError` | `missing_key`, `malformed_key`, `invalid_key`, `expired_key`, `revoked_key`; `missing_api_key` when no key is set (raised by `Client()`) |
| 403 | `PermissionDeniedError` | `missing_scope`, `template_not_allowed`, `spec_not_allowed` (another idle time on the free plan) |
| 404 | `NotFoundError` | `not_found` |
| 409 | `ConflictError` | `machine_not_running`, `operation_conflict`, `idempotency_conflict` |
| 429 | `RateLimitError` | `quota_exceeded`, `rate_limited` |
| 5xx | `ServerError` | `no_capacity`, `host_unavailable` |

`spec_not_allowed` is a plan or size limit and takes the status of what was refused: `400` for a
size or disk size the plan does not offer, `403` for another idle time
(`auto_suspend_after_seconds`) on the free plan or starting a paid sandbox larger than its plan
now allows, `409` for restoring a snapshot that does not fit the sandbox's disk.

An operation that ends `failed` raises `OperationFailedError` (an `APIError` carrying the
operation's error). A wait that runs out raises `WaitTimeoutError`. A request without a
response (network error, or the client `timeout`) raises `APIConnectionError`.

```python
import instabox

try:
    instabox.Client(api_key="ibk_wrong").machines.list()
except instabox.AuthenticationError as e:
    print(repr(e))  # AuthenticationError(code='malformed_key', message=..., status=401, ...)
    # A well-formed key the server does not know is code='invalid_key'.
```

## Development

```sh
cd sdk/python
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
ruff check . && ruff format --check . && mypy --strict src/instabox && pytest
```

`tests/test_live.py` mirrors the TypeScript SDK's live test against the real API (create,
exec, files, update, pause, resume, list, delete); it is skipped unless `INSTABOX_LIVE=1`:

```sh
INSTABOX_LIVE=1 INSTABOX_API_KEY=ibk_... pytest -v tests/test_live.py  # INSTABOX_LIVE_TEMPLATE=t3 on a paid plan
```

## Publishing

`.github/workflows/sdk-python-release.yml` publishes `sdk/python` to PyPI as `instabox-sdk` when a
merge to main changes the version in `pyproject.toml`. It authenticates with PyPI trusted
publishing (OIDC), which needs a one-time setup on PyPI by a maintainer with a PyPI account
(the first publish of 0.2.0 failed for want of it):

1. On <https://pypi.org/manage/account/publishing/>, add a **pending publisher** for the
   project name `instabox-sdk` (or, once the project exists, a publisher on its *Publishing*
   page) with owner `InsForge`, repository `instabox`, workflow `sdk-python-release.yml` and
   environment `pypi`.
2. In the GitHub repository, make sure the `pypi` environment exists (*Settings* →
   *Environments*).
3. Re-run the failed `sdk-python release` run from *Actions* (`workflow_dispatch`), or merge
   the next version bump.

Without trusted publishing, a PyPI API token in the repository secret `PYPI_API_TOKEN`
(scoped to the `instabox-sdk` project) is used instead; leave it unset to use OIDC.
