Metadata-Version: 2.4
Name: sail-sdk
Version: 0.1.4
Summary: Python SDK for the Sail sandbox platform
Project-URL: Homepage, https://app.sailresearch.com
Project-URL: Repository, https://github.com/sailresearch/sail
Project-URL: Issues, https://github.com/sailresearch/sail/issues
Author: Sail
License-Expression: Apache-2.0
Keywords: grpc,sail,sailbox,sandbox,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
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: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: grpcio>=1.80.0
Requires-Dist: protobuf>=6.31.1
Provides-Extra: dev
Requires-Dist: grpcio-tools>=1.80.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# sail-sdk

Python SDK for Sail sailboxes.

## Install

```bash
pip install sail-sdk
```

```bash
uv add sail-sdk
```

## Configure

The SDK reads configuration from environment variables:

```bash
export SAIL_API_KEY=sk_...
```

By default the SDK talks to production.

For dev or staging, set `SAIL_MODE`:

```bash
export SAIL_MODE=staging  # or dev
```

## Create A Sailbox

```python
import sail

app = sail.App.find(name="example-app", mint_if_missing=True)

sb = sail.Sailbox.create(
    app=app,
    image=sail.Image.debian_arm64,
    name="sandbox-1",
    cpu=1,
    memory_mib=1024,
)

print(sb.sailbox_id)
print(sb.status)
print(sb.worker_address)
```

`Sailbox.create` returns after the VM is running. Supported create arguments are:

- `app`: a `sail.App` from `App.find`
- `image`: a `sail.Image` value or a built custom image
- `name`: sailbox name
- `cpu`: vCPU count, default `1`
- `memory_mib`: memory in MiB, default `1024`
- `disk_gib`: writable disk size in GiB, default `1`
- `ingress_ports`: optional list of guest ports to expose

AMD64 image values still exist in the SDK for compatibility, but new sailboxes currently must use arm64 images. The scheduler rejects AMD64 sailbox create and image build requests.

## Daemon Sailboxes

Use `Sailbox.create_daemon` when the sailbox should start and manage one long-running process. Daemon sailboxes support lifecycle operations, listeners, and outbound requests, but public `exec()` is disabled after the daemon is registered.

```python
import sail

app = sail.App.find(name="daemon-demo", mint_if_missing=True)

sb = sail.Sailbox.create_daemon(
    app=app,
    image=sail.Image.debian_arm64,
    name="daemon-1",
    command="python3 -m http.server 3000",
    checkpoint_poll_frequency_s=5,
    min_checkpoint_cooldown_s=30,
    ingress_ports=[3000],
)

print(sb.sailbox_id)
print(sb.daemon_pgid)
```

## Exec

```python
result = sb.exec("echo hi", timeout=5).wait()

print(result.stdout)
print(result.stderr)
print(result.returncode)
```

`timeout` is the command runtime budget in seconds. Omit it to let the command run without an SDK-provided runtime limit.
Exec requests use an idempotency key so transient worker-proxy connection closures can be retried safely. The SDK generates one automatically; pass `idempotency_key` when you need a stable key across process restarts.

Background exec starts a detached process and waits only for the launcher shell:

```python
sb.exec("python3 -m http.server 3000", background=True).wait()
```

Only one exec request may run at a time for a sailbox. If another exec is already active, the SDK raises `sail.SailboxExecAlreadyRunningError`.

## Networking

Expose guest ports when creating the sailbox:

```python
sb = sail.Sailbox.create(
    app=app,
    image=sail.Image.debian_arm64,
    name="sandbox-net",
    ingress_ports=[3000],
)

sb.exec("python3 -m http.server 3000", background=True).wait()

listener = sb.listener(3000)
print(listener.url)
print(listener.route_status)
listener.wait(timeout=60)
```

Listeners can front plain HTTP services, server-sent events, and WebSocket-style
upgrade traffic running inside the sailbox.

Use `listeners()` to list every exposed port:

```python
for listener in sb.listeners():
    print(listener.port, listener.url, listener.route_status)
```

Use `request()` to ask the worker proxy to make an outbound HTTP request on behalf of the sailbox:

```python
req = sb.request(
    "POST",
    "https://example.com/api",
    json={"hello": "world"},
    idempotency_key="example-1",
)

completed = req.wait()
print(completed.status)
if completed.response:
    print(completed.response.status_code)
    print(completed.response.text)
```

`idempotency_key` is required for `request()` and ensures only a single request is sent from the client side. `data` and `json` are mutually exclusive.

You can refresh, wait for, or cancel a tracked request:

```python
req.refresh()
req.wait(timeout=30)
req.cancel()
```

## Lifecycle

```python
sb.checkpoint()  # durably checkpoint while keeping the sailbox running
sb.stop()        # checkpoint and stop
sb.start()       # resume a stopped sailbox
sb.terminate()   # permanently terminate
```

After `stop()`, `exec()` raises `SailboxExecutionError` until `start()` succeeds.

## Custom Images

Start from the arm64 Debian base image, add build steps, then call `build()`:

```python
image = (
    sail.Image.debian_arm64.apt_install("git", "curl")
    .pip_install("requests")
    .run_commands("python3 -m pip show requests >/tmp/requests.txt")
    .env({"APP_ENV": "demo"})
)

built_image = image.build(timeout=1800)

sb = sail.Sailbox.create(
    app=app,
    image=built_image,
    name="custom-image-demo",
)
```

Supported image build helpers:

- `apt_install(*packages)`
- `pip_install(*packages)`
- `run_commands(*commands)`
- `env(dict[str, str])`
- `build(timeout=1800)`

## Images

Current image properties:

```python
arm64_image = sail.Image.debian_arm64
arm_image = sail.Image.debian_arm
amd64_image = sail.Image.debian_amd64
amd_image = sail.Image.debian_amd
```

Common SDK exceptions:

- `sail.SailError`
- `sail.SailboxError`
- `sail.SailboxCreationError`
- `sail.SailboxExecutionError`
- `sail.SailboxExecAlreadyRunningError`
- `sail.SailboxExecRequestNotFoundError`
- `sail.SailboxTerminatedError`
- `sail.SailboxWorkerLostError`
- `sail.ImageBuildError`

## Examples

- `examples/sailbox_smoke.py`: start an arm64 Debian sailbox and run exec commands.
- `examples/sailbox_custom_image.py`: build an arm64 custom image with `apt_install`, `pip_install`, `run_commands`, and `env`, then launch a sailbox from it.

The repo-level daemon examples under `testing/examples` include force-rebuild
knobs for validating rootfs or guest-agent changes without reusing an existing
custom image:

```bash
OPENCODE_FORCE_IMAGE_REBUILD=1 uv run --project testing python testing/examples/opencode_server.py
OPENCLAW_FORCE_IMAGE_REBUILD=1 uv run --project testing python testing/examples/openclaw_server.py
```

## Publishing

Build a distributable package locally from the repo root:

```bash
just python-sdk-build
```

Publish from a developer machine with a PyPI token:

```bash
export UV_PUBLISH_TOKEN=pypi-...
just python-sdk-publish
```

The repository also includes a GitHub Actions release workflow at `.github/workflows/python-sdk-publish.yml`.
It publishes when you push a tag like `python-sdk-v0.1.0`, after verifying that the tag version matches `sail.__version__`.

Recommended setup:

1. Create the `sail-sdk` project on PyPI.
2. Configure PyPI Trusted Publishing for this GitHub repository and the `python-sdk-publish.yml` workflow.
3. Bump `sdk/python/src/sail/__about__.py`.
4. Push a matching tag: `git tag python-sdk-v0.1.0 && git push origin python-sdk-v0.1.0`
