exedev 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exedev-0.2.0/.gitignore +7 -0
- exedev-0.2.0/LICENSE +21 -0
- exedev-0.2.0/PKG-INFO +295 -0
- exedev-0.2.0/README.md +278 -0
- exedev-0.2.0/pyproject.toml +41 -0
- exedev-0.2.0/scripts/sync.sh +6 -0
- exedev-0.2.0/src/exedev/__init__.py +66 -0
- exedev-0.2.0/src/exedev/_async.py +2707 -0
- exedev-0.2.0/src/exedev/_errors.py +344 -0
- exedev-0.2.0/src/exedev/_sync.py +2701 -0
- exedev-0.2.0/src/exedev/_wire.py +884 -0
- exedev-0.2.0/src/exedev/py.typed +0 -0
- exedev-0.2.0/src/exedev/types.py +536 -0
- exedev-0.2.0/tests/conftest.py +278 -0
- exedev-0.2.0/tests/test_client.py +330 -0
- exedev-0.2.0/tests/test_files.py +116 -0
- exedev-0.2.0/tests/test_integration.py +289 -0
- exedev-0.2.0/tests/test_processes.py +106 -0
- exedev-0.2.0/tests/test_run.py +316 -0
- exedev-0.2.0/tests/test_scripts.py +60 -0
- exedev-0.2.0/tests/test_vm.py +463 -0
- exedev-0.2.0/uv.lock +477 -0
exedev-0.2.0/.gitignore
ADDED
exedev-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bold Software, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
exedev-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: exedev
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for the exe.dev HTTPS API
|
|
5
|
+
Project-URL: Homepage, https://exe.dev
|
|
6
|
+
Project-URL: Documentation, https://exe.dev/docs/https-api
|
|
7
|
+
Project-URL: Source, https://github.com/boldsoftware/exe-sdk-python
|
|
8
|
+
Author: Bold Software, Inc.
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# exedev
|
|
19
|
+
|
|
20
|
+
The official Python SDK for [exe.dev](https://exe.dev): create VMs, run
|
|
21
|
+
commands on them, and manage your account, from sync or async code.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install exedev # or: uv add exedev
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import exedev
|
|
31
|
+
|
|
32
|
+
exe = exedev.Client() # reads EXE_TOKEN
|
|
33
|
+
|
|
34
|
+
with exe.new(image="ubuntu:24.04") as vm: # deleted at the end of the block
|
|
35
|
+
print(vm.name, vm.https_url)
|
|
36
|
+
result = vm.run("uname -a", check=True)
|
|
37
|
+
print(result.stdout)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`new()` returns once the VM runs commands, so the first `run()` needs no
|
|
41
|
+
retry loop, and if it fails or is interrupted on the way, it deletes the VM
|
|
42
|
+
rather than leak it. Create a token with `ssh exe.dev ssh-key generate-api-key`; its
|
|
43
|
+
permissions decide which methods work.
|
|
44
|
+
|
|
45
|
+
The same API is available for asyncio:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
import exedev
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with exedev.AsyncClient() as exe:
|
|
54
|
+
async with exe.new() as vm:
|
|
55
|
+
result = await vm.run(["python3", "-c", "print('hello')"], check=True)
|
|
56
|
+
print(result.stdout)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`Client` and `AsyncClient`, and `VM` and `AsyncVM`, have the same methods;
|
|
63
|
+
the async ones are coroutines.
|
|
64
|
+
|
|
65
|
+
## Running commands
|
|
66
|
+
|
|
67
|
+
`vm.run()` runs a command once, in the VM user's login shell, and returns a
|
|
68
|
+
`CommandResult` with `exit_code`, `stdout`, `stderr` (text), `stdout_bytes`,
|
|
69
|
+
`stderr_bytes` (exact bytes), and `ok`.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
vm.run("make -j8 && make test") # a shell command line
|
|
73
|
+
vm.run(["cat", "/etc/os-release"]) # an argument list, quoted for you
|
|
74
|
+
vm.run("npm test", cwd="/app", env={"CI": "1"})
|
|
75
|
+
vm.run("pytest", check=True) # nonzero exit raises CommandError
|
|
76
|
+
vm.run("./long-job", timeout=600) # stopped after 10 minutes
|
|
77
|
+
vm.run(llm_written_program) # any size: long commands are uploaded first
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
As with `subprocess.run`, a nonzero exit status is a result, not an
|
|
81
|
+
exception, unless you pass `check=True`. The `CommandError` it raises carries
|
|
82
|
+
the `result`, and its message ends with the last line of standard error.
|
|
83
|
+
|
|
84
|
+
Stream output as it arrives with `on_stdout` and `on_stderr`, which receive
|
|
85
|
+
it a line at a time, without the line ending (and may be coroutine functions
|
|
86
|
+
with `AsyncVM`):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
vm.run("apt-get install -y ffmpeg", on_stdout=print, on_stderr=print)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Output is captured up to `max_output` bytes (8 MiB by default); beyond that,
|
|
93
|
+
`OutputLimitError`. For large output, pass `capture=False` and stream it.
|
|
94
|
+
|
|
95
|
+
If the connection breaks before the exit status arrives, `run()` raises
|
|
96
|
+
`CommandInterruptedError` with whatever output it captured: the command may
|
|
97
|
+
or may not have finished, so do not blindly retry one that is unsafe to run
|
|
98
|
+
twice. Closing the connection, whether by `timeout`, a callback raising, or
|
|
99
|
+
cancelling the asyncio task, stops the command and everything it started.
|
|
100
|
+
|
|
101
|
+
`run()` lasts only as long as its request. For servers and long jobs, use
|
|
102
|
+
`spawn()`.
|
|
103
|
+
|
|
104
|
+
## Background processes
|
|
105
|
+
|
|
106
|
+
`vm.spawn()` starts a command and returns a `Process` at once. The process
|
|
107
|
+
runs on its own, whatever becomes of your program, and its output goes to
|
|
108
|
+
files on the VM.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
server = vm.spawn("python3 -m http.server 8000", name="web", cwd="site")
|
|
112
|
+
print(vm.url(8000)) # its URL through the exe.dev proxy
|
|
113
|
+
server.logs(tail=20) # Logs(stdout=..., stderr=...)
|
|
114
|
+
server.poll() # None while it runs, then its exit status
|
|
115
|
+
server.terminate() # or kill(), or send_signal("HUP")
|
|
116
|
+
|
|
117
|
+
job = vm.spawn("make -j8 all")
|
|
118
|
+
result = job.wait(timeout=3600, check=True) # a CommandResult; survives lost connections
|
|
119
|
+
|
|
120
|
+
vm.process("web") # found again later, from any program
|
|
121
|
+
vm.processes() # everything spawn() started
|
|
122
|
+
|
|
123
|
+
with vm.spawn("./worker") as worker: # killed at the end of the block
|
|
124
|
+
...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Signals reach the process and everything it started. If the VM restarts,
|
|
128
|
+
`poll()` and `wait()` on a process it was running raise `ProcessLostError`.
|
|
129
|
+
|
|
130
|
+
## Files
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
vm.write_file("app/config.json", json.dumps(config)) # str or bytes; makes directories
|
|
134
|
+
vm.write_file("bin/tool", script, mode=0o755)
|
|
135
|
+
data = vm.read_file("results/out.csv") # bytes; FileNotFoundError if missing
|
|
136
|
+
vm.upload("./src", "app/src", exclude=[".git", "node_modules", "*.pyc"])
|
|
137
|
+
vm.download("app/build", "./build") # a file or a directory
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Paths on the VM are relative to the home directory unless absolute.
|
|
141
|
+
Transfers go through `/exec` in parts of about 64 KB, sent in parallel, and
|
|
142
|
+
compressed when that helps: expect a few seconds per megabyte.
|
|
143
|
+
|
|
144
|
+
## Creating VMs
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
vm = exe.new(
|
|
148
|
+
name="build", # default: a generated name
|
|
149
|
+
image="ghcr.io/me/ci:latest",
|
|
150
|
+
cpu=4, memory="8G", disk="50G",
|
|
151
|
+
env={"MODE": "ci"},
|
|
152
|
+
tags=["ci"],
|
|
153
|
+
setup_script="#!/bin/sh\napt-get install -y jq",
|
|
154
|
+
wait_until="setup", # "ssh" (default), "setup", or "created"
|
|
155
|
+
email=False, # the default; True emails you about it
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`wait_until="ssh"` returns once the VM runs commands; `"setup"` also waits for
|
|
160
|
+
its setup script to finish, raising `VMSetupError` (with the script's
|
|
161
|
+
`exit_code` and `output`) if it fails; `"created"` returns at once. Waiting
|
|
162
|
+
needs a token that may run `ssh`. If the VM is not ready within `timeout`
|
|
163
|
+
seconds (60, or 600 for setup), `VMReadyError` is raised.
|
|
164
|
+
|
|
165
|
+
If waiting fails, or is interrupted by an exception, a `KeyboardInterrupt`,
|
|
166
|
+
or cancellation, `new()` deletes the VM before raising. Pass
|
|
167
|
+
`keep_on_failure=True` to keep it for debugging, as `err.vm`.
|
|
168
|
+
|
|
169
|
+
A VM from `new()` or `vm.copy()` is a context manager that deletes it at the
|
|
170
|
+
end of the block (`async with exe.new() as vm:` in async code). Otherwise,
|
|
171
|
+
call `vm.delete()`.
|
|
172
|
+
|
|
173
|
+
## Working with VMs
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
for vm in exe.vms(): # or exe.vms("web-*")
|
|
177
|
+
print(vm.name, vm.status, vm.tags)
|
|
178
|
+
|
|
179
|
+
vm = exe.vm("build") # NotFoundError if there is none
|
|
180
|
+
|
|
181
|
+
copy = vm.copy("build-2", cpu=8) # a new VM with a copy of the disk (syncs it first)
|
|
182
|
+
vm.restart() # waits for SSH again, like new()
|
|
183
|
+
vm.pause(); vm.resume()
|
|
184
|
+
vm.rename("builder") # vm.name, ssh_dest, and https_url follow
|
|
185
|
+
vm.tag("ci", "linux"); vm.untag("linux")
|
|
186
|
+
vm.set_comment("nightly builds")
|
|
187
|
+
vm.resize(memory="16G", restart=True) # memory and CPU changes need a restart
|
|
188
|
+
vm.url(3000, "/api") # https://build.exe.xyz:3000/api
|
|
189
|
+
vm.stats(range="7d")
|
|
190
|
+
vm.delete() # NotFoundError if it does not exist
|
|
191
|
+
|
|
192
|
+
exe.delete("a", "b", vm) # several at once; reports, rather than raises, failures
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A VM's attributes (`status`, `ssh_dest`, `https_url`, `tags`, `comment`,
|
|
196
|
+
`region`, `created_at`) are a snapshot: the SDK's own operations keep them
|
|
197
|
+
current, and `refresh()` fetches the rest.
|
|
198
|
+
|
|
199
|
+
`vm.delete()` raises `NotFoundError` for a VM that is gone, while
|
|
200
|
+
`exe.delete()` reports each VM's fate in its result, since some may be
|
|
201
|
+
deleted and others not.
|
|
202
|
+
|
|
203
|
+
VM-scoped command groups are namespaces on the VM:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
vm.share.port(3000)
|
|
207
|
+
vm.share.set_public()
|
|
208
|
+
vm.share.add("friend@example.com", message="have a look")
|
|
209
|
+
vm.share.add_link()
|
|
210
|
+
vm.domain.add("app.example.com")
|
|
211
|
+
vm.shelley.install()
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Your account
|
|
215
|
+
|
|
216
|
+
Every other exe.dev command is a method, grouped as on the command line:
|
|
217
|
+
`ssh exe.dev team members` is `exe.team.members()`, and flags are keyword
|
|
218
|
+
arguments (`--copy-tags=false` is `copy_tags=False`).
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
exe.whoami()
|
|
222
|
+
exe.ssh_key.list()
|
|
223
|
+
exe.ssh_key.generate_api_key(label="ci", vm="build", exp="30d")
|
|
224
|
+
exe.integrations.add("http-proxy", name="api", target="https://api.example.com", headers=["X-Key:secret"])
|
|
225
|
+
exe.integrations.attach("api", "tag:ci")
|
|
226
|
+
exe.team() # a group that is also a command is callable
|
|
227
|
+
exe.team.settings.vm_placement.pool("shared")
|
|
228
|
+
exe.billing.credits()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Each method's docstring is the command's help, with its CLI usage.
|
|
232
|
+
|
|
233
|
+
## Results
|
|
234
|
+
|
|
235
|
+
Methods whose commands report JSON in a known shape return it as a dict,
|
|
236
|
+
typed with a `TypedDict` from `exedev.types` (for example `whoami()` returns
|
|
237
|
+
`exedev.types.WhoamiResponse`). Commands that report something else return
|
|
238
|
+
their JSON, or their text.
|
|
239
|
+
|
|
240
|
+
## Errors
|
|
241
|
+
|
|
242
|
+
All exceptions derive from `exedev.ExeError`:
|
|
243
|
+
|
|
244
|
+
- `APIError`: exe.dev refused or failed a command; it has `status`,
|
|
245
|
+
`operation`, and the response `body`. Subclasses: `AuthenticationError`
|
|
246
|
+
(401), `PermissionDeniedError` (403), `NotFoundError` (404, also for
|
|
247
|
+
`run()` on a VM that does not exist), `RateLimitError` (429, after the
|
|
248
|
+
client's `max_retries`), and `VMNotRunningError` (`run()` on a paused or
|
|
249
|
+
stopped VM).
|
|
250
|
+
- `NetworkError`: the request did not complete; whether it took effect is
|
|
251
|
+
unknown.
|
|
252
|
+
- `ProtocolError`: a response was not what the SDK expected.
|
|
253
|
+
- `CommandError`: `run(check=True)` and the command failed.
|
|
254
|
+
- `CommandInterruptedError`: the command's exit status is unknown.
|
|
255
|
+
Subclasses: `OutputLimitError`, and `CommandTimeoutError` (also a
|
|
256
|
+
`TimeoutError`).
|
|
257
|
+
- `VMReadyError`: a VM did not become ready. Subclass: `VMSetupError`.
|
|
258
|
+
- `ProcessLostError`: a spawned process ended without an exit status.
|
|
259
|
+
|
|
260
|
+
File methods raise the built-in `FileNotFoundError` and `IsADirectoryError`,
|
|
261
|
+
and `vm.process()` raises `ProcessLookupError`.
|
|
262
|
+
|
|
263
|
+
Error messages include the server's reason, sanitized and shortened, but never
|
|
264
|
+
your token or the command line.
|
|
265
|
+
|
|
266
|
+
## Configuration
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
exe = exedev.Client(
|
|
270
|
+
token="exe1.…", # default: EXE_TOKEN
|
|
271
|
+
base_url="https://exe.dev",
|
|
272
|
+
http_client=httpx.Client(proxy="http://proxy:3128"),
|
|
273
|
+
max_retries=5, # retries of rate-limited requests, with backoff
|
|
274
|
+
)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Pass your own `httpx.Client` (or `httpx.AsyncClient`) for proxies, custom
|
|
278
|
+
TLS, or tests; the SDK leaves it open. Close a client the SDK made with
|
|
279
|
+
`exe.close()` (`await exe.aclose()`), or use the client as a context manager.
|
|
280
|
+
|
|
281
|
+
## Development
|
|
282
|
+
|
|
283
|
+
This SDK is generated from the command tree of exe.dev's server, so it
|
|
284
|
+
follows the commands exe.dev offers. `src/exedev` is a copy of `sdk/python`
|
|
285
|
+
in the exe.dev repository; do not edit it here. To update it:
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
scripts/sync.sh ../exe
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Run the tests with `uv run pytest`. They include integration tests that
|
|
292
|
+
create VMs on exe.dev, with a token from `EXE_TOKEN` or `EXE_TOKEN_FILE`
|
|
293
|
+
(default `~/.config/exedev/sdk-test-token`); set `EXE_SKIP_INTEGRATION=1` to
|
|
294
|
+
skip them, or `EXE_SKIP_SSH_INTEGRATION=1` to skip those that need `ssh`.
|
|
295
|
+
`uv run mypy` and `uv run pyright` check types.
|
exedev-0.2.0/README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# exedev
|
|
2
|
+
|
|
3
|
+
The official Python SDK for [exe.dev](https://exe.dev): create VMs, run
|
|
4
|
+
commands on them, and manage your account, from sync or async code.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install exedev # or: uv add exedev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import exedev
|
|
14
|
+
|
|
15
|
+
exe = exedev.Client() # reads EXE_TOKEN
|
|
16
|
+
|
|
17
|
+
with exe.new(image="ubuntu:24.04") as vm: # deleted at the end of the block
|
|
18
|
+
print(vm.name, vm.https_url)
|
|
19
|
+
result = vm.run("uname -a", check=True)
|
|
20
|
+
print(result.stdout)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`new()` returns once the VM runs commands, so the first `run()` needs no
|
|
24
|
+
retry loop, and if it fails or is interrupted on the way, it deletes the VM
|
|
25
|
+
rather than leak it. Create a token with `ssh exe.dev ssh-key generate-api-key`; its
|
|
26
|
+
permissions decide which methods work.
|
|
27
|
+
|
|
28
|
+
The same API is available for asyncio:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
import exedev
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def main() -> None:
|
|
36
|
+
async with exedev.AsyncClient() as exe:
|
|
37
|
+
async with exe.new() as vm:
|
|
38
|
+
result = await vm.run(["python3", "-c", "print('hello')"], check=True)
|
|
39
|
+
print(result.stdout)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`Client` and `AsyncClient`, and `VM` and `AsyncVM`, have the same methods;
|
|
46
|
+
the async ones are coroutines.
|
|
47
|
+
|
|
48
|
+
## Running commands
|
|
49
|
+
|
|
50
|
+
`vm.run()` runs a command once, in the VM user's login shell, and returns a
|
|
51
|
+
`CommandResult` with `exit_code`, `stdout`, `stderr` (text), `stdout_bytes`,
|
|
52
|
+
`stderr_bytes` (exact bytes), and `ok`.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
vm.run("make -j8 && make test") # a shell command line
|
|
56
|
+
vm.run(["cat", "/etc/os-release"]) # an argument list, quoted for you
|
|
57
|
+
vm.run("npm test", cwd="/app", env={"CI": "1"})
|
|
58
|
+
vm.run("pytest", check=True) # nonzero exit raises CommandError
|
|
59
|
+
vm.run("./long-job", timeout=600) # stopped after 10 minutes
|
|
60
|
+
vm.run(llm_written_program) # any size: long commands are uploaded first
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
As with `subprocess.run`, a nonzero exit status is a result, not an
|
|
64
|
+
exception, unless you pass `check=True`. The `CommandError` it raises carries
|
|
65
|
+
the `result`, and its message ends with the last line of standard error.
|
|
66
|
+
|
|
67
|
+
Stream output as it arrives with `on_stdout` and `on_stderr`, which receive
|
|
68
|
+
it a line at a time, without the line ending (and may be coroutine functions
|
|
69
|
+
with `AsyncVM`):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
vm.run("apt-get install -y ffmpeg", on_stdout=print, on_stderr=print)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Output is captured up to `max_output` bytes (8 MiB by default); beyond that,
|
|
76
|
+
`OutputLimitError`. For large output, pass `capture=False` and stream it.
|
|
77
|
+
|
|
78
|
+
If the connection breaks before the exit status arrives, `run()` raises
|
|
79
|
+
`CommandInterruptedError` with whatever output it captured: the command may
|
|
80
|
+
or may not have finished, so do not blindly retry one that is unsafe to run
|
|
81
|
+
twice. Closing the connection, whether by `timeout`, a callback raising, or
|
|
82
|
+
cancelling the asyncio task, stops the command and everything it started.
|
|
83
|
+
|
|
84
|
+
`run()` lasts only as long as its request. For servers and long jobs, use
|
|
85
|
+
`spawn()`.
|
|
86
|
+
|
|
87
|
+
## Background processes
|
|
88
|
+
|
|
89
|
+
`vm.spawn()` starts a command and returns a `Process` at once. The process
|
|
90
|
+
runs on its own, whatever becomes of your program, and its output goes to
|
|
91
|
+
files on the VM.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
server = vm.spawn("python3 -m http.server 8000", name="web", cwd="site")
|
|
95
|
+
print(vm.url(8000)) # its URL through the exe.dev proxy
|
|
96
|
+
server.logs(tail=20) # Logs(stdout=..., stderr=...)
|
|
97
|
+
server.poll() # None while it runs, then its exit status
|
|
98
|
+
server.terminate() # or kill(), or send_signal("HUP")
|
|
99
|
+
|
|
100
|
+
job = vm.spawn("make -j8 all")
|
|
101
|
+
result = job.wait(timeout=3600, check=True) # a CommandResult; survives lost connections
|
|
102
|
+
|
|
103
|
+
vm.process("web") # found again later, from any program
|
|
104
|
+
vm.processes() # everything spawn() started
|
|
105
|
+
|
|
106
|
+
with vm.spawn("./worker") as worker: # killed at the end of the block
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Signals reach the process and everything it started. If the VM restarts,
|
|
111
|
+
`poll()` and `wait()` on a process it was running raise `ProcessLostError`.
|
|
112
|
+
|
|
113
|
+
## Files
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
vm.write_file("app/config.json", json.dumps(config)) # str or bytes; makes directories
|
|
117
|
+
vm.write_file("bin/tool", script, mode=0o755)
|
|
118
|
+
data = vm.read_file("results/out.csv") # bytes; FileNotFoundError if missing
|
|
119
|
+
vm.upload("./src", "app/src", exclude=[".git", "node_modules", "*.pyc"])
|
|
120
|
+
vm.download("app/build", "./build") # a file or a directory
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Paths on the VM are relative to the home directory unless absolute.
|
|
124
|
+
Transfers go through `/exec` in parts of about 64 KB, sent in parallel, and
|
|
125
|
+
compressed when that helps: expect a few seconds per megabyte.
|
|
126
|
+
|
|
127
|
+
## Creating VMs
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
vm = exe.new(
|
|
131
|
+
name="build", # default: a generated name
|
|
132
|
+
image="ghcr.io/me/ci:latest",
|
|
133
|
+
cpu=4, memory="8G", disk="50G",
|
|
134
|
+
env={"MODE": "ci"},
|
|
135
|
+
tags=["ci"],
|
|
136
|
+
setup_script="#!/bin/sh\napt-get install -y jq",
|
|
137
|
+
wait_until="setup", # "ssh" (default), "setup", or "created"
|
|
138
|
+
email=False, # the default; True emails you about it
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`wait_until="ssh"` returns once the VM runs commands; `"setup"` also waits for
|
|
143
|
+
its setup script to finish, raising `VMSetupError` (with the script's
|
|
144
|
+
`exit_code` and `output`) if it fails; `"created"` returns at once. Waiting
|
|
145
|
+
needs a token that may run `ssh`. If the VM is not ready within `timeout`
|
|
146
|
+
seconds (60, or 600 for setup), `VMReadyError` is raised.
|
|
147
|
+
|
|
148
|
+
If waiting fails, or is interrupted by an exception, a `KeyboardInterrupt`,
|
|
149
|
+
or cancellation, `new()` deletes the VM before raising. Pass
|
|
150
|
+
`keep_on_failure=True` to keep it for debugging, as `err.vm`.
|
|
151
|
+
|
|
152
|
+
A VM from `new()` or `vm.copy()` is a context manager that deletes it at the
|
|
153
|
+
end of the block (`async with exe.new() as vm:` in async code). Otherwise,
|
|
154
|
+
call `vm.delete()`.
|
|
155
|
+
|
|
156
|
+
## Working with VMs
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
for vm in exe.vms(): # or exe.vms("web-*")
|
|
160
|
+
print(vm.name, vm.status, vm.tags)
|
|
161
|
+
|
|
162
|
+
vm = exe.vm("build") # NotFoundError if there is none
|
|
163
|
+
|
|
164
|
+
copy = vm.copy("build-2", cpu=8) # a new VM with a copy of the disk (syncs it first)
|
|
165
|
+
vm.restart() # waits for SSH again, like new()
|
|
166
|
+
vm.pause(); vm.resume()
|
|
167
|
+
vm.rename("builder") # vm.name, ssh_dest, and https_url follow
|
|
168
|
+
vm.tag("ci", "linux"); vm.untag("linux")
|
|
169
|
+
vm.set_comment("nightly builds")
|
|
170
|
+
vm.resize(memory="16G", restart=True) # memory and CPU changes need a restart
|
|
171
|
+
vm.url(3000, "/api") # https://build.exe.xyz:3000/api
|
|
172
|
+
vm.stats(range="7d")
|
|
173
|
+
vm.delete() # NotFoundError if it does not exist
|
|
174
|
+
|
|
175
|
+
exe.delete("a", "b", vm) # several at once; reports, rather than raises, failures
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A VM's attributes (`status`, `ssh_dest`, `https_url`, `tags`, `comment`,
|
|
179
|
+
`region`, `created_at`) are a snapshot: the SDK's own operations keep them
|
|
180
|
+
current, and `refresh()` fetches the rest.
|
|
181
|
+
|
|
182
|
+
`vm.delete()` raises `NotFoundError` for a VM that is gone, while
|
|
183
|
+
`exe.delete()` reports each VM's fate in its result, since some may be
|
|
184
|
+
deleted and others not.
|
|
185
|
+
|
|
186
|
+
VM-scoped command groups are namespaces on the VM:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
vm.share.port(3000)
|
|
190
|
+
vm.share.set_public()
|
|
191
|
+
vm.share.add("friend@example.com", message="have a look")
|
|
192
|
+
vm.share.add_link()
|
|
193
|
+
vm.domain.add("app.example.com")
|
|
194
|
+
vm.shelley.install()
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Your account
|
|
198
|
+
|
|
199
|
+
Every other exe.dev command is a method, grouped as on the command line:
|
|
200
|
+
`ssh exe.dev team members` is `exe.team.members()`, and flags are keyword
|
|
201
|
+
arguments (`--copy-tags=false` is `copy_tags=False`).
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
exe.whoami()
|
|
205
|
+
exe.ssh_key.list()
|
|
206
|
+
exe.ssh_key.generate_api_key(label="ci", vm="build", exp="30d")
|
|
207
|
+
exe.integrations.add("http-proxy", name="api", target="https://api.example.com", headers=["X-Key:secret"])
|
|
208
|
+
exe.integrations.attach("api", "tag:ci")
|
|
209
|
+
exe.team() # a group that is also a command is callable
|
|
210
|
+
exe.team.settings.vm_placement.pool("shared")
|
|
211
|
+
exe.billing.credits()
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Each method's docstring is the command's help, with its CLI usage.
|
|
215
|
+
|
|
216
|
+
## Results
|
|
217
|
+
|
|
218
|
+
Methods whose commands report JSON in a known shape return it as a dict,
|
|
219
|
+
typed with a `TypedDict` from `exedev.types` (for example `whoami()` returns
|
|
220
|
+
`exedev.types.WhoamiResponse`). Commands that report something else return
|
|
221
|
+
their JSON, or their text.
|
|
222
|
+
|
|
223
|
+
## Errors
|
|
224
|
+
|
|
225
|
+
All exceptions derive from `exedev.ExeError`:
|
|
226
|
+
|
|
227
|
+
- `APIError`: exe.dev refused or failed a command; it has `status`,
|
|
228
|
+
`operation`, and the response `body`. Subclasses: `AuthenticationError`
|
|
229
|
+
(401), `PermissionDeniedError` (403), `NotFoundError` (404, also for
|
|
230
|
+
`run()` on a VM that does not exist), `RateLimitError` (429, after the
|
|
231
|
+
client's `max_retries`), and `VMNotRunningError` (`run()` on a paused or
|
|
232
|
+
stopped VM).
|
|
233
|
+
- `NetworkError`: the request did not complete; whether it took effect is
|
|
234
|
+
unknown.
|
|
235
|
+
- `ProtocolError`: a response was not what the SDK expected.
|
|
236
|
+
- `CommandError`: `run(check=True)` and the command failed.
|
|
237
|
+
- `CommandInterruptedError`: the command's exit status is unknown.
|
|
238
|
+
Subclasses: `OutputLimitError`, and `CommandTimeoutError` (also a
|
|
239
|
+
`TimeoutError`).
|
|
240
|
+
- `VMReadyError`: a VM did not become ready. Subclass: `VMSetupError`.
|
|
241
|
+
- `ProcessLostError`: a spawned process ended without an exit status.
|
|
242
|
+
|
|
243
|
+
File methods raise the built-in `FileNotFoundError` and `IsADirectoryError`,
|
|
244
|
+
and `vm.process()` raises `ProcessLookupError`.
|
|
245
|
+
|
|
246
|
+
Error messages include the server's reason, sanitized and shortened, but never
|
|
247
|
+
your token or the command line.
|
|
248
|
+
|
|
249
|
+
## Configuration
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
exe = exedev.Client(
|
|
253
|
+
token="exe1.…", # default: EXE_TOKEN
|
|
254
|
+
base_url="https://exe.dev",
|
|
255
|
+
http_client=httpx.Client(proxy="http://proxy:3128"),
|
|
256
|
+
max_retries=5, # retries of rate-limited requests, with backoff
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Pass your own `httpx.Client` (or `httpx.AsyncClient`) for proxies, custom
|
|
261
|
+
TLS, or tests; the SDK leaves it open. Close a client the SDK made with
|
|
262
|
+
`exe.close()` (`await exe.aclose()`), or use the client as a context manager.
|
|
263
|
+
|
|
264
|
+
## Development
|
|
265
|
+
|
|
266
|
+
This SDK is generated from the command tree of exe.dev's server, so it
|
|
267
|
+
follows the commands exe.dev offers. `src/exedev` is a copy of `sdk/python`
|
|
268
|
+
in the exe.dev repository; do not edit it here. To update it:
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
scripts/sync.sh ../exe
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Run the tests with `uv run pytest`. They include integration tests that
|
|
275
|
+
create VMs on exe.dev, with a token from `EXE_TOKEN` or `EXE_TOKEN_FILE`
|
|
276
|
+
(default `~/.config/exedev/sdk-test-token`); set `EXE_SKIP_INTEGRATION=1` to
|
|
277
|
+
skip them, or `EXE_SKIP_SSH_INTEGRATION=1` to skip those that need `ssh`.
|
|
278
|
+
`uv run mypy` and `uv run pyright` check types.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "exedev"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Official Python SDK for the exe.dev HTTPS API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Bold Software, Inc." }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = ["httpx>=0.27"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Framework :: AsyncIO",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://exe.dev"
|
|
19
|
+
Documentation = "https://exe.dev/docs/https-api"
|
|
20
|
+
Source = "https://github.com/boldsoftware/exe-sdk-python"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=8", "mypy>=1.10", "pyright>=1.1.380", "ruff>=0.5"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling>=1.26"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
strict = true
|
|
34
|
+
files = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pyright]
|
|
37
|
+
include = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 120
|
|
41
|
+
extend-exclude = ["src/exedev", "README.md"] # generated; aligned comments
|