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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
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
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # Copies the generated SDK from an exe.dev repository checkout.
3
+ # Usage: scripts/sync.sh [path/to/exe] (default: ../exe)
4
+ set -eu
5
+ rm -rf src/exedev
6
+ cp -R "${1:-../exe}/sdk/python/exedev" src/