Metadata-Version: 2.5
Name: promigence
Version: 0.1.0b7
Summary: Promigence — verified, forkable sandboxes for agents, evals and RL (Python SDK)
Project-URL: Homepage, https://promigence.ai
Author-email: Promigence AI <support@promigence.ai>
License: Apache-2.0
Keywords: agents,evals,reinforcement-learning,sandbox,snapshots,verified
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# promigence — Python SDK for Promigence

Verified, forkable sandboxes for agents, evals and RL. One runtime dependency (`httpx`),
Python ≥ 3.9, sync and async clients, fully typed.

```bash
pip install ./promigence-<version>-py3-none-any.whl   # the package you were given (not on PyPI)
export PROMIGENCE_API_KEY=...            # or `promigence auth login`; see "Credentials" below
```

## Quickstart

```python
from promigence import Sandbox

image = "python:3.12"                     # the API pins a tag to its digest; NAME@sha256:… works too
# A new image's snapshot build is charged your build price (`promigence pricing`) once it verifies;
# later uses reuse it free.
# allow_build=True agrees to that; without it the create is refused before anything is sent.
with Sandbox.create(image=image, spend_cap=1, allow_build=True) as sb:
    result = sb.commands.run("python -c 'print(6 * 7)' > /tmp/answer.txt")
    print(result.exit_code)                                # 0 (non-zero raises CommandExitError)
    print(sb.files.read("/tmp/answer.txt"))                # "42\n"
# leaving the block kills the sandbox (sb.kill())
```

Async is the same shape: `async with await AsyncSandbox.create(image=image, spend_cap=1, allow_build=True) as sb:`
and `await sb.commands.run(...)`.

An image needs a tag or a digest: `python` alone is refused, because it would pin whatever `python:latest`
is today (write `python:latest` to mean that). To pay for a build once and on purpose, build it with
`promigence snapshot create --image python:3.12 --alias py312` and create from the name:
`Sandbox.create("py312")`, which never builds. `on_build=` receives the build's answer (`build_price`,
`deduped`) before the wait; without it a new build's price is logged as a `promigence` warning.

## The two surfaces

- **`Sandbox` / `AsyncSandbox`** — a simple class for one sandbox: `create`, `fork`, `connect`, `list`,
  `commands.run|list|kill|connect`, `files.read|write|remove|exists|list`, `pty`, `ports`,
  `kill`, `pause`, `resume`, `probe`, `set_timeout`, `is_running`, `get_info` (`get_host` raises while preview
  URLs are not available in the private beta).
  A live fork works in two calls: `client.sandboxes.snapshot(sandbox_id, alias=None, force=None)` (it ends
  background execs; refused with `secrets_in_snapshot` unless `force=True`), then `Sandbox.fork(snapshot_id,
  count=N)`. `snapshots.bundle()` is not supported from Python (export with the CLI).
  `sandboxes.exec(..., terminal=True)` does not give you a TTY (use `sandboxes.pty`): it makes the command
  own the sandbox — when it exits the sandbox is closed on its verdict, `task_failed` with a 7-day capture on
  a non-zero exit. Leave it False to keep using the sandbox.
- **`Promigence` / `AsyncPromigence`** — the native client. Responses are the wire JSON (snake_case
  dicts, typed in `promigence.types`):

```python
from promigence import Promigence

with Promigence() as client:
    build = client.snapshots.create(image="ghcr.io/org/env@sha256:...", verify="pytest -q", tier="small")
    done = client.snapshots.wait_build(build["build_id"])          # long-polled, bounded, never kills
    run = client.runs.start(done["build"]["snapshot_id"], count=50, spend_cap=5)
    client.runs.wait_done(run["run_id"])
    report = client.runs.report(run["run_id"])                     # env vs task failures, cost
```

Keys, organizations and projects: `client.keys.list|create|revoke`, `client.orgs.list|set_default|create_key|
members|invites|leave`, `client.orgs.projects.list|create|rename`, and `client.credits.offer()`. Inviting
people, roles, removals, deleting an organization or project, a project's cap, moving a project and the
audit log need a signed-in person, so they are CLI commands (`promigence org …`, `promigence project …`).
`runs.list_page`, `snapshots.builds_page` and `billing.ledger_page` return `{items, next_cursor}`: pass
`next_cursor` back as `cursor` until it is `None`.

`snapshots.create` takes exactly one input form: `image=` (a tag such as `python:3.12`, which the API
pins to its digest, or `name@sha256:<digest>`),
`dockerfile={"context": ..., "path": ...}`, or `repo=` + a full 40-character `commit=` (with
`verify=`, required for that form).

Give it a Dockerfile and we build it for you — a directory here, or a repository pinned to a
full commit. Both refuse anything that moves, because a snapshot that cannot be rebuilt is not
reproducible:

```python
from promigence import pack_context

client.snapshots.create(dockerfile={"context": pack_context("./app"), "path": "ci/Dockerfile"},
                        setup=["pip install -e ."], verify="pytest -q")
client.snapshots.create(dockerfile={"context": "https://github.com/org/app@<40-char sha>"})
```

## Live sessions

Background processes, directory listing and interactive terminals:

```python
with Sandbox.create(image=image, spend_cap=1) as sb:
    # Background: returns a CommandHandle at once. `pid` is the exec_id (a string).
    server = sb.commands.run("python -m http.server 3000", background=True,
                             on_stdout=print, on_stderr=print)
    print([p.pid for p in sb.commands.list()])
    server.kill()                       # or sb.commands.kill(server.pid)

    print([e.name for e in sb.files.list("/work")])   # EntryInfo: type, size, permissions, …

    # Terminal over a WebSocket (stdlib client, Authorization header — never a query string).
    term = sb.pty.create(120, 40, on_data=lambda b: print(b.decode(errors="replace"), end=""))
    term.send_input("echo hi\n")
    term.resize(100, 30)
    term.eof()                          # or term.kill() (SIGKILL), term.disconnect() (SIGHUP)
    print(term.wait().exit_code)        # PtyExit: exit_code, signal, wall_ms, error
```

- `handle.wait()` long-polls, returns a `CommandResult`, and raises `CommandExitError` on a
  non-zero exit — or an unknown one: an exit with no code is `128 + signal`, else `None`, never 0.
- A command killed at its own `timeout_ms` raises `CommandTimeoutError`, a `CommandExitError` with
  `code="task_timeout"`, `timed_out=True` and a message naming the bound (`exit_code` stays 137).
- Ctrl-C (or a cancelled task) during `Sandbox.create` ends whatever the create had started, also
  while its `POST /runs` is still unanswered, then the interruption propagates.
  `handle.disconnect()` stops reading; the process keeps running and `commands.connect(pid)`
  re-attaches with its output replayed from the first byte.
- A background command cannot take `secrets` (its output is served by byte range and is not
  redacted); the SDK refuses it before sending, with the API's reason.
- `AsyncSandbox` is the same surface with `await`; callbacks run on the event loop
  (`CommandHandle` polls on a daemon thread, `PtySession` reads on one).
- Native client: `sandboxes.exec_background`, `sandboxes.execs.list|get|read|signal|forget`,
  `sandboxes.ports.expose|list|revoke`, `sandboxes.files.list`, `sandboxes.pty`.

## Units

Every duration is **milliseconds** and says so (`timeout_ms`, `wait_timeout_ms`), matching the
wire, on purpose: the unit is in the name.

## Credentials

Resolved in this order: explicit `api_key=` / `api_url=` → `PROMIGENCE_API_KEY` / `PROMIGENCE_API_URL`
→ the key `promigence login` stored (`~/.config/promigence/credentials` when it is there, else the
system keychain; honours `XDG_CONFIG_HOME`; shared with the CLI) →
the compatibility variables, which only ever carry a Promigence key and are only ever used together:
a key from an argument, `PROMIGENCE_API_KEY` or the credentials file never goes where a
compatibility variable points.

A key and its URL come from the same place: the stored key goes only to the `api_url` stored with
it (the default when there is none), and `PROMIGENCE_API_KEY` not to an `api_url=` passed
without `api_key=`. A URL from a higher place than the key's leaves the client without a key, and
every authenticated call raises `InvalidArgumentError` naming the fix (`client.credentials_problem`
says why). A credentials file, or its directory, owned by another user or writable by others is
ignored with a warning on the `promigence` logger.

Every run is capped: `spend_cap=` → `PROMIGENCE_SPEND_CAP` → your org's `default_spend_cap`.

## Errors

Everything raised on purpose is a `PromigenceError` with `code`, `message`, `fix`, `docs`,
`status`, `request_id` and the CLI `exit_code`: `AuthError`, `NotFoundError`,
`RateLimitError` (`retry_after_ms`), `SpendCapError`, `PromigenceTimeoutError`, `UnreachableError`,
`InvalidArgumentError`, `EnvFailedError` (ours; credited back when our side shows the cause), `NotVerifiedError`,

`NotReleasedError`, `CommandExitError` (`exit_code` is the command's; `CommandTimeoutError` when the
command ran past its own `timeout_ms`), `ApiError`. An API refusal of your arguments keeps the API's
code (`usage`) as the TypeScript SDK does.

Only 429 and 503 are retried (at most 3 times, jittered, honouring `Retry-After` up to 10 s).

## Changes

### 0.1.0b7

- A sandbox's record names the snapshot it started from (`snapshot_id`) and when it was created (`created_at`), and
  a reattached sandbox carries both.
- A paused sandbox's record says when it will be deleted if it is not resumed (`pause_expires_at`), when a deletion
  date applies.
- `snapshots.create()` no longer takes `cpu_class`: passing it raises `TypeError` before any request, as a removed
  option does.
- `snapshots.bundle()` no longer calls a route the API does not serve: it raises `UnsupportedError` naming
  `promigence snapshot export`, which builds the bundle (a missing snapshot is still `NotFoundError`).
- The result of storing a secret (`SecretStored`) includes `scan_responses`, as the API returns it.
- The docstrings and types name only what the API returns: a sandbox's stdin errors are a 400 `usage`, and the
  response types drop fields the API never sends.
- The version follows the npm packages (`0.1.0-beta.7` there).

### 0.1.0b6

- `DEFAULT_SNAPSHOT`'s description names what the default sandbox carries. No behaviour changed.
- The version follows the npm packages (`0.1.0-beta.6` there).

### 0.1.0b5

- Packing a directory for a Dockerfile build applies its `.dockerignore` with the build's own rules, and
  always sends the Dockerfile and its ignore file. `.git` is not sent unless the ignore file says `!.git`.
  Files that look like credentials are refused unless `allow_secrets=True`. A snapshot built from a packed
  directory gets a new id once, because what is sent changed.
- A file write keeps the existing file's mode; a new file is `0644` unless a mode is given.
- The version follows the npm packages (`0.1.0-beta.5` there).

### 0.1.0b4

- `runs.start()` drops a deprecated option that never had an effect and was no longer sent. Passing it now
  raises `TypeError`, which names it, before any request: remove it from the call. (`inject` still warns
  and is ignored.)
- A sandbox's handle no longer says where it runs: that attribute of `Sandbox` is always `None`, and the
  constructor still accepts it and ignores it. The typed responses drop the fields the API never sends to
  a customer key.
- The version follows the npm packages (`0.1.0-beta.4` there).

## Development

```bash
cd sdk-py && python3 -m venv .venv && .venv/bin/pip install -e '.[dev]' && .venv/bin/pytest -q
```
