numen-sandbox-py
Python client for the Numen sandbox API.
This is a 0.x release. Breaking changes can still ship in any version until 1.0.
Install
pip install numen-sandbox-py
Credentials
Put this file at ~/.config/numen/credentials.json:
{
"client_id": "pcc_...",
"client_secret": "..."
}
Quickstart
image is required. numen/base is the general platform image. The with block deletes the sandbox when the block ends. sb.delete() does the same.
from numen_sandbox_py import Client, Sandbox
with Client() as c, Sandbox.create(c, image="numen/base") as sb:
print(sb.run(["python", "-c", "print(2+2)"]).stdout)
Concepts
A sandbox is one machine with one writable root filesystem, processes, and outbound internet access. Commands start in the workdir (/work when the image sets none). Sandbox is the handle for that machine. Client is the account: images, volumes, secrets, snapshots, tags, and the list of sandboxes.
A name is an optional lookup you choose. The service mints the id. Names are lowercase and stay until you delete the row. A second create with the same name raises NameTakenError; existing_id is the row that already has it.
Park stops running processes and keeps your files. A parked sandbox does not count against your CPU and memory. Open wakes it. Get reads the record and does not wake it. Reading or uploading files does not wake a parked sandbox.
A snapshot is the writable layer. A volume is a separate disk bind-mounted at the workdir. A secret is a named value stored for your account. Reading a secret returns its name and times, not the value. A tag points at a policy. Create the policy in the portal, then client.tags.create(name, policy_id=...). Labels are a string map you replace as a whole.
CPU is cpu_millicores. Memory is memory_bytes. Omit either one on create for 500 millicores and 4 GiB.
Where things are
Sandbox work is on Sandbox. Everything else is on Client.
| Call | What it does |
|---|---|
client.list_sandboxes |
One page of sandboxes |
client.list_sandboxes_iter |
Every sandbox, walking pages |
client.list_images_iter |
Every image, walking pages |
client.images.register |
Name an image that already exists |
client.images.build |
Build from a Dockerfile |
client.images.get / list / delete |
Read or remove an image |
client.volumes.create / get / list / set_labels / delete |
Account disks |
client.secrets.set / get / list / delete / list_references |
Named secret values |
client.snapshots.get / list / set_labels / delete |
Saved writable layers |
client.tags.create / get / list / update / delete |
Names that point at a policy |
Sandbox.create / open / get |
Create, wake, or read a sandbox |
sb.run / start / process / processes |
Run a command or keep a process |
sb.upload / read / write / mkdir / rm / ls / stat / download / export_path |
Files on the writable root |
sb.logs / logs_all |
Log lines |
sb.park / open / get / set_size / set_labels / set_tags |
Lifecycle and metadata |
sb.snapshot / clone / forward / wait / delete |
Copy, connect, wait, remove |
c.build_image and c.get_secret are not methods. Use c.images.build and c.secrets.get.
Guides
Your own image
images.build sends a Dockerfile and the directory next to it. The name you give is what you later pass to create.
with Client() as c:
c.images.build("my-agent", dockerfile="Dockerfile", context=".")
with Sandbox.create(c, image="my-agent") as sb:
print(sb.run(["python", "-c", "print(2+2)"]).stdout)
FROM python:3.12-slim
FROM is a public image. An image you have already built or registered, including numen/base, cannot be the base of a new one. images.register names an image you can already pull, and waits until it is ready (up to wait_sec, default 600).
Files
A relative path joins the workdir. An absolute path is on the writable root. Denied: /.numen, /proc, /sys, /dev, /run. upload writes one file, or a local directory as one tree. A directory upload replaces that guest directory. Pass overwrite=False to update only the paths you send.
sb.upload("/work/hello.py", "print('hi')\n", mode=0o755)
sb.upload("/work/app", local="./app")
print(sb.run(["python", "/work/hello.py"]).stdout)
print(sb.read("/work/hello.py"))
write applies a list, in order. Each item is {"mkdir": path}, {"remove": path, "recursive": True}, or {"write": {"path", "content", "mode"}}. mkdir creates parents. rm of a missing path succeeds unless missing_ok=False. download writes a guest file or directory onto your disk. export_path writes a tar of a subtree (gzip=True for gzip).
A process that stays up
run waits until the command exits. Omit timeout for 60 seconds. 0 does not kill the command. start returns while the process is still running.
# Returns immediately. ends_sandbox defaults to False, so this exit leaves the sandbox up.
proc = sb.start(["python", "-u", "-c", "print(input())"])
proc.write_stdin("hello\n", eof=True) # one line, then stdin closes and the program exits
for entry in proc.follow(): # lines until that exit
print(entry.stream, entry.line)
proc.wait() # already exited; the sandbox is still there
follow does not kill the process. Omit its timeout, or pass 0, to read until the process exits. A timeout ends the iteration and leaves the process running. from_start=True yields output already saved for that process, then live output. text=False yields raw chunks.
Park and open
sb.park()
sb.upload("/work/note.txt", "still here\n")
sb.open()
Park keeps files. Running processes stop. Open after a park starts a fresh session on those files. Close a forward before you park, and open a new one after open().
Sizing
Omit cpu_millicores and memory_bytes on create for 500 millicores and 4 GiB. Change the size only while the sandbox is parked:
sb.park()
sb.set_size(cpu_millicores=1000, memory_bytes=8 << 30)
sb.open()
0 on set_size keeps the current value. CapacityError means there is not enough room right now. Wait and retry. retry_after_sec is set when the service tells you how long.
Snapshots and clones
snapshot parks a running sandbox if it needs to, saves the writable layer, then opens the sandbox again. name is an optional lookup.
snap = sb.snapshot(name="before-edit")
copy = Sandbox.create(c, image=sb.image, from_snapshot_id=snap.id)
clone(n) saves the writable layer, creates n sandboxes from that copy, and deletes the snapshot. Each new sandbox gets its own layer. They do not share a volume. The new sandboxes are returned. The original stays.
Volumes
vol = c.volumes.create(name="data")
with Sandbox.create(c, image="numen/base", volume_id=vol.id) as sb:
sb.upload("/work/note.txt", "ok\n")
That sandbox's workdir is the volume. Another create with the same volume_id fails while this one is attached.
get takes an id, or name=. set_labels replaces the whole map. delete is by id.
Forwarding a port
The program must already be listening on 0.0.0.0 inside the sandbox.
proc = sb.start(["python", "-m", "http.server", "8080", "--bind", "0.0.0.0"])
with sb.forward(8080) as fwd:
fwd.send(b"GET / HTTP/1.0\r\n\r\n")
print(fwd.recv())
recv waits up to 30 seconds. send and recv move bytes. Closing the session does not stop the process.
Secrets and tags
c.secrets.set("api-token", "value")
meta = c.secrets.get("api-token")
print(meta.name)
c.secrets.list_references("api-token")
tag = c.tags.create("reviewed", policy_id="...")
sb.set_tags([tag.name])
get does not return the secret value. list_references returns the policy ids that use it.
set_tags replaces the sandbox's tags. Each name must already exist. tags.update changes which policy a tag points at.
Async
AsyncClient and AsyncSandbox have the same methods. await them, and use async with.
from numen_sandbox_py import AsyncClient, AsyncSandbox
async with AsyncClient() as c:
async with await AsyncSandbox.create(c, image="numen/base") as sb:
result = await sb.run(["python", "-c", "print(2+2)"])
print(result.stdout)
Reference
timeout on these calls is seconds. Omit it to keep that call's default. 0 means no limit where the method says so. Client(..., timeout_ms=...) is the only place the unit is milliseconds: it limits how long the client waits for a reply.
Client
Client(base_url="", *, timeout_ms=None, credentials=None, credentials_file="", client_id="", client_secret="", token_url="", token="")
close() drops the connection. Use with Client() as c.
list_sandboxes(*, labels=None, tags=None, statuses=None, page_size=0, page_token="", timeout=None) returns one page. Labels are combined. list_sandboxes_iter(**kwargs) yields every row. list_images_iter does the same for images.
client.images
register(name, *, from_, timeout=None, wait_until_ready=True, wait_sec=600) names an existing image.
build(name, *, dockerfile="Dockerfile", context=".", timeout=None, wait_until_ready=True, wait_sec=600) builds and, by default, waits until the image is ready.
get(name), list(*, page_size=0, page_token=""), delete(name).
client.volumes
create(*, name="", labels=None) returns a volume. get(id="", *, name="") needs exactly one of id or name. list(*, labels=None, page_size=0, page_token=""). set_labels(id, labels=None) replaces the map. delete(id).
client.secrets
set(name, value) stores the value. get(name) returns the name and times, not the value. list(*, page_size=0, page_token=""). delete(name). list_references(name) returns the policy ids that use it.
client.snapshots
get(id="", *, name="") needs exactly one of id or name. list(*, labels=None, source_sandbox_id="", page_size=0, page_token=""). set_labels(id, labels=None) replaces the map. delete(id). size_bytes on a snapshot is the stored size, in bytes.
client.tags
create(name, *, policy_id=""). get(id). list(*, page_size=0, page_token=""). update(id, *, policy_id=""). delete(id).
Sandbox.create
Sandbox.create(client, *, image="", name="", env=None, workdir="", lifetime_sec=0, idle_timeout_sec=0, from_snapshot_id="", volume_id="", labels=None, tags=None, cpu_millicores=None, memory_bytes=None)
image is required. The service mints the id. name is optional. Empty workdir is the image working directory, or /work. Omit CPU or memory for 500 millicores and 4 GiB. Put files in with upload after create. A taken name raises NameTakenError. You can set idle_timeout_sec so the sandbox parks after that many seconds of inactivity. Omit it and the sandbox stays up.
Sandbox.open(client, id="", *, name="") wakes by id or name. Exactly one of them. It does not create.
Sandbox.get(client, id="", *, name="") reads by id or name and does not wake. Exactly one of them.
sb.open() wakes this sandbox. sb.get() refreshes this record and does not wake.
Properties
id, status, name, image, env, workdir, labels, tags, volume_id, from_snapshot_id, cpu_millicores, memory_bytes, lifetime_sec, idle_timeout_sec, lifetime_remaining_sec, idle_timeout_remaining_sec, exit_code.
Sandbox methods
run(argv, *, env=None, cwd="", timeout=None, stdin=None, text=True, stdout=None, stderr=None) runs a command and returns ExecResult (stdout, stderr, exit_code). text=False returns bytes. Omit timeout for 60 seconds. 0 does not kill the command. stdout and stderr can be writers that receive output as it arrives. A timeout raises CommandTimeoutError and keeps the partial output on the error.
start(argv, *, env=None, cwd="", timeout=None, service_name="", holds_idle=False, ends_sandbox=False) returns a ProcessHandle. Omit timeout for a process that stays up.
process(process_id) and processes() return handles for processes that are already there.
upload(path, content=None, *, local=None, mode=None, timeout=None, overwrite=True) takes content or local, not both. mode defaults to 0644. overwrite applies to a directory upload.
read(path, *, timeout=None) returns bytes. download(path, dest, *, timeout=None) writes them locally. ls(path, *, max_depth=1, timeout=None) lists one directory; max_depth=0 is the whole tree. stat(path) returns FileInfo (name, path, type, size, mtime_ms). export_path(path, *, dest, gzip=False, timeout=None) writes a tar.
write(ops, *, timeout=None), mkdir(path), rm(path, *, recursive=False, missing_ok=True).
logs(*, stream="", timeout=None) and logs_all(...) return log lines. logs_all walks every page.
park() stops processes and keeps files. set_size(*, cpu_millicores=0, memory_bytes=0) only while parked. set_labels(labels) and set_tags(tags) replace the whole map or list.
snapshot(*, name="", labels=None) returns a Snapshot. clone(n=1, *, concurrency=8) returns new sandboxes and deletes the temporary snapshot.
forward(port, *, base_url=None) returns a ForwardSession. wait(*, status="", timeout=60) waits until the sandbox reaches that status. delete() removes it. Deleting a sandbox that is already gone succeeds.
ProcessHandle
id, status, service_name, exit_code.
poll() reads the current status. wait(*, timeout=60) waits until it exits. A timeout raises ProcessWaitTimeoutError and leaves the process running. kill(signal=9) signals it. write_stdin(data=b"", *, eof=False) sends bytes or text. follow(*, timeout=None, from_start=False, text=True) yields lines, or raw chunks when text=False.
ForwardSession
sandbox_id. send(data) sends bytes. recv(*, timeout=30) returns bytes, or b"" when the other side closes. close() ends the session. Use with sb.forward(port) as fwd.
Result types
ExecResult has stdout, stderr, and exit_code. A non-zero exit is still a normal return. ExecBytesResult is the same with bytes. FileInfo has name, path, type, size, and mtime_ms. FileList has entries. SandboxInfo is the record create, get, park, and set_size return. Process is the record on a ProcessHandle. Image, Volume, Snapshot, Secret, and Tag are the catalog records. A list page is sandboxes, volumes, secrets, snapshots, images, or tags, plus next_page_token. Snapshot.size_bytes and Volume.size_bytes are the stored size, in bytes.
Errors
Every named error is a SandboxError. The message is message. Fields you can read: sandbox_id, path, op, op_index, process_id, request_id, status, exit_code, stdout, stderr, retry_after_sec. NameTakenError.existing_id is the row that already has the name.
| Error | When |
|---|---|
NameTakenError |
That name is already used. Open existing_id, or pick another name. |
SandboxNotFoundError |
No sandbox, snapshot, or volume with that id or name. |
SandboxNotReadyError |
The call needs a different status. set_size needs the sandbox to be parked. |
SandboxConflictError |
The sandbox is already doing that. |
SandboxStateError |
The status does not allow this call. |
CapacityError |
Not enough CPU, memory, disk, or free slots. Wait and retry. Use retry_after_sec when it is set. |
AuthenticationError |
The credentials were rejected. |
InvalidArgumentError |
A bad argument. Empty image is this error. |
CommandTooLargeError |
The command, env, and working directory are too large. |
CommandTimeoutError |
run killed the command because timeout elapsed. Partial output is on the error. |
ProcessWaitTimeoutError |
wait returned before the process exited. |
SandboxWaitTimeoutError |
wait returned before the sandbox reached the status. |
FileNotFoundError |
That path is not there. |
DirectoryNotEmptyError |
rm of a non-empty directory without recursive. |
FileExistsError |
The path is already there. |
FileTooLargeError |
The file or export is over the limit. |
InvalidPathError |
The path is on the denylist, or it is the workdir itself on rm. |
ProcessNotFoundError |
No process with that id. |
ExecutableNotFoundError |
The program is not in the image. |
GuestRuntimeDeadError |
The sandbox can no longer run commands. |
Units and timeouts
Seconds on sandbox and process calls: run, start, wait, follow, upload, read, and the catalog timeout argument. timeout_ms exists only on Client, and it limits how long the client waits for a reply.
0 means no limit: run does not kill the command, and a follow or file call does not stop on its own. Omit run's timeout for 60 seconds. Omit a file call's timeout for 60 seconds. ProcessHandle.wait defaults to 60 seconds.
Metadata
Release files for numen-sandbox-py 0.24.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| numen_sandbox_py-0.24.0.tar.gz | 82.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| numen_sandbox_py-0.24.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 172.3 kB
Release files / numen_sandbox_py-0.24.0.tar.gz
| Download URL | numen_sandbox_py-0.24.0.tar.gz |
|---|---|
| Size | 82.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9673e2a3a4b8b9636dae9a553ec9103f854c5f4c3836d4b2fc12bee09bb48e54
|
|
BLAKE2b-256 checksum How to use checksums |
e10c8feb81f69e2af17358966a66ea97a3dc65a9abfed9ba2e24f6b7503ea090
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / numen_sandbox_py-0.24.0-py3-none-any.whl
| Download URL | numen_sandbox_py-0.24.0-py3-none-any.whl |
|---|---|
| Size | 90.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8d6d8460b30432a050296b577a2349c376590b6fe5117631633690754e52f820
|
|
BLAKE2b-256 checksum How to use checksums |
8ce5d00703965ed033e01aedee711d4f9fcfd48ae3e413a4bcb4b4e1b498cee2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for numen-sandbox-py 0.24.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.