Python SDK for managing Buddy sandboxes - isolated Ubuntu environments for running commands.
[TypeScript SDK] · [Python SDK]
pip install git+https://github.com/buddy/sandbox-sdk-python.gitEverything is importable from the package root - the entities, the errors, and every model the API returns or accepts:
from buddy_sandbox import Sandbox, SandboxResponse, SandboxAppView, ConnectionConfigThe package ships type information (py.typed), so those models are fully
typed in your editor.
import asyncio
from buddy_sandbox import Sandbox
async def main() -> None:
identifier = "my-sandbox"
try:
sandbox = await Sandbox.get_by_identifier(identifier)
except Exception:
sandbox = await Sandbox.create(
identifier=identifier,
name="My Sandbox",
os="ubuntu:24.04",
)
await sandbox.start()
await sandbox.run_command(command="ping -c 5 buddy.works")
await sandbox.stop()
asyncio.run(main())Set the environment variables:
export BUDDY_TOKEN="your-api-token"
export BUDDY_WORKSPACE="your-workspace"
export BUDDY_PROJECT="your-project" # Optional: see Scopes below
export BUDDY_ENVIRONMENT="your-environment" # Optional: see Scopes below
export BUDDY_REGION="US" # Optional: US (default), EU, or ASOnly the token and the workspace are required - the project and the environment decide where new sandboxes land.
The SDK is async throughout: every call is awaited, and log streaming is an async iterator.
A sandbox lives in a project, in an environment, or directly in the workspace.
There is no scope parameter: the scope is derived from which of project and
environment you supply. Their values come from the env vars (BUDDY_PROJECT,
BUDDY_ENVIRONMENT), or from the connection object you can pass to any call -
see Connection overrides.
| Project | Environment | Where the sandbox is created | Scope |
|---|---|---|---|
my-project |
– | in that project | PROJECT |
| – | staging |
in the workspace-level environment staging |
ENVIRONMENT |
my-project |
staging |
in the environment staging belonging to my-project |
ENVIRONMENT |
| – | – | in the workspace itself | WORKSPACE |
Giving both is not a conflict. Environments belong either to a project or to the
workspace, and the project decides which of the two staging means - nothing
else. The sandbox is scoped to the environment either way; it never ends up in
the project.
from buddy_sandbox import ConnectionConfig, Sandbox
# in a project
await Sandbox.create(connection=ConnectionConfig(project="my-project"))
# in an environment of that project
await Sandbox.create(connection=ConnectionConfig(project="my-project", environment="staging"))
# in the workspace - nothing configured at all
await Sandbox.create()
# in the workspace despite a globally set BUDDY_PROJECT
await Sandbox.create(connection=ConnectionConfig(workspace="my-company"))Resolving an environment identifier to its ID costs an extra request. Pass
connection.environment_id instead of connection.environment to skip it -
worth doing when you create or list sandboxes in a loop.
A connection that sets workspace, project or environment decides the
placement on its own: you get exactly those values and nothing from the env
vars. ConnectionConfig(workspace="my-company") therefore means that workspace
with no project, even if BUDDY_PROJECT is set. A connection that sets none
of the three - overriding only token, say - leaves the env vars in charge.
The lookup follows the same rule: with a project, only that project's environments are searched; without one, only workspace-level ones. There is no second attempt - a miss means the environment is not where you pointed, and you get an error rather than a sandbox somewhere else.
Sandbox.list() and Sandbox.list_snapshots() return one scope at a time,
mirroring the API - listing across scopes means one call per scope.
Creating a sandbox outside a project - in an environment or in the workspace itself - requires workspace admin rights.
Sandbox.create() blocks until the sandbox has finished setup and reached
RUNNING, so the instance it returns is ready to use. While waiting it polls
the API on a backoff starting at 100ms and growing to 500ms.
Pass wait=False to skip the wait and drive it yourself. The instance you get
back carries everything the create call returned - id, identifier, url,
resources - but connection details are not settled yet.
sandbox = await Sandbox.create(identifier="my-sandbox", wait=False)
# ... do other work while the sandbox boots ...
await sandbox.wait_until_ready() # setup_status: SUCCESS
await sandbox.wait_until_running() # status: RUNNINGThe waiters accept an explicit interval, which pins polling to that fixed value instead of backing off:
await sandbox.wait_until_running(500) # check every 500msrun_command() blocks until the command finishes, streaming its output to
sys.stdout and sys.stderr as it arrives. Pass stdout=None / stderr=None
to silence it, or detached=True to get the Command back immediately.
command = await sandbox.run_command(command="pytest -q", stdout=None, stderr=None)
print(command.data.exit_code)
print(await command.stdout())Logs are an async iterator, so you can format them yourself:
command = await sandbox.run_command(command="make build", detached=True, stdout=None, stderr=None)
async for entry in command.logs(follow=True):
print(f"[{entry.type}] {entry.data}")
finished = await command.wait()Sandboxes can run multiple apps simultaneously. Each app is a long-running process defined by a command string.
sandbox = await Sandbox.create(
identifier="my-sandbox",
name="My Sandbox",
os="ubuntu:24.04",
first_boot_commands="apt-get update && apt-get install -y curl",
apps=["node server.js", "python worker.py"],
timeout=600, # auto-stop after 10 minutes of inactivity
)
# List apps
for app in sandbox.data.apps or []:
print(f'{app.id}: "{app.command}" -> {app.app_status}')
# Control individual apps
app_id = (sandbox.data.apps or [])[0].id
await sandbox.stop_app(app_id)
await sandbox.start_app(app_id)
logs = await sandbox.get_app_logs(app_id)
print(logs.logs)Use fetch to clone repositories or download artifacts into the sandbox on
first boot. Each entry sets a type (PROJECT_REPO, PUBLIC_REPO, or
ARTIFACT) plus the fields relevant to it.
await Sandbox.create(
identifier="my-sandbox",
fetch=[
{
"type": "PUBLIC_REPO",
"repository": "https://github.com/octocat/Hello-World",
"ref": "master",
"path": "/workspace/hello",
"build_command": "echo built",
}
],
)Use sandbox.update() to change configuration after creation - timeout,
apps, endpoints, variables, tags, and so on. The internal state is
updated with the API's response.
sandbox = await Sandbox.get_by_identifier("my-sandbox")
await sandbox.update(timeout=1200, tags=["staging", "feature-x"])Changing first_boot_commands leaves the sandbox in setup_status: STALE - the
new commands only apply on first boot, so the sandbox must be recreated for them
to take effect.
await sandbox.fs.create_folder("/workspace/data")
await sandbox.fs.upload_file(b"hello", "/workspace/data/hello.txt")
await sandbox.fs.upload_file("./local-file.txt", "/workspace/data/copy.txt")
content = await sandbox.fs.download_file("/workspace/data/hello.txt") # bytes
await sandbox.fs.download_file("/workspace/data", "./archive.tar.gz") # a directory
for entry in await sandbox.fs.list_files("/workspace/data"):
print(entry.name, entry.type, entry.size)
await sandbox.fs.delete_file("/workspace/data/hello.txt")A FileSystem can also be built from a sandbox ID alone:
from buddy_sandbox import FileSystem
fs = FileSystem.for_sandbox(sandbox_id)Take point-in-time snapshots of a sandbox and restore from them later. Each
snapshot is returned as a Snapshot instance with its own methods.
sandbox = await Sandbox.get_by_identifier("my-sandbox")
# Create a snapshot. Returns immediately with status "CREATING".
snapshot = await sandbox.create_snapshot(name="before-deploy")
# Wait until the snapshot is "CREATED" before using it.
await snapshot.wait_until_ready()
# Create a new sandbox from the snapshot.
restored = await Sandbox.create_from_snapshot(snapshot.id, name="restored-sandbox")
# List all snapshots for this sandbox.
snapshots = await sandbox.list_snapshots()
# Delete a snapshot.
await snapshot.delete()If you already have a snapshot ID from elsewhere, get the entity directly:
snapshot = await Sandbox.get_snapshot_by_id(sandbox_id, snapshot_id)list_snapshots and delete_snapshot mean one thing on the class and another
on an instance: on Sandbox they cover every snapshot in the current scope,
including orphans whose parent sandbox is gone; on a sandbox they cover that
sandbox's own.
await Sandbox.list_snapshots() # every snapshot in the scope
await sandbox.list_snapshots() # this sandbox's snapshots
await Sandbox.delete_snapshot(id) # no parent sandbox needed
await sandbox.delete_snapshot(id) # this sandbox's snapshotConvenience methods on Sandbox are available when you only have an ID:
await sandbox.wait_for_snapshot_ready(snapshot_id)Configure the API region:
# Via environment variable (recommended)
export BUDDY_REGION="EU"# Or via connection config
sandbox = await Sandbox.create(
identifier="my-sandbox",
connection=ConnectionConfig(region="EU"), # US, EU, or AS
)Override the workspace and the credentials per call. Setting workspace,
project or environment here also decides where the sandbox goes - see
Scopes:
await Sandbox.create(
identifier="my-sandbox",
name="My Sandbox",
os="ubuntu:24.04",
connection=ConnectionConfig(
workspace="different-workspace",
project="different-project",
token="custom-token",
region="EU",
),
)Every public method raises BuddySDKError, carrying a code, and for HTTP
failures a status_code and the API's own details.
from buddy_sandbox import BuddySDKError, ERROR_CODES
try:
await Sandbox.get_by_identifier("nope")
except BuddySDKError as error:
print(error.code) # HTTP_ERROR | VALIDATION_ERROR | GENERIC_ERROR
print(error.status_code) # 404
print(error.details) # the errors array the API returnedEvery call shares one connection pool, so there is nothing to open or close. A program that keeps running after it is done with its sandboxes can release it:
import buddy_sandbox
await buddy_sandbox.aclose()MIT