Metadata-Version: 2.4
Name: boxd
Version: 0.2.0.dev38
Summary: Python SDK for the boxd cloud VM platform
Author: Azin
License-Expression: MIT
Project-URL: Homepage, https://boxd.sh
Keywords: boxd,vm,microvm,sandbox,compute,grpc,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Distributed Computing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: grpcio>=1.60
Requires-Dist: protobuf>=4.25
Requires-Dist: pydantic>=2
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=4.0; extra == "dev"
Dynamic: license-file

# boxd Python SDK

Python SDK for the [boxd](https://boxd.sh) cloud machine platform. Create
machines, run commands in them, move files, and manage everything around them.

Requires Python 3.10+.

## Install

```bash
pip install boxd
```

## Quick start

```python
from boxd import Boxd

boxd = Boxd(api_key="bxd_...")

machine = boxd.machines.create("my-machine")
boxd.machines.wait_until_ready(machine.id)

result = boxd.machines.exec(machine.id, "uname -a")
print(result.stdout)

boxd.machines.delete(machine.id)
```

Everything follows the same shape: **`boxd.<resource>.<verb>(id, ...)`**.
Resources return plain data — a `Machine` has fields, not methods.

## Client

```python
Boxd()                                     # production
Boxd(api_key="bxd_...")
Boxd(base_url="https://boxd.example.com:9443")   # any other cluster
```

| Argument | Environment variable | Default |
|---|---|---|
| `api_key` | `BOXD_API_KEY` | — |
| `token` | `BOXD_TOKEN` | — |
| `base_url` | `BOXD_BASE_URL` | production |
| `timeout` | — | 60 seconds |
| `max_retries` | — | 2 |

There is **no `environment` argument**. One `base_url` selects a cluster and
everything else follows from it.

The client holds a connection, so keep one around rather than making a new one
per call. Close it when you're done — or use it as a context manager:

```python
with Boxd(api_key="bxd_...") as boxd:
    ...
```

## Authentication

The first of these that is present wins:

1. `token=` — used as given
2. `api_key=` — exchanged for a short-lived credential and kept fresh for you
3. `BOXD_TOKEN`, then `BOXD_API_KEY`
4. running inside a boxd machine — see below
5. otherwise `AuthenticationError`

If your key is revoked mid-session, the SDK fails fast with
`AuthenticationError` rather than retrying.

### Inside a machine

Inside a boxd machine, `Boxd()` authenticates automatically — no API key
needed, and it talks to that machine's own cluster unless you pass `base_url`.

```python
from boxd import Boxd

boxd = Boxd()
for machine in boxd.machines.list():
    print(machine.name, machine.status)
```

One limit: inside a **shared** machine the automatic credential can manage the
organization's shared machines, but cannot read environment variables or
secrets, and cannot reach private machines. Pass an API key for those.

## Sync and async

`Boxd` and `AsyncBoxd` are the same surface — same namespaces, same method
names, same arguments, same return types. Switching is `await` and an import,
not a rewrite.

```python
from boxd import AsyncBoxd

boxd = AsyncBoxd(api_key="bxd_...")

machine = await boxd.machines.create("my-machine")
result = await boxd.machines.exec(machine.id, "echo hello")
await boxd.close()
```

Use `AsyncBoxd` when you already have an event loop (FastAPI, asyncio scripts,
anyio). Use `Boxd` everywhere else — scripts, notebooks, Django views.

## Machines

```python
machine = boxd.machines.create(
    "my-machine",
    vcpu=4,
    memory="16G",
    env={"MODE": "production"},
)
boxd.machines.get("my-machine")     # by name or id
boxd.machines.list()                     # a plain list
boxd.machines.delete("my-machine")
```

State:

```python
boxd.machines.start(id)
boxd.machines.stop(id)
boxd.machines.reboot(id)
boxd.machines.pause(id)        # suspend to RAM — fast to resume
boxd.machines.resume(id)
boxd.machines.hibernate(id)    # suspend to disk — cheaper, slower to wake
boxd.machines.wake(id)
```

Everything else:

```python
boxd.machines.fork("my-machine", "my-copy")     # live clone
boxd.machines.rename(id, "new-name")            # reboots the machine
boxd.machines.share(id)                         # visible to your whole org
boxd.machines.unshare(id)
boxd.machines.set_auto_suspend_timeout(id, 300) # seconds idle; 0 disables
boxd.machines.set_auto_hibernate_timeout(id, 0)
boxd.machines.wait_until_ready(id)
boxd.machines.suggest_name()
```

`create` and `fork` return once the machine is scheduled, not once it is
usable. Call `wait_until_ready` before doing anything that depends on it
running — especially before forking it again.

### The `Machine` record

Related fields travel together, so you read one object instead of remembering
which flat field pairs with which.

```python
machine.id, machine.name, machine.status, machine.image_ref
machine.restart_policy          # str | None
machine.created_at              # datetime | None — None on older machines

machine.resources.vcpu          # what the machine actually got, not what you
machine.resources.memory_bytes  # asked for — always concrete
machine.resources.disk_bytes

machine.org                     # OrgRef(id, name) | None — None = personal quota
machine.shared                  # shared with that org, or private to you

machine.access.ssh_port         # int | None — None until allocated
machine.access.domain
machine.access.url              # https://<name>.<domain>

machine.idle.suspend_after      # seconds; 0 = that timer is disabled
machine.idle.hibernate_after
machine.idle.destroy_after

machine.source                  # MachineSource | None — None = booted from an image
machine.source.kind             # "fork" | "snapshot"
machine.source.name             # source machine, or snapshot name
machine.source.version          # int | None — snapshots only; a fork has none
machine.source.id               # str | None — provenance; may not resolve

machine.hibernated_at           # datetime | None — None = not hibernated
machine.last_connected_at       # datetime | None — None = never connected
machine.boot_time_ms            # int | None — last boot; None = never booted
```

`None` always means "not set": a port that was never allocated, a boot that
never happened, an org you do not have. Where `0` is a real answer — a disabled
idle timer — it stays `0`.

`org` is the org the machine belongs to and is billed to; `shared` says whether
your teammates can see it. A private machine can still be org-billed, so `org`
set with `shared=False` is normal, not a contradiction.

`source.id` points at the machine or snapshot this one came from. It is a record
of where the machine came from, not a live link — **it may not resolve**, and a
lookup that finds nothing is normal.

### Creating from a snapshot

```python
boxd.snapshots.create(machine_id, "golden")
machine = boxd.machines.create("from-golden", from_snapshot="golden")
```

### Exec

```python
result = boxd.machines.exec(id, "cargo build")
result.stdout      # str
result.stderr      # str — populated for non-PTY execs
result.exit_code   # int
result.success     # bool

boxd.machines.exec(id, ["echo", "a b"])              # a list is quoted for you
boxd.machines.exec(id, "env", env={"FOO": "bar"})
boxd.machines.exec(id, "cargo build", timeout=30)    # seconds

# Under a PTY, stderr merges into stdout and `stderr` comes back empty.
boxd.machines.exec(id, "top -b -n1", tty=True, cols=120, rows=40)
```

For anything interactive, `stream_exec` gives you a live session — the one
handle in the SDK, because a bidirectional stream really is stateful:

```python
with boxd.machines.stream_exec(id, command="bash", tty=True) as stream:
    stream.write(b"ls\n")
    stream.write_eof()
    for chunk in stream:
        print(chunk.decode(errors="replace"), end="")
    print("exited", stream.exit_code)
```

`iter_chunks()` tags each slice with `is_stderr` when you need the two streams
apart. Under `tty=True` the terminal merges them, so everything arrives as
stdout — set `tty=False` if you need the split.

For a headless one-shot that reads stdin (`jq`, `cat`, `claude -p`), pass
`close_stdin=True` so it sees end-of-input immediately instead of hanging.
Combining it with `tty=True` raises `ValueError` — a shell needs stdin open.

Set the terminal size with `cols`/`rows`, and call `stream.resize(cols, rows)`
when the local terminal changes size:

```python
import shutil, signal

cols, rows = shutil.get_terminal_size()
stream = boxd.machines.stream_exec(id, command="htop", tty=True, cols=cols, rows=rows)
signal.signal(signal.SIGWINCH, lambda *_: stream.resize(*shutil.get_terminal_size()))
```

### Logs

```python
for chunk in boxd.machines.logs(id):
    print(chunk.decode(errors="replace"), end="")

for chunk in boxd.machines.logs(id, follow=True):   # stays open
    ...
```

### Files

```python
boxd.machines.files.upload(id, "/app/config.json", '{"debug": true}')
boxd.machines.files.upload(id, "/app/data.bin", open("local.bin", "rb").read())
data = boxd.machines.files.download(id, "/app/output.json")   # bytes
```

### Ports and proxies

```python
boxd.machines.ports.expose(id, 8080)                  # public TCP forward
boxd.machines.ports.expose(id, 5353, protocol="udp")
boxd.machines.ports.unexpose(id, 8080)
boxd.machines.ports.list()                            # every forward you own
```

`ports.list()` is account-wide — pass a machine to narrow it, or filter on
`.machine_id` / `.machine_name`.

```python
boxd.machines.proxies.create("my-machine", "api", 3001)  # api.<machine>...
routes = boxd.machines.proxies.list("my-machine")
routes[0].port          # int — where traffic actually goes
routes[0].port_mode     # "locked" (you pinned it) | "auto" (detected for you)
routes[0].machine_id
boxd.machines.proxies.set_port("my-machine", 3000, name="api")
boxd.machines.proxies.set_port("my-machine", "auto")  # default route, auto-detected
boxd.machines.proxies.delete("my-machine", "api")
```

These take an id or a name, like everything else on `machines`.

### Checkpoints

Per-machine captures, restored in place. They are deleted with the machine.

```python
boxd.machines.checkpoints.create(id, "before-upgrade")
boxd.machines.checkpoints.list(id)
boxd.machines.checkpoints.restore(id, "before-upgrade")
boxd.machines.checkpoints.delete(id, "before-upgrade")
```

## Environment variables and secrets

Two namespaces with identical methods. The difference is that a secret's value
is write-only — the server never returns it, and the `Secret` model has no
`value` field at all.

```python
boxd.env.set("MODE", "production", scope="all")
boxd.env.list()                    # EnvVar(name, scope, value)
boxd.env.delete("MODE", scope="all")

boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
boxd.secrets.list()                # Secret(name, scope) — no value
boxd.secrets.delete("API_TOKEN", scope="shared")
```

`set`, `delete` and `move` each return the server's human-readable
confirmation of what it did.

Scope decides which machines a name applies to:

| Scope | Applies to |
|---|---|
| `private` | only your machines in that organization |
| `shared` | the organization's shared machines |
| `all` | every machine in the organization |

Scope is part of a name's identity — the same name can exist in several scopes
at once — so changing it is a `move` between two addresses, and both ends are
required:

```python
boxd.secrets.move("API_TOKEN", from_scope="private", to_scope="shared")
```

Calling it twice fails the second time. Environment variables and secrets share
one namespace within a scope, so an environment variable can block a secret of
the same name moving in, and vice versa.

## Snapshots and disks

```python
boxd.snapshots.create(machine_id, "golden")   # re-saving bumps the version
boxd.snapshots.get("golden")
boxd.snapshots.list()
boxd.snapshots.delete("golden")

disk = boxd.disks.create("data", "10G")
boxd.disks.attach(disk.id, machine_id, "/mnt/data")
boxd.disks.attach(disk.id, machine_id, "/mnt/data", read_only=True)
boxd.disks.detach(disk.id, machine_id)
boxd.disks.list()
boxd.disks.delete(disk.id)
```

A `Snapshot` carries both `created_at` (the first capture) and `updated_at` (the
most recent one — re-saving under the same name bumps the version). A `Disk`
carries `created_at` and a `status` of `"creating"`, `"ready"` or `"destroyed"`;
it can only be attached once it is `"ready"`.

## Organizations, credentials, billing, account

```python
orgs = boxd.orgs.list()              # a plain list; each org has `is_default`

key = boxd.api_keys.create("ci", org="acme")
key.api_key                          # the raw key — shown once, store it now
boxd.api_keys.list()
boxd.api_keys.delete(key.id)



me = boxd.account.get()
me.user_id, me.display_name, me.pubkey_fingerprints
boxd.account.link_ssh_key(open("~/.ssh/id_ed25519.pub").read())
boxd.account.config()                # default image, cluster zone
```

## Errors

```python
from boxd import (
    BoxdError,               # base class — catch this to catch everything
    AuthenticationError,     # no usable credential, or it was rejected
    PermissionDeniedError,   # authenticated, but not allowed
    NotFoundError,
    ConflictError,           # already exists, or fights the current state
    RateLimitError,          # rate limit or quota
    APIStatusError,          # any other error from the server
    APIConnectionError,      # could not reach the server
)

try:
    boxd.machines.get("nope")
except NotFoundError:
    ...
```

Every error carries `.message` and `.code` (the canonical status name, e.g.
`"not_found"`).

Connection failures are retried with exponential backoff, `max_retries` times.
Timeouts are never retried — the server may already have applied the request —
and neither is `AuthenticationError`.

## Update notices

The SDK prints a one-time note to stderr if the server reports a newer release:

```
A new version of boxd is available (v0.2.0, you have v0.1.9). Update with:
  pip install --upgrade boxd
```

It fires at most once per process and never causes a request to fail.

The installed version is available as `boxd.__version__`.

## Development

```bash
cd sdk/python
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

pytest                                     # unit tests
bash scripts/compile_proto.sh              # regenerate stubs after an API change
```
