Metadata-Version: 2.5
Name: prized
Version: 0.1.0
Summary: Python SDK for Prized Sandboxes (beta): microVMs your code creates, runs commands in, pauses, forks and deletes.
Project-URL: Homepage, https://prized.dev/docs/sandboxes
Project-URL: Documentation, https://prized.dev/docs/sandboxes-api
Author-email: Prized <team@prized.dev>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,code execution,microvm,prized,sandbox,sandboxes
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy<2,>=1.8; extra == 'dev'
Requires-Dist: pytest<9,>=7; extra == 'dev'
Requires-Dist: ruff==0.17.0; extra == 'dev'
Requires-Dist: twine>=6.1; extra == 'dev'
Description-Content-Type: text/markdown

# Prized Python SDK: Sandboxes (beta)

A sandbox is a microVM your code creates, runs commands in, moves
files in and out of, pauses for the price of its storage, forks into live
copies, and deletes. This package is the Python client for the Prized
Sandboxes API, with a sync `Sandbox` and an asyncio `AsyncSandbox` that share
one surface.

Sandboxes are in beta. A workspace on free credit alone runs them on the
free tier: up to 10 at once, each up to 2 vCPU and 4 GB of memory and 60
minutes of running. A top-up or a paid plan lifts the limits: a sandbox then
runs up to 7 days at a stretch, and a paused one is kept until you delete it.

## Install

```sh
pip install prized
```

Python 3.10 or newer. The only dependency is [httpx](https://www.python-httpx.org/).

## Authenticate

Make an API key at [prized.dev/dashboard/workspace/api-keys](https://prized.dev/dashboard/workspace/api-keys)
and put it in the environment. New to Prized? [Open Sandboxes](https://prized.dev/onboarding/sandboxes)
signs you up (free credit, no card) and makes one.

```sh
export PRIZED_API_KEY=dcp_…
```

Or pass `api_key="dcp_…"` to any call. The key acts as you and sees every
sandbox in its workspace.

## Quickstart

```python
from prized import Sandbox

with Sandbox.create() as sb:  # returns once it runs
    result = sb.exec("python3 --version")
    print(result.stdout, result.exit_code)

    sb.files.write("hello.py", "print('hello from the sandbox')\n")
    print(sb.exec("python3 hello.py").stdout)
# leaving the block deletes the sandbox
```

The same with asyncio:

```python
import asyncio
from prized import AsyncSandbox


async def main() -> None:
    async with await AsyncSandbox.create() as sb:
        result = await sb.exec("node --version")
        print(result.stdout)


asyncio.run(main())
```

Many at once, a few at a time: a workspace caps how many sandboxes it holds
(10 on the free tier), and a sandbox being deleted still counts for a moment,
so each one is gone before its slot takes the next command:

```python
async def run_all(commands: list[str], limit: int = 4) -> list[str]:
    slots = asyncio.Semaphore(limit)  # 4 fits the free tier

    async def one(cmd: str) -> str:
        async with slots:
            async with await AsyncSandbox.create(vcpu=2, memory_gb=4, labels={"job": "batch"}) as sb:
                try:
                    result = await sb.exec(cmd, timeout=600)  # past it: CommandTimeoutError
                finally:
                    await sb.delete(wait=True)
                if result.exit_code != 0:
                    raise RuntimeError(f"{cmd!r} failed: {result.stderr}")
                return result.stdout

    # Every job settles (and deletes its sandbox) before a failure is raised.
    results = await asyncio.gather(*(one(c) for c in commands), return_exceptions=True)
    for r in results:
        if isinstance(r, BaseException):
            raise r
    return results
```

## Reference

Everything below exists on both classes; on `AsyncSandbox` every method and
classmethod is awaited.

### `Sandbox.create(template="prized-base-v1", **options) -> Sandbox`

Creates a sandbox and returns once it is `running`, ready for commands.

| Argument | Meaning |
|---|---|
| `template` | `prized-base-v1` (Ubuntu with Node, Python 3.12 and git; Python has only its standard library, and packages go in a venv: `python3 -m venv ~/.venv && ~/.venv/bin/pip install numpy`) or `prized-agent-v1` (adds Claude Code and Codex). `Sandbox.templates()` lists them with their shapes. |
| `vcpu`, `memory_gb` or `memory_mb`, `disk_gb` | The shape. Each defaults to the template's; the template sets the bounds (up to 32 vCPU, 128 GB of memory and 128 GB of disk; 2 vCPU and 4 GB on the free tier). |
| `env_vars` | `dict[str, str]` set for every command in the sandbox. |
| `labels` | `dict[str, str]` to find it by later (`Sandbox.list(labels=...)`). |
| `auto_pause_min` | Idle minutes before it pauses itself. Commands and file transfers count as activity. `0` turns it off. |
| `auto_delete_min` | Minutes after pausing before it is deleted. `0` turns it off. |
| `max_runtime_min` | Its run time (below): minutes of running before it is deleted. 24 hours by default, up to 7 days (10 080); 60 on the free tier. |
| `timeout` | Seconds for the request, its retries and the wait for `running` (default 180). A large shape (more than 16 vCPU or about 58 GB of memory) can take a minute or two to start: give it `timeout=300` or more. |
| `wait` | `False` returns as soon as the API accepts (state `starting`), still within `timeout`. |
| `idempotency_key` | Makes a retried create return the same sandbox. The SDK sends a fresh one per call when you don't, so its own retries never make a second sandbox. |

If the sandbox fails to start or `timeout` runs out, the SDK raises
`PrizedError` (`code` `sandbox_error` or `timeout`; `sandbox_id` set) and
deletes the sandbox. A create that is interrupted (Ctrl-C, a cancelled task)
or never gets an answer (`timeout` or `network_error` once its retries are
spent) finds the sandbox it may have made and deletes it too.

With your own `idempotency_key`, the SDK deletes nothing: that key may be
shared with another call holding the same sandbox. The error names the
sandbox (`sandbox_id`); resend the create with the key to get it back, or
delete it. `fork()` follows the same rules for its copies.

That cleanup is best effort, bounded (10 seconds for a create, a little more
per copy for a fork): if Prized cannot be reached meanwhile, a sandbox may be
left until its run time ends, or, if it pauses first (`auto_pause_min`), until
you delete it (free tier: 30 days after its last use). Label your sandboxes, and
`Sandbox.delete_by_labels(...)` deletes any leftovers once it can.

Create, `pause()` and `resume()` ask the API to answer once the change has
landed, and only poll if it has not within a few seconds.

Every classmethod also takes the connection options:

| Option | Default |
|---|---|
| `api_key` | `PRIZED_API_KEY` |
| `base_url` | `https://api.prized.dev` |
| `edge_url` | The command endpoint the API names for each sandbox |
| `http_client` | A client the SDK owns. Pass your own `httpx.Client` / `httpx.AsyncClient` for proxies or custom transports; the SDK never closes it. |
| `max_retries` | `3` |
| `poll_outage` | `120` seconds: how long a streamed or long `exec` keeps polling through a dropped connection before it stops the command |

### Find sandboxes

```python
sb = Sandbox.connect("sbx_…")  # any state; no waiting
mine = Sandbox.list(labels={"job": "batch"}, state="running")
templates = Sandbox.templates()  # [Template(id, defaults, min, max, ...)]
```

`list()` returns the workspace's sandboxes that are not yet deleted, newest
first. `labels` keeps those carrying every label; `state` takes one state or
several.

### Delete by label

```python
ids = Sandbox.delete_by_labels({"job": "batch"})  # every sandbox carrying it, in one call
```

`delete_by_labels()` needs at least one label and returns the ids it deleted.
It is safe to repeat (one already deleting is listed again; none matching
returns `[]`). A sandbox being deleted still counts toward your quota until
`Sandbox.list(labels=...)` no longer returns it.

### Fields

`sb.id` (`sbx_…`), `sb.state`, `sb.template`, `sb.resources` (`vcpu`,
`memory_mb`, `memory_gb`, `disk_gb`), `sb.labels`, `sb.auto_pause_min`,
`sb.auto_delete_min`, `sb.expires_at` (when its run time ends; `None` while
a paused sandbox's clock is stopped), `sb.created_at`, `sb.last_active_at`,
`sb.error_reason`, and `sb.info` (all of it; `sb.info.raw` is the document
as the API sent it).

States: `starting`, `running`, `pausing`, `paused`, `resizing`, `deleting`,
`deleted`, `error`. Fields reflect the last answer the SDK read;
`sb.refresh()` reads the sandbox again.

### `sb.exec(cmd, cwd=None, env=None, timeout=None, on_stdout=None, on_stderr=None, background=False, full_output=False, poll_outage=None)`

Runs one command with `bash` as the user `prized` and returns
`ExecResult(stdout, stderr, exit_code, truncated, timed_out, duration_ms)`.

- `cmd` is a shell string (`"ls -la | wc -l"`) or a list of arguments
  (`["python3", "-c", code]`), which the SDK quotes for you.
- `cwd` defaults to `/home/prized/work`; relative paths resolve against it.
- `env` adds variables for this command, on top of the sandbox's `env_vars`.
- `timeout` is in seconds: default 60, `0` for no limit. A command that runs
  past it is killed, and the call raises `prized.CommandTimeoutError` (a
  `PrizedError`, `code` `command_timeout`). Its `result` is the
  `ExecResult` with what the command printed before (also `err.stdout`,
  `err.stderr`), `exit_code=-1` and `timed_out=True`.
- A non-zero exit code is a result, not an exception.
- One call keeps the first 4 MiB of stdout and of stderr, and
  `truncated=True` says when it cut one. Pass `full_output=True` for builds,
  test suites and anything else whose output you need whole: the result keeps
  up to 100 MiB of each stream (`truncated=True` past that). It is still one
  request when the output is small; past 4 MiB the SDK reads it back from
  files in `/home/prized/.prized/exec/`, then removes them. With it, a
  command a signal ends gets `exit_code` 128+n (a plain exec may give -1), as a
  background command does.
- `on_stdout` / `on_stderr` receive the output as it arrives. On
  `AsyncSandbox` they may be coroutine functions.
- A streamed command (or one with a `timeout` over 240 seconds, or `0`)
  rides out a dropped connection: the SDK keeps polling it for up to
  `poll_outage` seconds (default 120, set on the call or the client) before
  it gives up and stops the command; a refused key, or an answer that
  settles it (the sandbox deleted or not running), stops it at once.

```python
result = sb.exec("npm test", timeout=180, full_output=True)  # every line of the log
sb.exec(
    "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt",
    timeout=600,
    on_stdout=lambda out: print(out, end=""),
)  # chunks keep their own newlines
```

### Background commands

`background=True` starts the command and returns a `CommandHandle` at once.
The command runs in its own session: it keeps running after your process
exits, and while it runs, `kill()` reaches every process it started (not one
that left its process group, such as a daemon). Once the command has ended,
`kill()` returns False and signals nothing.

```python
server = sb.exec("python3 -m http.server 8000", background=True)
server.status()  # CommandStatus(running=True, exit_code=None)
server.logs()  # CommandLogs(stdout=..., stderr=...) so far
server.kill()  # SIGTERM (or kill("SIGKILL")); False if it had exited
result = server.wait(on_stdout=lambda out: print(out, end=""))  # ExecResult once it ends
server.cleanup()  # remove its log files from the sandbox
```

`wait(timeout=...)` bounds the wait only; the command keeps running, and
`wait()` returns what the command printed by the time it ended (not what a
process it left behind prints after). Pass `on_stdout`/`on_stderr` to
`wait()`; `exec(..., background=True)` refuses them. A background command has
no time limit unless you pass `timeout=` (whole seconds); one that runs past
its limit is killed, `wait()` raises `CommandTimeoutError` at once, and
anything it started that ignores the TERM is killed 5 seconds later. `env` applies to the command
itself. Background and streamed commands run with `PYTHONUNBUFFERED=1` unless their environment
already sets it (`env={"PYTHONUNBUFFERED": ""}` keeps Python's buffering), so a Python program's
output arrives as it prints. Its output is kept in `/home/prized/.prized/runs/` until `cleanup()`,
which removes it once the command has ended (a command still running keeps
its files, and its time limit); a command that prints without end fills the
sandbox's disk there, so redirect such output yourself (`> /dev/null`, or a
log you rotate). `logs()` says `truncated=True` when a stream passed what the
SDK keeps.

### `sb.files`

| Method | Does |
|---|---|
| `write(path, data, mode=None)` | Writes `str` (UTF-8) or `bytes`, creating parent directories and replacing any file there whole. A file it replaces keeps its mode, so a script made executable stays executable; a new file gets the sandbox's default (`0600` in `prized-base-v1` and `prized-agent-v1`). `mode=0o755` sets the permission bits. Returns `WriteInfo(path, size)`. |
| `read(path)` / `read_bytes(path)` | The contents as `str` / `bytes`. |
| `upload(local_path, path, mode=None)` | Copies a local file in, as `write` does. Returns `WriteInfo`. |
| `download(path, local_path)` | Copies a file out (local parent directories are created). Returns the byte count. |
| `list(path="/home/prized/work")` | The entries of a directory, sorted by name: `FileInfo(name, path, size, is_dir, is_symlink, mode, modified)`. |
| `remove(path, recursive=False)` | Deletes a file or an empty directory; `recursive=True` deletes a directory and everything under it. A missing path is not an error. |
| `exists(path)` | `True` or `False`. |

Paths are absolute or relative to `/home/prized/work`. `write`, `read`,
`upload` and `download` work inside `/home/prized`.

### Lifecycle

```python
sb.pause()  # memory and disk kept (until you delete it; free tier: 30 days); no compute charged
sb.resume()  # every process carries on where it left off
kids = sb.fork(4)  # 4 new sandboxes, each a live copy of this one (running or paused)
sb.refresh()  # read the sandbox again (state and the other fields)
sb.keep_alive()  # count as activity now: the auto-pause clock starts over
sb.delete()
sb.close()  # release the SDK's connections; the sandbox keeps running
```

- `pause()` and `resume()` return once the sandbox is `paused` / `running`.
  Pass `wait=False` to return as soon as the API accepts, and `timeout=`
  (seconds) to bound the whole call (the request, its retries and the wait).
- A command or file call on a paused sandbox resumes it first (billed and
  counted as activity like any resume), so `resume()` is optional before one.
- `fork(count=1, labels=None)` works on a `running` or a `paused` sandbox. Each
  copy is an ordinary sandbox with its own id, its own files from then on, and
  the parent's memory and every process as they were at the fork (or at the
  pause), running. A running parent pauses briefly, usually under a second,
  and keeps running; a paused one stays paused. All or nothing: it returns once every
  copy is running; if any copy fails, or the call runs out of time
  (`timeout=`, default 180 s, the request and the wait together), every copy
  is deleted and `PrizedError` is
  raised. A fork that does not fit raises `fork_capacity`, whose message says
  how many copies fit now (`err.body["fits"]`). The copies keep the parent's
  labels unless you pass `labels=`, which gives them only the labels you pass.
- `delete()` returns once the API accepts; `delete(wait=True)` waits until the
  sandbox is gone (a deleting sandbox still counts toward quotas). Deleting a
  sandbox that is already gone is not an error.
- `with Sandbox.create(...) as sb:` deletes the sandbox when the block ends,
  error or not.

### Run time

A sandbox is deleted when its run time ends (`sb.expires_at`). The clock
counts running time only:

- **Paid:** 24 hours by default, up to 7 days (`max_runtime_min` on create).
  The clock stops while the sandbox is paused, and each resume starts it
  again with a fresh `max_runtime_min`. A paused sandbox is kept, memory
  and disk, until you delete it (or its `auto_delete_min` does).
- **Free tier:** 60 minutes of running in all. Paused time does not count,
  but a resume carries on the time left. A paused one is kept 30 days after
  its last use.
- **Forks:** on a paid workspace each copy gets the parent's
  `max_runtime_min` from the fork; on the free tier copies keep the parent's
  deletion time.
- **Extend a running one** with the CLI (`prized sandbox extend <id> --by 2h`)
  or the API (`POST /api/v1/sandboxes/{id}/extend`; see
  [Run time](https://prized.dev/docs/sandboxes#run-time)). On the free tier
  an extend cannot move the end later.

## Errors

Every failure of a call to Prized raises `prized.PrizedError`. Two kinds keep
Python's own exceptions: arguments the SDK refuses before sending anything
raise `ValueError` or `TypeError`, and `files.upload()` / `files.download()`
raise `OSError` for the local file.

| Attribute | |
|---|---|
| `code` | Machine-readable: branch on this. |
| `status` | The HTTP status, or `None` (network failure, timeout, missing key). |
| `message` | A sentence for people. |
| `reason` | A finer reason when the API gives one, e.g. which quota. |
| `sandbox_id` | The sandbox concerned, when there is one. |
| `body` | The API's whole error document. |

```python
from prized import PrizedError, Sandbox

try:
    sb = Sandbox.create()
except PrizedError as err:
    if err.code in ("paid_balance_required", "insufficient_balance"):
        print("Add funds at prized.dev, then retry.")
    elif err.code == "sandbox_quota_exceeded":
        print(f"At a workspace quota ({err.reason}).")
    else:
        raise
```

Codes you may meet:

| Code | Means |
|---|---|
| `missing_api_key` | No `api_key=` and no `PRIZED_API_KEY`. |
| `invalid_api_key` | The key has characters no key has (a stray newline?); it is never quoted back. |
| `edge_unknown` | A `base_url` other than Prized's, whose API named no command endpoint on its own host: pass `edge_url=`. |
| `unauthorized`, `token_expired` | The key was refused. |
| `key_access_denied` | The key lacks the Sandboxes permission. |
| `paid_balance_required` | The workspace has only free credit, and sandboxes need a paid balance right now; add funds. |
| `insufficient_balance` | The balance is empty. |
| `sandbox_quota_exceeded` | A workspace quota is full; `reason` names it. On the free tier the body says `"tier": "free"` and gives `limit` in the reason's unit (`err.body["limit"]`): a top-up lifts it. |
| `template_not_found`, `invalid_resources` | Check `Sandbox.templates()`. `invalid_resources` with `"tier": "free"` is past the free tier's 2 vCPU / 4 GB. |
| `not_found` | No such sandbox (or file) for this key. For a sandbox, most often one deleted when its run time ended; the message names it. |
| `sandbox_not_running` | The sandbox is not running and the call could not wake it: it is resizing (`refresh()` until it leaves `resizing`, then retry), failed, or being deleted. |
| `sandbox_not_ready` | A paused or starting sandbox did not reach `running` within the edge's 10-second wait. Nothing ran; the SDK retries it. |
| `sandbox_unreachable` | The sandbox did not answer. A command may or may not have run; the SDK does not retry it. |
| `sandbox_deleted` | The sandbox was deleted: a wait found it gone, a command or file call was sent to it before or while it was deleted (`410`; a command that was running stopped with it), or `pause()` or `resume()` was called while it was being deleted (`410`). Not retried. |
| `parent_not_running` | Only a running or paused sandbox can be forked; fork one that is `starting`, `pausing` or `resizing` once it settles. |
| `parent_not_forkable` | This sandbox cannot be forked. `reason` `resized_while_paused`: it has no memory to copy; resume it, then fork it. `reason` `parent_too_big`: its machine cannot hold even one copy beside it; fork a smaller sandbox. |
| `fork_capacity` | The parent's machine has room for only `fits` more copies (`err.body["fits"]`): fork that many or fewer, try again later, or create a new sandbox and fork that one, keeping the parent until it runs. Each copy counts toward your quota. |
| `sandbox_capacity` | No sandbox capacity is free right now. Nothing was created or billed; the SDK retries it after `Retry-After`. |
| `sandboxes_disabled`, `sandbox_create_disabled`, `sandbox_fork_disabled` | Sandboxes, or creating or forking them, are paused for maintenance. Nothing was created; retry later (the SDK does not). |
| `sandbox_error` | The sandbox failed; `reason` says why. |
| `command_failed` | A file or background-command helper failed in the sandbox. |
| `command_timeout` | A command ran past its own `timeout=` and was killed. Raised as `CommandTimeoutError`, whose `result` has what it printed. |
| `timeout` | A wait, or a call, ran out of time. One that ended because a retry could not start in time says what the last attempt met (also in `__cause__`). |
| `network_error` | No answer: the API or the sandbox could not be reached, or the connection dropped. |
| `http_error` | An answer the SDK could not read as an API error (a proxy's page). |
| `bad_response` | A `2xx` the SDK could not read. For a command or file write it did happen, and the message says so. |

**Retries.** The SDK retries rate limits (`429`, honouring `Retry-After`),
`502`, `503` and `504` answers, dropped connections, and every answer a
command is retried on, with backoff (0.5 s, 1 s, 2 s, …), up to
`max_retries` times. A read (`Sandbox.list`, `connect`, `refresh`,
`templates`, a file download, and the polling while `create`, `pause`,
`resume`, `fork` or `delete` waits) is retried on any other `500` too; a
write is not, since it may already have happened. A command, a
`files.write` / `files.upload`, a `files.remove` or a `kill()` is sent again only when
it provably never arrived: the connection never opened, or the answer says
so (rate limited, the sandbox not ready). One whose answer was lost is not
sent again: the command may have run, or the sandbox written the file
since. Quota, balance and idempotency refusals, and the maintenance switches above, are never retried.

**Did it run?** When a command, a file write, a `files.remove` or a `kill()`
fails without a plain refusal, the error keeps its `code` and its `message`
says which of three it was. It never reached the sandbox (the connection
never opened, or the answer proves it, such as `sandbox_not_ready`): it had
no effect. No clear answer came back (a dropped connection, a timeout, or
any other `5xx`): it may or may not have run, and the message says how to
check (read the file back, `files.exists()`, `status()`). A `2xx` came back
that could not be read: it ran. A background command (or a streamed or long
`exec`, which runs as one) whose start got no clear answer is stopped by the
SDK, and the error says so, or, when the SDK could not confirm the stop, that
it may still be running and under which run id. So is a streamed or long
`exec` whose polling fails while it runs (a dropped connection or a `5xx`
for longer than `poll_outage`, a refused key): the error keeps the poll's
`code` and `status`, is raised from the poll's own error (`__cause__`), and
says whether the SDK stopped the command or it may still be running. An
error that settles it (the sandbox deleted or not running, the run not
found) is raised as it came.

**Deadlines.** Retries stay within a call's bound: `timeout=` for `create`,
`pause`, `resume`, `fork` and `delete` (the request, its retries and the
wait, its polls included; with `wait=False` too), 2 minutes for `connect`,
`list`, `templates`, `refresh` and `keep_alive`, and 10 minutes for a file
transfer. `Retry-After` is honoured up to a minute. A retry that could not
start in time is not sent: the call raises `timeout`. A wait's last poll
goes out just before its deadline.

## Limits

| | |
|---|---|
| Shape | Up to 32 vCPU, 128 GB memory and 128 GB disk per sandbox, within the template's bounds (`Sandbox.templates()`); 2 vCPU and 4 GB on the free tier. |
| Command output | One call keeps the first 4 MiB of stdout and of stderr (past that, `truncated=True`); with `full_output=True` it keeps up to 100 MiB of each. Streamed and background commands read all of it, and their result keeps the first 64 Mi characters of each stream (`truncated=True` past that; `on_stdout`/`on_stderr` still get every chunk). |
| Command time | 60 s by default; `timeout=0` for none. |
| File transfer | 100 MiB per file, inside `/home/prized`. |
| Fork | 1 to 256 copies per call, subject to capacity. |
| Run time | Paid: 24 hours of running by default, up to 7 days; the clock stops while paused and restarts on resume. Free tier: 60 minutes of running in all. |
| Paused | Kept until you delete it; on the free tier, 30 days after its last use. |
| Workspace | Quotas cap sandboxes and their total vCPU, memory and disk. The free tier: 10 sandboxes at once. |

Pricing is per second while running, and storage only while paused:
[prized.dev/pricing](https://prized.dev/pricing).

Not yet: an interactive terminal from the SDK, custom templates, more than
32 vCPU or 128 GB, GPUs.

## Development

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

The tests run against an in-memory fake of the API (`tests/fake_api.py`) and
never touch the network. `scripts/package_check.py` builds the wheel and sdist
and checks them as PyPI gets them. `scripts/live_smoke.py` drives real
sandboxes end to end with `PRIZED_API_KEY`; it costs money and is not run in CI.

## License

Apache-2.0. The full text is in the `LICENSE` file that ships with the package.
