Metadata-Version: 2.5
Name: numen-sandbox-py
Version: 0.23.0
Summary: Python client for the Numen sandbox API
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Requires-Dist: grpcio>=1.70.0
Requires-Dist: protobuf>=7.36.2
Description-Content-Type: text/markdown

# numen-sandbox-py

Python client for the Numen sandbox API.

This is a **0.x** release. Breaking changes can still ship in any version until 1.0.

## Install

```bash
pip install numen-sandbox-py
```

## Credentials

Put this file at `~/.config/numen/credentials.json`:

```json
{
  "client_id": "pcc_...",
  "client_secret": "..."
}
```

## Quickstart

`image` is required. `numen/base` is the general platform image. The `with` block deletes the sandbox when the block ends. `sb.delete()` does the same.

```python
from numen_sandbox_py import Client, Sandbox

with Client() as c, Sandbox.create(c, image="numen/base") as sb:
    print(sb.run(["python", "-c", "print(2+2)"]).stdout)
```

## Concepts

A **sandbox** is one machine with a filesystem under `/work`, processes, and outbound internet access. `Sandbox` is the handle for that machine. `Client` is the account: images, volumes, secrets, snapshots, tags, and the list of sandboxes.

A **name** is an optional lookup you choose. The service mints the id. Names are lowercase and stay until you delete the row. A second create with the same name raises `NameTakenError`; `existing_id` is the row that already has it.

**Park** stops running processes and keeps your files. A parked sandbox does not count against your CPU and memory. **Open** wakes it. **Get** reads the record and does not wake it. Reading or uploading files does not wake a parked sandbox.

A **snapshot** is a saved copy of `/work`. A **volume** is a mutable `/work` you attach when you create a sandbox. A **secret** is a named value stored for your account. Reading a secret returns its name and times, not the value. A **tag** points at a policy. Create the policy in the portal, then `client.tags.create(name, policy_id=...)`. **Labels** are a string map you replace as a whole.

CPU is `cpu_millicores`. Memory is `memory_bytes`. Omit either one on create for 500 millicores and 4 GiB.

## Where things are

Sandbox work is on `Sandbox`. Everything else is on `Client`.

| Call | What it does |
| --- | --- |
| `client.list_sandboxes` | One page of sandboxes |
| `client.list_sandboxes_iter` | Every sandbox, walking pages |
| `client.list_images_iter` | Every image, walking pages |
| `client.images.register` | Name an image that already exists |
| `client.images.build` | Build from a Dockerfile |
| `client.images.get` / `list` / `delete` | Read or remove an image |
| `client.volumes.create` / `get` / `list` / `set_labels` / `delete` | Account disks |
| `client.secrets.set` / `get` / `list` / `delete` / `list_references` | Named secret values |
| `client.snapshots.get` / `list` / `set_labels` / `delete` | Saved `/work` copies |
| `client.tags.create` / `get` / `list` / `update` / `delete` | Names that point at a policy |
| `Sandbox.create` / `open` / `get` | Create, wake, or read a sandbox |
| `sb.run` / `start` / `process` / `processes` | Run a command or keep a process |
| `sb.upload` / `read` / `write` / `mkdir` / `rm` / `ls` / `stat` / `download` / `export_path` | Files under `/work` |
| `sb.logs` / `logs_all` | Log lines |
| `sb.park` / `open` / `get` / `set_size` / `set_labels` / `set_tags` | Lifecycle and metadata |
| `sb.snapshot` / `clone` / `forward` / `wait` / `delete` | Copy, connect, wait, remove |

`c.build_image` and `c.get_secret` are not methods. Use `c.images.build` and `c.secrets.get`.

## Guides

### Your own image

`images.build` sends a Dockerfile and the directory next to it. The name you give is what you later pass to `create`.

```python
with Client() as c:
    c.images.build("my-agent", dockerfile="Dockerfile", context=".")
    with Sandbox.create(c, image="my-agent") as sb:
        print(sb.run(["python", "-c", "print(2+2)"]).stdout)
```

```dockerfile
FROM python:3.12-slim
```

`FROM` is a public image. An image you have already built or registered, including `numen/base`, cannot be the base of a new one. `images.register` names an image you can already pull, and waits until it is ready (up to `wait_sec`, default 600).

### Files

Paths are under `/work`. A relative path is placed there. `upload` writes one file, or a local directory as one tree. A directory upload replaces that guest directory. Pass `overwrite=False` to update only the paths you send.

```python
sb.upload("/work/hello.py", "print('hi')\n", mode=0o755)
sb.upload("/work/app", local="./app")
print(sb.run(["python", "/work/hello.py"]).stdout)
print(sb.read("/work/hello.py"))
```

`write` applies a list, in order. Each item is `{"mkdir": path}`, `{"remove": path, "recursive": True}`, or `{"write": {"path", "content", "mode"}}`. `mkdir` creates parents. `rm` of a missing path succeeds unless `missing_ok=False`. `download` writes a guest file or directory onto your disk. `export_path` writes a tar of a subtree (`gzip=True` for gzip).

### A process that stays up

`run` waits until the command exits. Omit `timeout` for 60 seconds. `0` does not kill the command. `start` returns while the process is still running.

```python
# Returns immediately. ends_sandbox defaults to False, so this exit leaves the sandbox up.
proc = sb.start(["python", "-u", "-c", "print(input())"])
proc.write_stdin("hello\n", eof=True)  # one line, then stdin closes and the program exits
for entry in proc.follow():  # lines until that exit
    print(entry.stream, entry.line)
proc.wait()  # already exited; the sandbox is still there
```

`follow` does not kill the process. Omit its `timeout`, or pass `0`, to read until the process exits. A timeout ends the iteration and leaves the process running. `from_start=True` yields output already saved for that process, then live output. `text=False` yields raw chunks.

### Park and open

```python
sb.park()
sb.upload("/work/note.txt", "still here\n")
sb.open()
```

Park keeps files. Running processes stop. Open after a park starts a fresh session on those files. Close a `forward` before you park, and open a new one after `open()`.

### Sizing

Omit `cpu_millicores` and `memory_bytes` on create for 500 millicores and 4 GiB. Change the size only while the sandbox is parked:

```python
sb.park()
sb.set_size(cpu_millicores=1000, memory_bytes=8 << 30)
sb.open()
```

`0` on `set_size` keeps the current value. `CapacityError` means there is not enough room right now. Wait and retry. `retry_after_sec` is set when the service tells you how long.

### Snapshots and clones

`snapshot` parks a running sandbox if it needs to, saves `/work`, then opens the sandbox again. `name` is an optional lookup.

```python
snap = sb.snapshot(name="before-edit")
copy = Sandbox.create(c, image=sb.image, from_snapshot_id=snap.id)
```

`clone(n)` saves `/work`, creates `n` sandboxes from that copy, and deletes the snapshot. Each new sandbox gets its own `/work`. They do not share a volume. The new sandboxes are returned. The original stays.

### Volumes

```python
vol = c.volumes.create(name="data")
with Sandbox.create(c, image="numen/base", volume_id=vol.id) as sb:
    sb.upload("/work/note.txt", "ok\n")
```

That sandbox's `/work` is the volume. Another create with the same `volume_id` fails while this one is attached.

`get` takes an id, or `name=`. `set_labels` replaces the whole map. `delete` is by id.

### Forwarding a port

The program must already be listening on `0.0.0.0` inside the sandbox.

```python
proc = sb.start(["python", "-m", "http.server", "8080", "--bind", "0.0.0.0"])
with sb.forward(8080) as fwd:
    fwd.send(b"GET / HTTP/1.0\r\n\r\n")
    print(fwd.recv())
```

`recv` waits up to 30 seconds. `send` and `recv` move bytes. Closing the session does not stop the process.

### Secrets and tags

```python
c.secrets.set("api-token", "value")
meta = c.secrets.get("api-token")
print(meta.name)
c.secrets.list_references("api-token")
tag = c.tags.create("reviewed", policy_id="...")
sb.set_tags([tag.name])
```

`get` does not return the secret value. `list_references` returns the policy ids that use it.

`set_tags` replaces the sandbox's tags. Each name must already exist. `tags.update` changes which policy a tag points at.

### Async

`AsyncClient` and `AsyncSandbox` have the same methods. `await` them, and use `async with`.

```python
from numen_sandbox_py import AsyncClient, AsyncSandbox

async with AsyncClient() as c:
    async with await AsyncSandbox.create(c, image="numen/base") as sb:
        result = await sb.run(["python", "-c", "print(2+2)"])
        print(result.stdout)
```

## Reference

`timeout` on these calls is seconds. Omit it to keep that call's default. `0` means no limit where the method says so. `Client(..., timeout_ms=...)` is the only place the unit is milliseconds: it limits how long the client waits for a reply.

### Client

`Client(base_url="", *, timeout_ms=None, credentials=None, credentials_file="", client_id="", client_secret="", token_url="", token="")`

`close()` drops the connection. Use `with Client() as c`.

`list_sandboxes(*, labels=None, tags=None, statuses=None, page_size=0, page_token="", timeout=None)` returns one page. Labels are combined. `list_sandboxes_iter(**kwargs)` yields every row. `list_images_iter` does the same for images.

### client.images

`register(name, *, from_, timeout=None, wait_until_ready=True, wait_sec=600)` names an existing image.

`build(name, *, dockerfile="Dockerfile", context=".", timeout=None, wait_until_ready=True, wait_sec=600)` builds and, by default, waits until the image is ready.

`get(name)`, `list(*, page_size=0, page_token="")`, `delete(name)`.

### client.volumes

`create(*, name="", labels=None)` returns a volume. `get(id="", *, name="")` needs exactly one of id or name. `list(*, labels=None, page_size=0, page_token="")`. `set_labels(id, labels=None)` replaces the map. `delete(id)`.

### client.secrets

`set(name, value)` stores the value. `get(name)` returns the name and times, not the value. `list(*, page_size=0, page_token="")`. `delete(name)`. `list_references(name)` returns the policy ids that use it.

### client.snapshots

`get(id="", *, name="")` needs exactly one of id or name. `list(*, labels=None, source_sandbox_id="", page_size=0, page_token="")`. `set_labels(id, labels=None)` replaces the map. `delete(id)`. `size_bytes` on a snapshot is the stored size, in bytes.

### client.tags

`create(name, *, policy_id="")`. `get(id)`. `list(*, page_size=0, page_token="")`. `update(id, *, policy_id="")`. `delete(id)`.

### Sandbox.create

`Sandbox.create(client, *, image="", name="", env=None, workdir="", lifetime_sec=0, idle_timeout_sec=0, from_snapshot_id="", volume_id="", labels=None, tags=None, cpu_millicores=None, memory_bytes=None)`

`image` is required. The service mints the id. `name` is optional. Empty `workdir` is `/work`. Omit CPU or memory for 500 millicores and 4 GiB. Put files in with `upload` after create. A taken name raises `NameTakenError`. You can set `idle_timeout_sec` so the sandbox parks after that many seconds of inactivity. Omit it and the sandbox stays up.

`Sandbox.open(client, id="", *, name="")` wakes by id or name. Exactly one of them. It does not create.

`Sandbox.get(client, id="", *, name="")` reads by id or name and does not wake. Exactly one of them.

`sb.open()` wakes this sandbox. `sb.get()` refreshes this record and does not wake.

### Properties

`id`, `status`, `name`, `image`, `env`, `workdir`, `labels`, `tags`, `volume_id`, `from_snapshot_id`, `cpu_millicores`, `memory_bytes`, `lifetime_sec`, `idle_timeout_sec`, `lifetime_remaining_sec`, `idle_timeout_remaining_sec`, `exit_code`.

### Sandbox methods

`run(argv, *, env=None, cwd="", timeout=None, stdin=None, text=True, stdout=None, stderr=None)` runs a command and returns `ExecResult` (`stdout`, `stderr`, `exit_code`). `text=False` returns bytes. Omit `timeout` for 60 seconds. `0` does not kill the command. `stdout` and `stderr` can be writers that receive output as it arrives. A timeout raises `CommandTimeoutError` and keeps the partial output on the error.

`start(argv, *, env=None, cwd="", timeout=None, service_name="", holds_idle=False, ends_sandbox=False)` returns a `ProcessHandle`. Omit `timeout` for a process that stays up.

`process(process_id)` and `processes()` return handles for processes that are already there.

`upload(path, content=None, *, local=None, mode=None, timeout=None, overwrite=True)` takes `content` or `local`, not both. `mode` defaults to `0644`. `overwrite` applies to a directory upload.

`read(path, *, timeout=None)` returns bytes. `download(path, dest, *, timeout=None)` writes them locally. `ls(path, *, max_depth=1, timeout=None)` lists one directory; `max_depth=0` is the whole tree. `stat(path)` returns `FileInfo` (`name`, `path`, `type`, `size`, `mtime_ms`). `export_path(path, *, dest, gzip=False, timeout=None)` writes a tar.

`write(ops, *, timeout=None)`, `mkdir(path)`, `rm(path, *, recursive=False, missing_ok=True)`.

`logs(*, stream="", timeout=None)` and `logs_all(...)` return log lines. `logs_all` walks every page.

`park()` stops processes and keeps files. `set_size(*, cpu_millicores=0, memory_bytes=0)` only while parked. `set_labels(labels)` and `set_tags(tags)` replace the whole map or list.

`snapshot(*, name="", labels=None)` returns a `Snapshot`. `clone(n=1, *, concurrency=8)` returns new sandboxes and deletes the temporary snapshot.

`forward(port, *, base_url=None)` returns a `ForwardSession`. `wait(*, status="", timeout=60)` waits until the sandbox reaches that status. `delete()` removes it. Deleting a sandbox that is already gone succeeds.

### ProcessHandle

`id`, `status`, `service_name`, `exit_code`.

`poll()` reads the current status. `wait(*, timeout=60)` waits until it exits. A timeout raises `ProcessWaitTimeoutError` and leaves the process running. `kill(signal=9)` signals it. `write_stdin(data=b"", *, eof=False)` sends bytes or text. `follow(*, timeout=None, from_start=False, text=True)` yields lines, or raw chunks when `text=False`.

### ForwardSession

`sandbox_id`. `send(data)` sends bytes. `recv(*, timeout=30)` returns bytes, or `b""` when the other side closes. `close()` ends the session. Use `with sb.forward(port) as fwd`.

### Result types

`ExecResult` has `stdout`, `stderr`, and `exit_code`. A non-zero exit is still a normal return. `ExecBytesResult` is the same with bytes. `FileInfo` has `name`, `path`, `type`, `size`, and `mtime_ms`. `FileList` has `entries`. `SandboxInfo` is the record `create`, `get`, `park`, and `set_size` return. `Process` is the record on a `ProcessHandle`. `Image`, `Volume`, `Snapshot`, `Secret`, and `Tag` are the catalog records. A list page is `sandboxes`, `volumes`, `secrets`, `snapshots`, `images`, or `tags`, plus `next_page_token`. `Snapshot.size_bytes` and `Volume.size_bytes` are the stored size, in bytes.

## Errors

Every named error is a `SandboxError`. The message is `message`. Fields you can read: `sandbox_id`, `path`, `op`, `op_index`, `process_id`, `request_id`, `status`, `exit_code`, `stdout`, `stderr`, `retry_after_sec`. `NameTakenError.existing_id` is the row that already has the name.

| Error | When |
| --- | --- |
| `NameTakenError` | That name is already used. Open `existing_id`, or pick another name. |
| `SandboxNotFoundError` | No sandbox, snapshot, or volume with that id or name. |
| `SandboxNotReadyError` | The call needs a different status. `set_size` needs the sandbox to be parked. |
| `SandboxConflictError` | The sandbox is already doing that. |
| `SandboxStateError` | The status does not allow this call. |
| `CapacityError` | Not enough CPU, memory, disk, or free slots. Wait and retry. Use `retry_after_sec` when it is set. |
| `AuthenticationError` | The credentials were rejected. |
| `InvalidArgumentError` | A bad argument. Empty `image` is this error. |
| `CommandTooLargeError` | The command, env, and working directory are too large. |
| `CommandTimeoutError` | `run` killed the command because `timeout` elapsed. Partial output is on the error. |
| `ProcessWaitTimeoutError` | `wait` returned before the process exited. |
| `SandboxWaitTimeoutError` | `wait` returned before the sandbox reached the status. |
| `FileNotFoundError` | That path is not there. |
| `DirectoryNotEmptyError` | `rm` of a non-empty directory without `recursive`. |
| `FileExistsError` | The path is already there. |
| `FileTooLargeError` | The file or export is over the limit. |
| `InvalidPathError` | The path is not under `/work`, or it is `/work` itself on `rm`. |
| `ProcessNotFoundError` | No process with that id. |
| `ExecutableNotFoundError` | The program is not in the image. |
| `GuestRuntimeDeadError` | The sandbox can no longer run commands. |

## Units and timeouts

Seconds on sandbox and process calls: `run`, `start`, `wait`, `follow`, `upload`, `read`, and the catalog `timeout` argument. `timeout_ms` exists only on `Client`, and it limits how long the client waits for a reply.

`0` means no limit: `run` does not kill the command, and a follow or file call does not stop on its own. Omit `run`'s timeout for 60 seconds. Omit a file call's timeout for 60 seconds. `ProcessHandle.wait` defaults to 60 seconds.
