Metadata-Version: 2.5
Name: instabox-sdk
Version: 0.4.0
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)`; `data` is `str`, bytes, a binary file object or an iterable of chunks |
| `client.files.read(machine, path, *, max_bytes=None)` / `read_bytes(machine, path, *, max_bytes=None)` | `str` (UTF-8) / `bytes` |
| `client.files.read_stream(machine, path, *, timeout=300)` | `FileStream`: iterate it for `bytes` chunks |
| `client.files.upload(machine, local_dir, remote_dir, *, git=True, all=False)` | `UploadResult(path, files, bytes)` |
| `client.files.download(machine, remote_dir, local_dir)` | `DownloadResult(path, remote_path, files, 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, or relative to the sandbox's HOME (`/data/home`). `write`
  replaces the file atomically as the sandbox's user and `parents` creates missing
  directories; a missing file is `NotFoundError`, a directory `ConflictError` (`not_a_file`).
  [Large files, directories and binary stdin](#large-files-directories-and-binary-stdin) has
  the rest.
- `exec`'s `stdin` is `str` or bytes and reaches the command byte for byte.
- `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`.

## Large files, directories and binary stdin

With `client` and `box` from the quickstart (local paths are relative to the current directory,
sandbox paths to HOME in the sandbox):

<!-- verbatim: tests/test_readme.py runs this block as written -->

```python
import hashlib

# Stream a file of any size (up to 5 GiB) in, and back out, without holding it in memory.
with open("dataset.bin", "rb") as f:
    client.files.write(box.id, "dataset.bin", f)
with client.files.read_stream(box.id, "dataset.bin") as src, open("copy.bin", "wb") as out:
    for chunk in src:
        out.write(chunk)

# Any iterable of bytes works too: each chunk is sent as it is made.
rows = (f"{i},{i * i}\n".encode() for i in range(100_000))
client.files.write(box.id, "squares.csv", rows)

# A whole directory, one call each way.
up = client.files.upload(box.id, "my-app", "my-app")  # UploadResult(path='/data/home/my-app', ...)
down = client.files.download(box.id, "my-app/dist", "dist")  # DownloadResult(path='dist', ...)

# Bytes on stdin arrive exactly as sent.
with open("logo.png", "rb") as f:
    logo = f.read()
r = client.machines.exec(box.id, "sha256sum", stdin=logo)
assert r.stdout.split()[0] == hashlib.sha256(logo).hexdigest()
```

`write`, `read`, `read_bytes` and `read_stream` move the bytes in one request each over the API's
file routes (`PUT /v1/machines/{id}/files` and `GET /v1/machines/{id}/files/content`), the path
`instabox cp` uses.

- `write` takes `str` (sent as UTF-8), bytes, a binary file object (`open(p, "rb")`) or any
  iterable of `bytes` (or `str`) chunks, such as a generator. A file object or an iterable is
  sent as it is read, chunked, so memory stays flat whatever its size: 100 MiB moved with a
  3 MiB peak of Python allocations (`tests/test_stream_memory.py` holds it under 16 MiB, and RSS
  growth under 64 MiB). The server takes up to 5 GiB per file (`MAX_UPLOAD_BYTES`; `too_large`
  past it). A stream can be sent only once, so before the first one the SDK checks that the
  server has the route. `timeout` is how long a transfer may make no progress.
- `write` checks the size and SHA-256 the server reports against what was sent
  (`transfer_mismatch` when they differ). An exception from your data raises `APIError` code
  `source_error` (yours as `__cause__`), and the file keeps its old content.
- `read_bytes` and `read` load the file into memory; `max_bytes` refuses a larger one with
  `file_too_large` before reading it. `read_stream` returns a `FileStream` as soon as the file
  is open (a missing file raises `NotFoundError` there, before any byte); iterate it for `bytes`
  chunks of up to 256 KiB, and use it in a `with` block (or call `close()`) if you stop early.
  `size` is the file's size. A stream that ends short of it raises `file_changed`.
- `upload(machine, local_dir, remote_dir)` and `download(machine, remote_dir, local_dir)` move a
  directory's contents as one gzipped tar, the way `instabox push` and `instabox pull` do:
  `upload` packs it locally as it streams it to a temporary file in the sandbox
  (`/tmp/.instabox-transfer-*`), then unpacks it there with `tar`; `download` packs the directory
  with `tar` in the sandbox (as root when sudo allows it, as the file routes read), streams the
  archive back and unpacks it as it arrives. Neither holds the archive in memory, and the
  temporary file is removed.
- What `upload` sends: in a git work tree, only the files git tracks (`git ls-files`), as
  `instabox push` does; `all=True` adds untracked files git does not ignore, and `git=False`
  sends everything under the directory. Outside a git work tree everything is sent, dot files
  and empty directories included. `download` brings everything under `remote_dir`.
- Symlinks are copied as symlinks (their target text as is), never followed. Sockets, FIFOs and
  devices are skipped. Permission bits are kept; owners are not. The destination and its parents
  are created; files the archive names are replaced, others kept (a merge, not a mirror).
- `download` refuses an archive entry that would land outside `local_dir` (an absolute path,
  `..`, or a path through a symlink) with `invalid_response`, and a file entry replaces a
  symlink at its path instead of writing through it. It checks each entry itself rather than
  using `tarfile`'s `filter="data"`, which Python 3.9.0-3.9.16, 3.10.0-3.10.11 and
  3.11.0-3.11.3 lack and which refuses symlinks that point outside the directory.
- `exec(..., stdin=...)`: up to 1 MiB of UTF-8 goes in the exec request. Anything else (bytes
  that are not UTF-8, or more than 1 MiB, text too) is first written to
  `/tmp/.instabox-stdin-<hex>` (mode 0600), and the command is prefixed
  `exec <FILE || exit 1; rm -f -- FILE`, as `instabox exec --stdin` does: the command reads the
  exact bytes, and the file is gone once it starts.
- On a server without the file routes, `write` of bytes, `read`, `read_bytes` and `read_stream`
  go in base64 chunks over exec (`write` capped at 10 MiB, `file_too_large`); `upload`,
  `download` and staged stdin move their file in base64 chunks of 768,000 bytes over exec, with
  no cap but slowly.
- Errors are `APIError`s with these codes: `local_not_found` and `local_io_error` (the local
  side), `not_a_directory` (a file where a directory was expected), `transfer_failed` (`tar` or
  a script failed in the sandbox; `PermissionDeniedError`, `permission_denied`, when it was
  refused access), `source_error`, `transfer_mismatch`, `file_changed`, `file_too_large`,
  `too_large`, `invalid_response`, `request_timeout` and `unsupported` (a stream on a server
  that lost its file routes mid-way). A missing sandbox directory is `NotFoundError`.

## 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). The file transfer calls raise `APIError` with the codes
[above](#large-files-directories-and-binary-stdin). 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.
