Skip to main content

exedev

The official Python SDK for exe.dev: create VMs, run commands on them, and manage your account, from sync or async code.

pip install exedev    # or: uv add exedev

Quick start

import exedev

exe = exedev.Client()  # reads EXE_TOKEN

with exe.new(image="ubuntu:24.04") as vm:  # deleted at the end of the block
    print(vm.name, vm.https_url)
    result = vm.run("uname -a", check=True)
    print(result.stdout)

new() returns once the VM runs commands, so the first run() needs no retry loop, and if it fails or is interrupted on the way, it deletes the VM rather than leak it. Create a token with ssh exe.dev ssh-key generate-api-key; its permissions decide which methods work. For automation, prefer a key scoped to a tag (--tag=ci): it manages only the VMs with that tag, and VMs it creates get the tag.

The same API is available for asyncio:

import asyncio
import exedev


async def main() -> None:
    async with exedev.AsyncClient() as exe:
        async with exe.new() as vm:
            result = await vm.run(["python3", "-c", "print('hello')"], check=True)
            print(result.stdout)


asyncio.run(main())

Client and AsyncClient, and VM and AsyncVM, have the same methods; the async ones are coroutines.

Running commands

vm.run() runs a command once, in the VM user's login shell, and returns a CommandResult with exit_code, stdout, stderr (text), stdout_bytes, stderr_bytes (exact bytes), and ok.

vm.run("make -j8 && make test")               # a shell command line
vm.run(["cat", "/etc/os-release"])            # an argument list, quoted for you
vm.run("npm test", cwd="/app", env={"CI": "1"})
vm.run("pytest", check=True)                  # nonzero exit raises CommandError
vm.run("./long-job", timeout=600)             # stopped after 10 minutes
vm.run(llm_written_program)                   # any size: long commands are uploaded first

As with subprocess.run, a nonzero exit status is a result, not an exception, unless you pass check=True. The CommandError it raises carries the result, and its message ends with the last line of standard error.

Stream output as it arrives with on_stdout and on_stderr, which receive it a line at a time, without the line ending (and may be coroutine functions with AsyncVM):

vm.run("apt-get install -y ffmpeg", on_stdout=print, on_stderr=print)

Output is captured up to max_output bytes (8 MiB by default); beyond that, OutputLimitError. For large output, pass capture=False and stream it.

If the connection breaks before the exit status arrives, run() raises CommandInterruptedError with whatever output it captured: the command may or may not have finished, so do not blindly retry one that is unsafe to run twice. Closing the connection, whether by timeout, a callback raising, or cancelling the asyncio task, stops the command and everything it started.

run() lasts only as long as its request. For servers and long jobs, use spawn().

Background processes

vm.spawn() starts a command and returns a Process at once. The process runs on its own, whatever becomes of your program, and its output goes to files on the VM.

server = vm.spawn("python3 -m http.server 8000", name="web", cwd="site")
print(vm.url(8000))                   # its URL through the exe.dev proxy
server.logs(tail=20)                  # Logs(stdout=..., stderr=...)
server.poll()                         # None while it runs, then its exit status
server.terminate()                    # or kill(), or send_signal("HUP")

job = vm.spawn("make -j8 all")
result = job.wait(timeout=3600, check=True)  # a CommandResult; survives lost connections

vm.process("web")                     # found again later, from any program
vm.processes()                        # everything spawn() started

with vm.spawn("./worker") as worker:  # killed at the end of the block
    ...

Signals reach the process and everything it started. If the VM restarts, poll() and wait() on a process it was running raise ProcessLostError.

Files

vm.write_file("app/config.json", json.dumps(config))   # str or bytes; makes directories
vm.write_file("bin/tool", script, mode=0o755)
data = vm.read_file("results/out.csv")                  # bytes; FileNotFoundError if missing
vm.upload("./src", "app/src", exclude=[".git", "node_modules", "*.pyc"])
vm.download("app/build", "./build")                     # a file or a directory

Paths on the VM are relative to the home directory unless absolute. Transfers go through /exec in parts of about 64 KB, sent in parallel, and compressed when that helps: expect a few seconds per megabyte.

Creating VMs

vm = exe.new(
    name="build",                       # default: a generated name
    image="ghcr.io/me/ci:latest",
    cpu=4, memory="8G", disk="50G",
    env={"MODE": "ci"},
    tags=["ci"],
    setup_script="#!/bin/sh\napt-get install -y jq",
    wait_until="setup",                 # "ssh" (default), "setup", or "created"
    email=False,                        # the default; True emails you about it
)

wait_until="ssh" returns once the VM runs commands; "setup" also waits for its setup script to finish, raising VMSetupError (with the script's exit_code and output) if it fails; "created" returns at once. Waiting needs a token that may run ssh. If the VM is not ready within timeout seconds (60, or 600 for setup), VMReadyError is raised.

If waiting fails, or is interrupted by an exception, a KeyboardInterrupt, or cancellation, new() deletes the VM before raising. Pass keep_on_failure=True to keep it for debugging, as err.vm. Interrupted while the server is still creating the VM, new() (and vm.copy()) waits for it, deletes it, and then raises; a second interrupt gives up on it.

A VM from new() or vm.copy() is a context manager that deletes it at the end of the block (async with exe.new() as vm: in async code). Otherwise, call vm.delete().

Working with VMs

for vm in exe.vms():                  # or exe.vms("web-*")
    print(vm.name, vm.status, vm.tags)

vm = exe.vm("build")                  # NotFoundError if there is none

copy = vm.copy("build-2", cpu=8)      # a new VM with a copy of the disk
vm.restart()                          # waits for SSH again, like new()
vm.pause(); vm.resume()               # a paused VM can be copied too
vm.rename("builder")                  # vm.name, ssh_dest, and https_url follow
vm.tag("ci", "linux"); vm.untag("linux")
vm.set_comment("nightly builds")
vm.resize(memory="16G", restart=True) # memory and CPU changes need a restart
vm.url(3000, "/api")                  # https://build.exe.xyz:3000/api
vm.stats(range="7d")
vm.delete()                           # NotFoundError if it does not exist

exe.delete("a", "b", vm)              # several at once; reports, rather than raises, failures

A VM's attributes (status, ssh_dest, https_url, tags, comment, region, created_at) are a snapshot: the SDK's own operations keep them current, and refresh() fetches the rest.

copy(), pause(), and restart() (and resize(restart=True)) first flush the VM's recent writes to its disk, so that copies and the restarted VM have them. Pass flush=False to skip that. It is best effort: it needs a token that may run ssh.

vm.delete() raises NotFoundError for a VM that is gone, while exe.delete() reports each VM's fate in its result, since some may be deleted and others not.

VM-scoped command groups are namespaces on the VM:

vm.share.port(3000)
vm.share.set_public()
vm.share.add("friend@example.com", message="have a look")
vm.share.add_link()
vm.domain.add("app.example.com")
vm.shelley.install()

Your account

Every other exe.dev command is a method, grouped as on the command line: ssh exe.dev team members is exe.team.members(), and flags are keyword arguments (--copy-tags=false is copy_tags=False).

exe.whoami()
exe.ssh_key.list()
exe.ssh_key.generate_api_key(label="ci", tag="ci", exp="30d")["token"]
exe.integrations.add("http-proxy", name="api", target="https://api.example.com", headers=["X-Key:secret"])
exe.integrations.attach("api", "tag:ci")
exe.team()                            # a group that is also a command is callable
exe.team.settings.vm_placement.pool("shared")
exe.billing.credits()

Each method's docstring is the command's help, with its CLI usage.

Results

Methods whose commands report JSON in a known shape return it as a dict, typed with a TypedDict from exedev.types (for example whoami() returns exedev.types.WhoamiResponse). Commands that report something else return their JSON, or their text.

Errors

All exceptions derive from exedev.ExeError:

  • APIError: exe.dev refused or failed a command; it has status, operation, and the response body. Subclasses: AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404, also for run() on a VM that does not exist), RateLimitError (429, after the client's max_retries), and VMNotRunningError (run() on a paused or stopped VM).
  • NetworkError: the request did not complete; whether it took effect is unknown.
  • ProtocolError: a response was not what the SDK expected.
  • CommandError: run(check=True) and the command failed.
  • CommandInterruptedError: the command's exit status is unknown. Subclasses: OutputLimitError, and CommandTimeoutError (also a TimeoutError).
  • VMReadyError: a VM did not become ready. Subclass: VMSetupError.
  • ProcessLostError: a spawned process ended without an exit status.

File methods raise the built-in FileNotFoundError and IsADirectoryError, and vm.process() raises ProcessLookupError.

Error messages include the server's reason, sanitized and shortened, but never your token or the command line.

Configuration

exe = exedev.Client(
    token="exe1.…",                     # default: EXE_TOKEN
    base_url="https://exe.dev",
    http_client=httpx.Client(proxy="http://proxy:3128"),
    max_retries=5,                      # retries of rate-limited requests, with backoff
)

Pass your own httpx.Client (or httpx.AsyncClient) for proxies, custom TLS, or tests; the SDK leaves it open. Close a client the SDK made with exe.close() (await exe.aclose()), or use the client as a context manager.

Development

This SDK is generated from the command tree of exe.dev's server, so it follows the commands exe.dev offers. src/exedev is a copy of sdk/python in the exe.dev repository; do not edit it here. To update it:

scripts/sync.sh ../exe

Run the tests with uv run pytest. They include integration tests that create VMs on exe.dev, with a token from EXE_TOKEN or EXE_TOKEN_FILE (default ~/.config/exedev/sdk-test-token); set EXE_SKIP_INTEGRATION=1 to skip them, or EXE_SKIP_SSH_INTEGRATION=1 to skip those that need ssh. uv run mypy and uv run pyright check types.

Metadata

Release files for exedev 0.3.1

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

Source distribution (sdist)

Source distribution for exedev 0.3.1
File Size Uploaded Provenance
exedev-0.3.1.tar.gz 117.3 kB Without provenance Details

Built distribution (wheel)

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

Total release size: 190.2 kB

Release files / exedev-0.3.1.tar.gz

Download URL exedev-0.3.1.tar.gz
Size 117.3 kB
Tags Source
SHA-256 checksum
How to use checksums
cc46d60bc7ccff10686cb193dd1352a9c7a355261a898e6dbf48ac370a0131a6
BLAKE2b-256 checksum
How to use checksums
b40b781c18416de624b91acaf03adfa7c60d9cbf27802f7d1abc8d8e46696233
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / exedev-0.3.1-py3-none-any.whl

Download URL exedev-0.3.1-py3-none-any.whl
Size 72.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4145f759036627b9b3473ebfa72ceb824fb98e503901fcbc94de6fde0ab032a1
BLAKE2b-256 checksum
How to use checksums
4e869bf6121b238e5deec2a29860f15bad2ca0aa6a6d8d7d46c43f2dadf6a730
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for exedev 0.3.1

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.

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