Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

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.

pip install promigence
export PROMIGENCE_API_KEY=...            # or `promigence auth login`; see "Credentials" below

Quickstart

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 billed per second while it runs (`promigence pricing` shows the rates);
# 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):
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
    snapshot_id = done["build"].get("snapshot_id")
    if snapshot_id is None:                                        # not stored: verify failed, or still building
        raise SystemExit(f"build {done['build'].get('state')}: {done['build'].get('error')}")
    run = client.runs.start(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:

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:

with Sandbox.create(image=image, spend_cap=1, allow_build=True) as sb:   # the Quickstart built this image's snapshot; this reuses it free
    # 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 stored key is read only when no key was given: with api_key= or PROMIGENCE_API_KEY the client never opens the credentials file or asks the system keychain, and that key goes to api_url=, else PROMIGENCE_API_URL, else the default API. No other environment variable is read for a key.

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.

429 and 503 are retried (at most 3 times, jittered, honouring Retry-After up to 10 s). A request that is safe to send twice (a read, a delete, a create with its idempotency key) is also sent again after a lost connection or a 502 or 504 that is not the API's own answer; a command, or anything else that may already have run, is sent once.

Changes

0.1.0b9

  • A 502 or 504 that is not the API's own answer is retried like a lost connection, for a request that is safe to send twice: a read, a delete, or a create with its idempotency key. Same budget as every retry (max_retries, Retry-After, jittered backoff). A command, and any other request that may already have run, is still sent once.
  • The version follows the npm packages (0.1.0-beta.9 there).

0.1.0b8

  • A key you pass (api_key=) or set (PROMIGENCE_API_KEY) is used as is: the client no longer opens the stored credentials file or asks the system keychain, and that key goes to api_url=, else PROMIGENCE_API_URL, else the default API. A stored login's api_url goes only with the stored key.
  • The client reads no other environment variable for a key; the compatibility variables are read only by the drop-in package.
  • The version follows the npm packages (0.1.0-beta.8 there).

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).

Metadata

Release files for promigence 0.1.0b9

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for promigence 0.1.0b9
File Size Uploaded
promigence-0.1.0b9.tar.gz 159.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for promigence 0.1.0b9
File Interpreter ABI Platform
promigence-0.1.0b9-py3-none-any.whl Python 3 none any Details

Total release size: 330.1 kB

Release files / promigence-0.1.0b9.tar.gz

Download URL promigence-0.1.0b9.tar.gz
Size 159.4 kB
Tags Source
SHA-256 checksum
How to use checksums
b8cadd20c85ce801a2bed5c3f30384c069904755d278d1804657ed0c862dbfb5
BLAKE2b-256 checksum
How to use checksums
552821f3c9dcbcc55b4701ea433bc6f990f5c255a1bfd3b4db5f8c12f534d8bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7

Release files / promigence-0.1.0b9-py3-none-any.whl

Download URL promigence-0.1.0b9-py3-none-any.whl
Size 170.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dbcc63432e1e71e953f49f1106b1b6452f3adcc30261b74c1a7bed844aaa8a6f
BLAKE2b-256 checksum
How to use checksums
f23a8b9df4fb0a1db77b8d5a8cd9b0d8866e9fb964f577bb35cec4973c52db2e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page