exedev 0.2.0__py3-none-any.whl

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/_async.py ADDED
@@ -0,0 +1,2707 @@
1
+ # Code generated by "go run ./cmd/gensdkpy" in the exe.dev repository. DO NOT EDIT.
2
+
3
+ """The asynchronous API: AsyncClient and AsyncVM."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import base64
9
+ import contextlib
10
+ import errno
11
+ import functools
12
+ import inspect
13
+ import os
14
+ import secrets
15
+ import shlex
16
+ import signal
17
+ import stat
18
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
19
+ from datetime import datetime
20
+ from pathlib import Path, PurePosixPath
21
+ from time import monotonic
22
+ from types import TracebackType
23
+ from typing import Any, Literal
24
+ from urllib.parse import urlsplit, urlunsplit
25
+
26
+ import httpx
27
+
28
+ from . import types as t
29
+ from ._errors import (
30
+ APIError,
31
+ CommandError,
32
+ CommandInterruptedError,
33
+ CommandTimeoutError,
34
+ ExeError,
35
+ NetworkError,
36
+ NotFoundError,
37
+ OutputLimitError,
38
+ PermissionDeniedError,
39
+ ProcessLostError,
40
+ ProtocolError,
41
+ VMReadyError,
42
+ VMSetupError,
43
+ api_error,
44
+ is_error_document,
45
+ parse_documents,
46
+ ssh_error,
47
+ )
48
+ from ._wire import (
49
+ DEFAULT_BASE_URL,
50
+ DOWNLOAD_SCRIPT,
51
+ HEADERS,
52
+ IS_DIRECTORY,
53
+ KILL_SCRIPT,
54
+ LIST_SCRIPT,
55
+ LOGS_SCRIPT,
56
+ MAX_OUTPUT,
57
+ MISSING,
58
+ PARALLEL,
59
+ PART_SCRIPT,
60
+ PART_SIZE,
61
+ READ_SCRIPT,
62
+ RETRYABLE,
63
+ RUN_SCRIPT,
64
+ RUNNING,
65
+ SETUP_PROBE,
66
+ SPAWN_SCRIPT,
67
+ STATE_SCRIPT,
68
+ TIMEOUT,
69
+ UNLIMITED,
70
+ UNTAR_SCRIPT,
71
+ UPLOADED_COMMAND,
72
+ WAIT_SCRIPT,
73
+ WRITE_SCRIPT,
74
+ Arg,
75
+ CommandResult,
76
+ Flag,
77
+ Logs,
78
+ Output,
79
+ ProcessState,
80
+ SetupFailure,
81
+ Stream,
82
+ VMInfo,
83
+ check_ready,
84
+ command_line,
85
+ creates,
86
+ env_flags,
87
+ file_mode,
88
+ fits,
89
+ is_ndjson,
90
+ on_off,
91
+ pack,
92
+ process_name,
93
+ process_state,
94
+ ready_timeout,
95
+ remote_path,
96
+ required,
97
+ retry_delay,
98
+ rm_result,
99
+ setup_state,
100
+ sh,
101
+ signal_name,
102
+ ssh_command,
103
+ stream_timeout,
104
+ tar_directory,
105
+ untar,
106
+ vm_info,
107
+ vm_list,
108
+ vm_name,
109
+ )
110
+
111
+ __all__ = ["AsyncClient", "AsyncProcess", "AsyncVM"]
112
+
113
+ OutputCallback = Callable[[str], object]
114
+ """Called with each line of output as it arrives, decoded as UTF-8, without its line ending. May be a coroutine function."""
115
+
116
+ WaitUntil = Literal["ssh", "setup", "created"]
117
+
118
+
119
+ class _AsyncTransport:
120
+ """Runs exe.dev commands over HTTPS: POST /exec with the command line as the body."""
121
+
122
+ def __init__(self, token: str, base_url: str, http_client: httpx.AsyncClient | None, max_retries: int) -> None:
123
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
124
+ raise TypeError("max_retries must be a nonnegative int")
125
+ self._url = base_url.rstrip("/") + "/exec"
126
+ self._headers = {**HEADERS, "Authorization": f"Bearer {token}"}
127
+ self._owned = http_client is None
128
+ self._http = http_client if http_client is not None else httpx.AsyncClient(timeout=TIMEOUT)
129
+ self._retries = max_retries
130
+
131
+ async def aclose(self) -> None:
132
+ if self._owned:
133
+ await self._http.aclose()
134
+
135
+ async def _send(self, command: str, timeout: httpx.Timeout = TIMEOUT, stream: bool = False) -> httpx.Response:
136
+ """POST command, retrying while the server says 429 (before running anything)."""
137
+ attempt = 0
138
+ while True:
139
+ request = self._http.build_request(
140
+ "POST", self._url, content=command.encode(), headers=self._headers, timeout=timeout
141
+ )
142
+ response = await self._http.send(request, stream=stream)
143
+ if response.status_code != 429 or attempt >= self._retries:
144
+ return response
145
+ await response.aclose()
146
+ await asyncio.sleep(retry_delay(response, attempt))
147
+ attempt += 1
148
+
149
+ async def documents(
150
+ self, path: str, args: Sequence[Arg] = (), flags: Sequence[Flag] = ()
151
+ ) -> tuple[int, str, list[Any] | None]:
152
+ """A successful response's status, body, and JSON documents, including in-band error documents."""
153
+ try:
154
+ response = await self._send(command_line(path, args, flags))
155
+ except httpx.RequestError as err:
156
+ raise NetworkError(path, err) from err
157
+ body = response.text
158
+ if not response.is_success:
159
+ raise api_error(path, response.status_code, body)
160
+ return response.status_code, body, parse_documents(body)
161
+
162
+ async def call(self, path: str, args: Sequence[Arg] = (), flags: Sequence[Flag] = (), json: bool = False) -> Any:
163
+ """Run a command: its JSON document, or its text output if it is not JSON and json is false."""
164
+ status, body, docs = await self.documents(path, args, flags)
165
+ if docs is None:
166
+ if json:
167
+ raise ProtocolError(path)
168
+ return body
169
+ if any(is_error_document(d) for d in docs):
170
+ raise api_error(path, status, body)
171
+ return docs[0] if len(docs) == 1 else docs
172
+
173
+ async def ssh(
174
+ self,
175
+ vm: str,
176
+ command: str,
177
+ *,
178
+ capture: bool = True,
179
+ max_output: int = MAX_OUTPUT,
180
+ on_stdout: OutputCallback | None = None,
181
+ on_stderr: OutputCallback | None = None,
182
+ timeout: float | None = None,
183
+ ) -> CommandResult:
184
+ output = Output(capture, max_output)
185
+ if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0):
186
+ raise ValueError("timeout must be a positive number of seconds")
187
+ deadline = None if timeout is None else monotonic() + timeout
188
+ callbacks = {"stdout": on_stdout, "stderr": on_stderr}
189
+
190
+ def expired() -> bool:
191
+ return deadline is not None and monotonic() >= deadline
192
+
193
+ def timed_out() -> CommandInterruptedError:
194
+ return output.interrupted(CommandTimeoutError, "the command did not finish within its timeout")
195
+
196
+ try:
197
+ response = await self._send(
198
+ f"ssh {shlex.quote(vm)} {shlex.quote(command)}", stream_timeout(timeout), stream=True
199
+ )
200
+ except httpx.RequestError as err:
201
+ # The request may have reached the VM: whether the command ran is unknown.
202
+ raise (timed_out() if expired() else output.interrupted()) from err
203
+ try:
204
+ if not response.is_success:
205
+ try:
206
+ await response.aread()
207
+ except httpx.RequestError as err:
208
+ raise NetworkError("ssh", err) from err
209
+ raise ssh_error(vm, response.status_code, response.text)
210
+ if not is_ndjson(response):
211
+ # An older server may be streaming the command's raw output, which
212
+ # can be endless: do not read it as an error, or claim the command
213
+ # did not run.
214
+ raise ProtocolError("ssh")
215
+ chunks = response.aiter_bytes()
216
+ while True:
217
+ try:
218
+ chunk = await anext(chunks)
219
+ except StopAsyncIteration:
220
+ break
221
+ except httpx.RequestError as err:
222
+ raise (timed_out() if expired() else output.interrupted()) from err
223
+ for stream, data in output.feed(chunk):
224
+ await _deliver(callbacks[stream], output, stream, data)
225
+ if expired():
226
+ raise timed_out()
227
+ for stream, data in output.finish():
228
+ await _deliver(callbacks[stream], output, stream, data)
229
+ for stream in ("stdout", "stderr"):
230
+ await _deliver(callbacks[stream], output, stream, b"", final=True)
231
+ return output.result()
232
+ finally:
233
+ # Before the end of the body, this ends the command.
234
+ await response.aclose()
235
+
236
+
237
+ async def _deliver(
238
+ callback: OutputCallback | None, output: Output, stream: Stream, data: bytes, final: bool = False
239
+ ) -> None:
240
+ """Call callback with each line that data completes."""
241
+ if callback is None:
242
+ return
243
+ for line in output.lines(stream, data, final):
244
+ result = callback(line)
245
+ if inspect.isawaitable(result):
246
+ await result
247
+
248
+
249
+ class AsyncVM:
250
+ """A VM on exe.dev.
251
+
252
+ Get one from AsyncClient.new(), AsyncClient.vms(), AsyncClient.vm(), or
253
+ AsyncVM.copy(). Its attributes are a snapshot: the SDK's own operations
254
+ keep them current, and refresh() fetches the rest.
255
+
256
+ A VM made by new() or copy() is a context manager that deletes it on
257
+ exit::
258
+
259
+ async with exe.new() as vm:
260
+ await vm.run("make test", check=True)
261
+ """
262
+
263
+ def __init__(self, transport: _AsyncTransport, info: VMInfo, *, owned: bool = False) -> None:
264
+ self._t = transport
265
+ self._info = info
266
+ self._owned = owned
267
+ self.domain = _AsyncVMDomain(self)
268
+ self.share = _AsyncVMShare(self)
269
+ self.shelley = _AsyncVMShelley(self)
270
+
271
+ @property
272
+ def name(self) -> str:
273
+ return self._info.name
274
+
275
+ @property
276
+ def status(self) -> str:
277
+ """Such as "starting", "running", or "paused", when last seen."""
278
+ return self._info.status
279
+
280
+ @property
281
+ def ssh_dest(self) -> str:
282
+ """The SSH destination, such as "myvm.exe.xyz"."""
283
+ return self._info.ssh_dest
284
+
285
+ @property
286
+ def https_url(self) -> str:
287
+ """The VM's web URL, such as "https://myvm.exe.xyz/". url() makes others."""
288
+ return self._info.https_url
289
+
290
+ @property
291
+ def tags(self) -> tuple[str, ...]:
292
+ return self._info.tags
293
+
294
+ @property
295
+ def comment(self) -> str:
296
+ """The VM's comment; "" when it has none."""
297
+ return self._info.comment
298
+
299
+ @property
300
+ def region(self) -> str | None:
301
+ """The VM's region; None until listed (see refresh())."""
302
+ return self._info.region
303
+
304
+ @property
305
+ def created_at(self) -> datetime | None:
306
+ """When the VM was created; None until listed (see refresh())."""
307
+ return self._info.created_at
308
+
309
+ @property
310
+ def prompt_error(self) -> str | None:
311
+ """Why new(prompt=...) did not reach Shelley, when it did not."""
312
+ return self._info.prompt_error
313
+
314
+ def url(self, port: int | None = None, path: str = "/") -> str:
315
+ """A URL that reaches the VM through exe.dev's HTTPS proxy.
316
+
317
+ Without a port, it is https_url, which reaches the VM's default port
318
+ (see share.port()). With one, it reaches that port; the proxy serves
319
+ ports 3000 to 9999. Who may visit depends on how the VM is shared.
320
+ """
321
+ if port is not None and (not isinstance(port, int) or isinstance(port, bool) or not 0 < port < 65536):
322
+ raise ValueError("port must be an int from 1 to 65535")
323
+ if not isinstance(path, str) or not path.startswith("/"):
324
+ raise ValueError("path must be a str starting with /")
325
+ url = urlsplit(self.https_url)
326
+ netloc = url.netloc if port is None else f"{url.hostname}:{port}"
327
+ return urlunsplit((url.scheme, netloc, path, "", ""))
328
+
329
+ def __repr__(self) -> str:
330
+ return f"<{type(self).__name__} {self.name!r} status={self.status!r}>"
331
+
332
+ async def __aenter__(self) -> AsyncVM:
333
+ if not self._owned:
334
+ raise TypeError("only a VM made by new() or copy() deletes itself at the end of a with block")
335
+ return self
336
+
337
+ async def __aexit__(
338
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
339
+ ) -> None:
340
+ with contextlib.suppress(NotFoundError):
341
+ await self.delete()
342
+
343
+ def _absorb(self, result: object) -> None:
344
+ """Take the status, comment, and tags a command reports for this VM into the snapshot."""
345
+ if not isinstance(result, dict) or result.get("vm_name") != self.name:
346
+ return
347
+ if isinstance(status := result.get("status"), str):
348
+ self._info.status = status
349
+ if isinstance(comment := result.get("comment"), str):
350
+ self._info.comment = comment
351
+ tags = result.get("tags")
352
+ if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags):
353
+ self._info.tags = tuple(tags)
354
+
355
+ def _renamed(self, result: Mapping[str, object]) -> None:
356
+ """Take a new name, and the destinations that change with it."""
357
+ ssh_dest, https_url = result.get("ssh_dest"), result.get("https_url")
358
+ if not isinstance(ssh_dest, str) or not ssh_dest or not isinstance(https_url, str) or not https_url:
359
+ raise ProtocolError("rename")
360
+ self._info.name = vm_name(result["new_name"])
361
+ self._info.ssh_dest = ssh_dest
362
+ self._info.https_url = https_url
363
+
364
+ async def run(
365
+ self,
366
+ command: str | Sequence[str],
367
+ *,
368
+ check: bool = False,
369
+ timeout: float | None = None,
370
+ cwd: str | os.PathLike[str] | None = None,
371
+ env: Mapping[str, str] | None = None,
372
+ on_stdout: OutputCallback | None = None,
373
+ on_stderr: OutputCallback | None = None,
374
+ capture: bool = True,
375
+ max_output: int = MAX_OUTPUT,
376
+ ) -> CommandResult:
377
+ """Run a command on the VM, once, and return how it ended.
378
+
379
+ A string is a shell command line, which the VM user's login shell
380
+ runs as a login shell, so PATH is as in a terminal. A sequence of
381
+ strings is an argument list, which is quoted for you:
382
+ ``vm.run(["cat", path])``.
383
+
384
+ Like subprocess.run, a nonzero exit status is a result, not an
385
+ exception, unless check is true; then it raises CommandError.
386
+
387
+ cwd is the directory to run it in, and env variables to set for it.
388
+
389
+ on_stdout and on_stderr are called with each line of output, without
390
+ its line ending, as it arrives; ``on_stdout=print`` works. They are
391
+ called one at a time, and may be coroutine functions. An exception
392
+ from one stops the command and propagates.
393
+ Output is also captured, up to max_output bytes in all (8 MiB by
394
+ default; more raises OutputLimitError). For long or large output,
395
+ pass capture=False and stream it.
396
+
397
+ timeout is in seconds; a command still running then is stopped,
398
+ with every process it started, and CommandTimeoutError raised.
399
+ Without a timeout, a command may run for as long as it likes.
400
+
401
+ run() returns once the command has exited and its output has
402
+ closed, so a process left running in the background with stdout or
403
+ stderr open keeps it waiting. Start those with spawn().
404
+
405
+ If the connection breaks first, CommandInterruptedError: whether the
406
+ command finished is unknown. A command line too long for one request
407
+ is uploaded as a file first. Requires ssh permission.
408
+ """
409
+ script = await self._fit(ssh_command(command, cwd, env), lambda line: sh(RUN_SCRIPT, line))
410
+ result = await self._t.ssh(
411
+ self.name,
412
+ script,
413
+ capture=capture,
414
+ max_output=max_output,
415
+ on_stdout=on_stdout,
416
+ on_stderr=on_stderr,
417
+ timeout=timeout,
418
+ )
419
+ if check and result.exit_code != 0:
420
+ raise CommandError(result)
421
+ return result
422
+
423
+ async def _fit(self, line: str, make: Callable[[str], str]) -> str:
424
+ """make(line), or if that does not fit in a request, make() of a
425
+ command line that runs line from a file uploaded first."""
426
+ command = make(line)
427
+ if fits(self.name, command):
428
+ return command
429
+ path = f"/tmp/.exedev-command-{secrets.token_hex(8)}"
430
+ await self.write_file(path, line)
431
+ return make(UPLOADED_COMMAND.format(path=path))
432
+
433
+ async def _script(self, command: str, max_output: int = MAX_OUTPUT, ok: Sequence[int] = ()) -> CommandResult:
434
+ """Run one of the SDK's scripts. An exit status other than 0 or those in ok raises CommandError."""
435
+ result = await self._t.ssh(self.name, command, max_output=max_output)
436
+ if result.exit_code != 0 and result.exit_code not in ok:
437
+ raise CommandError(result)
438
+ return result
439
+
440
+ async def _parallel(self, jobs: Sequence[Callable[[], Awaitable[object]]]) -> None:
441
+ """Run jobs, PARALLEL at a time. The first to fail stops the rest."""
442
+ semaphore = asyncio.Semaphore(PARALLEL)
443
+
444
+ async def one(job: Callable[[], Awaitable[object]]) -> None:
445
+ async with semaphore:
446
+ await job()
447
+
448
+ tasks = [asyncio.ensure_future(one(job)) for job in jobs]
449
+ try:
450
+ await asyncio.gather(*tasks)
451
+ finally:
452
+ for task in tasks:
453
+ task.cancel()
454
+ await asyncio.gather(*tasks, return_exceptions=True)
455
+
456
+ def _file_error(self, exit_code: int, path: object) -> None:
457
+ if exit_code == MISSING:
458
+ raise FileNotFoundError(errno.ENOENT, f"No such file or directory on VM {self.name}", str(path))
459
+ if exit_code == IS_DIRECTORY:
460
+ raise IsADirectoryError(errno.EISDIR, f"Is a directory on VM {self.name}", str(path))
461
+
462
+ async def write_file(self, path: str | PurePosixPath, data: str | bytes, *, mode: int | None = None) -> None:
463
+ """Write a file on the VM, replacing it, and making its directory if need be.
464
+
465
+ path is relative to the home directory unless it is absolute. A str
466
+ is written as UTF-8. mode, such as 0o755, sets the file's permissions.
467
+
468
+ A request carries at most 64 KB, so a large file goes in parts,
469
+ several at a time. The VM needs base64, and gzip for a large file.
470
+ Requires ssh permission.
471
+ """
472
+ remote = remote_path(path)
473
+ packed, gz = pack(data)
474
+ encoded = base64.b64encode(packed).decode()
475
+ options = (file_mode(mode), "gz" if gz else "")
476
+ command = sh(WRITE_SCRIPT, remote, encoded, *options, "")
477
+ if fits(self.name, command):
478
+ await self._script(command)
479
+ return
480
+ parts = f"/tmp/.exedev-upload-{secrets.token_hex(8)}"
481
+ try:
482
+ await self._parallel(
483
+ [
484
+ functools.partial(self._script, sh(PART_SCRIPT, parts, f"{i:06d}", encoded[start : start + PART_SIZE]))
485
+ for i, start in enumerate(range(0, len(encoded), PART_SIZE))
486
+ ]
487
+ )
488
+ except Exception:
489
+ with contextlib.suppress(ExeError):
490
+ await self._t.ssh(self.name, f"rm -rf {parts}", capture=False, timeout=30)
491
+ raise
492
+ await self._script(sh(WRITE_SCRIPT, remote, "", *options, parts))
493
+
494
+ async def read_file(self, path: str | PurePosixPath) -> bytes:
495
+ """The contents of a file on the VM, which raises FileNotFoundError if
496
+ there is none. path is relative to the home directory unless it is
497
+ absolute. Requires ssh permission."""
498
+ result = await self._script(sh(READ_SCRIPT, remote_path(path)), UNLIMITED, ok=(MISSING, IS_DIRECTORY))
499
+ self._file_error(result.exit_code, path)
500
+ return result.stdout_bytes
501
+
502
+ async def upload(
503
+ self, local: str | os.PathLike[str], remote: str | PurePosixPath, *, exclude: Sequence[str] = ()
504
+ ) -> None:
505
+ """Copy a local file or directory to the VM.
506
+
507
+ A file is written to remote, with its permissions. A directory's
508
+ contents are copied into remote, which is made if need be; exclude
509
+ leaves out files and directories whose names match any of its
510
+ patterns, such as ``[".git", "node_modules", "*.pyc"]``. The VM needs
511
+ tar for a directory. Requires ssh permission.
512
+ """
513
+ source = Path(local)
514
+ destination = remote_path(remote)
515
+ if source.is_dir():
516
+ archive = await asyncio.to_thread(tar_directory, source, exclude)
517
+ temporary = f"/tmp/.exedev-upload-{secrets.token_hex(8)}.tgz"
518
+ await self.write_file(temporary, archive)
519
+ await self._script(sh(UNTAR_SCRIPT, temporary, destination))
520
+ else:
521
+ await self.write_file(destination, source.read_bytes(), mode=stat.S_IMODE(source.stat().st_mode))
522
+
523
+ async def download(self, remote: str | PurePosixPath, local: str | os.PathLike[str]) -> None:
524
+ """Copy a file or directory from the VM.
525
+
526
+ A file is written to local. A directory's contents are copied into
527
+ local, which is made if need be. A path on the VM that does not
528
+ exist raises FileNotFoundError. The VM needs tar for a directory.
529
+ Requires ssh permission.
530
+ """
531
+ result = await self._script(sh(DOWNLOAD_SCRIPT, remote_path(remote)), UNLIMITED, ok=(MISSING,))
532
+ self._file_error(result.exit_code, remote)
533
+ kind, data = result.stdout_bytes[:1], result.stdout_bytes[1:]
534
+ destination = Path(local)
535
+ if kind == b"D":
536
+ await asyncio.to_thread(untar, data, destination)
537
+ elif kind == b"F":
538
+ destination.write_bytes(data)
539
+ else:
540
+ raise ProtocolError("download")
541
+
542
+ @creates
543
+ async def spawn(
544
+ self,
545
+ command: str | Sequence[str],
546
+ *,
547
+ name: str | None = None,
548
+ cwd: str | os.PathLike[str] | None = None,
549
+ env: Mapping[str, str] | None = None,
550
+ ) -> AsyncProcess:
551
+ """Start a command in the background, and return at once.
552
+
553
+ The command runs as run() runs it, but on its own: until it exits,
554
+ or is killed, whatever becomes of this program or its connection.
555
+ Its output goes to files on the VM; see AsyncProcess.logs().
556
+
557
+ name identifies the process, so that process() finds it again
558
+ later, from anywhere; by default, it is random. It must not be the
559
+ name of a process still running. A process is a context manager that
560
+ kills it on exit::
561
+
562
+ async with vm.spawn("python3 -m http.server 8000") as server:
563
+ ...
564
+
565
+ Requires ssh permission, and setsid on the VM.
566
+ """
567
+ name = process_name(name if name is not None else secrets.token_hex(4))
568
+ script = await self._fit(ssh_command(command, cwd, env), lambda line: sh(SPAWN_SCRIPT, name, line))
569
+ result = await self._script(script, ok=(RUNNING,))
570
+ if result.exit_code == RUNNING:
571
+ raise ExeError(f"a process named {name} is already running on VM {self.name}")
572
+ try:
573
+ pid = int(result.stdout)
574
+ except ValueError:
575
+ raise ProtocolError("spawn") from None
576
+ return AsyncProcess(self, name, ProcessState(pid))
577
+
578
+ async def process(self, name: str) -> AsyncProcess:
579
+ """The process that spawn() started with this name, running or not.
580
+ ProcessLookupError if there is none. Requires ssh permission."""
581
+ result = await self._script(sh(STATE_SCRIPT, process_name(name)), ok=(MISSING,))
582
+ if result.exit_code == MISSING:
583
+ raise ProcessLookupError(f"no process {name} on VM {self.name}")
584
+ return AsyncProcess(self, name, process_state(result.stdout))
585
+
586
+ async def processes(self) -> list[AsyncProcess]:
587
+ """The processes spawn() has started, running or not. Requires ssh permission."""
588
+ result = await self._script(sh(LIST_SCRIPT))
589
+ processes = []
590
+ for line in result.stdout.splitlines():
591
+ name, _, state = line.partition(" ")
592
+ processes.append(AsyncProcess(self, process_name(name), process_state(state)))
593
+ return processes
594
+
595
+ async def refresh(self) -> None:
596
+ """Fetch the VM's attributes. Requires ls permission."""
597
+ self._info = await _find(self._t, self.name)
598
+
599
+ async def wait_until_ready(self, until: Literal["ssh", "setup"] = "ssh", *, timeout: float | None = None) -> None:
600
+ """Wait until the VM runs commands.
601
+
602
+ It probes SSH with ``true`` until it succeeds. With until="setup", it
603
+ then waits for the VM's setup script (exeuntu's exe-setup.service) to
604
+ finish, raising VMSetupError if it failed. A script on an image
605
+ without that unit (anything not built on exeuntu) never runs: that is
606
+ a VMSetupError too, with result "unsupported". This does not wait for
607
+ servers the VM starts.
608
+
609
+ timeout is in seconds, by default 60, or 600 for setup. Raises
610
+ VMReadyError. Requires ssh permission.
611
+ """
612
+ seconds = ready_timeout(until, timeout)
613
+ await self._wait(until, seconds)
614
+
615
+ async def _wait(self, until: str, timeout: float) -> None:
616
+ deadline = monotonic() + timeout
617
+ backoff = 0.1
618
+ probe = SETUP_PROBE if until == "setup" else "true"
619
+ last: BaseException | None = None
620
+ while (remaining := deadline - monotonic()) > 0:
621
+ try:
622
+ result = await self._t.ssh(
623
+ self.name, probe, capture=until == "setup", max_output=1 << 20, timeout=remaining
624
+ )
625
+ if result.exit_code != 0:
626
+ raise ProtocolError("ssh readiness probe")
627
+ state = setup_state(result.stdout) if until == "setup" else "done"
628
+ if state == "done":
629
+ # It answered over SSH, so it is running, whatever new or cp said then.
630
+ self._info.status = "running"
631
+ return
632
+ if isinstance(state, SetupFailure):
633
+ raise VMSetupError(self, state.result, state.exit_code, state.signal, state.output)
634
+ backoff = max(backoff, 1.0)
635
+ except CommandTimeoutError as err:
636
+ last = err
637
+ break
638
+ except (OutputLimitError, ProtocolError) as err:
639
+ raise VMReadyError(self, "is not SSH-ready") from err
640
+ except CommandInterruptedError as err:
641
+ # The probe is safe to rerun after a lost connection.
642
+ last = err
643
+ except PermissionDeniedError as err:
644
+ raise VMReadyError(
645
+ self,
646
+ "is not known to be ready: this token may not run ssh, "
647
+ 'which readiness needs (use wait_until="created")',
648
+ ) from err
649
+ except APIError as err:
650
+ # /exec reports these before a remote command starts. Others (not
651
+ # found, no SSH configured) fail fast.
652
+ if err.status != 422 or not RETRYABLE.search(err.body):
653
+ raise VMReadyError(self, "is not SSH-ready") from err
654
+ last = err
655
+ except NetworkError as err:
656
+ last = err
657
+ await asyncio.sleep(max(0.0, min(backoff, deadline - monotonic())))
658
+ backoff = min(backoff * 2, 2.0)
659
+ what = "setup did not finish in time" if until == "setup" else "SSH readiness timed out"
660
+ raise VMReadyError(self, f"is not ready: {what}") from last
661
+
662
+ async def _ready(self, wait_until: WaitUntil, timeout: float | None) -> None:
663
+ if wait_until != "created":
664
+ await self._wait(wait_until, ready_timeout(wait_until, timeout))
665
+
666
+ async def _created(self, wait_until: WaitUntil, timeout: float | None, keep_on_failure: bool) -> AsyncVM:
667
+ """Wait until a VM that new() or copy() made is ready. If that fails,
668
+ or is interrupted, delete it: the caller never received it."""
669
+ try:
670
+ await self._ready(wait_until, timeout)
671
+ except BaseException as err:
672
+ if not (keep_on_failure and isinstance(err, VMReadyError)):
673
+ with contextlib.suppress(Exception):
674
+ await self.delete()
675
+ raise
676
+ return self
677
+
678
+ async def _flush(self) -> None:
679
+ """Flush the VM's recent writes to its disk, if it is running. Best effort."""
680
+ with contextlib.suppress(ExeError):
681
+ await self._t.ssh(self.name, "sync", capture=False, timeout=60)
682
+
683
+ async def delete(self) -> None:
684
+ """Delete the VM. A VM that does not exist raises NotFoundError.
685
+
686
+ CLI: ``rm <vmname>...``
687
+
688
+ Tag-scoped API keys may run this command.
689
+ """
690
+ _, body, docs = await self._t.documents("rm", [self.name])
691
+ summary = rm_result(docs)
692
+ if summary["failed"] or len(summary["deleted"]) != 1:
693
+ raise api_error("rm", 404 if summary.get("not_found") else 422, body)
694
+
695
+ async def restart(self, *, wait_until: WaitUntil = "ssh", timeout: float | None = None) -> t.RestartResponse:
696
+ """Restart a VM.
697
+
698
+ CLI: ``restart <vmname>``
699
+
700
+ Tag-scoped API keys may run this command.
701
+
702
+ Args:
703
+ wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
704
+ also waits for its setup script to finish, and succeed; "created"
705
+ returns at once. Waiting needs ssh permission, and raises VMReadyError
706
+ if the VM does not become ready.
707
+ timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
708
+ """
709
+ check_ready(wait_until, timeout)
710
+ result: t.RestartResponse = await self._t.call("restart", [self.name], json=True)
711
+ await self._ready(wait_until, timeout)
712
+ return result
713
+
714
+ async def pause(self) -> t.PauseResponse:
715
+ """Pause a VM (snapshot RAM to disk, free its CPU and memory).
716
+
717
+ CLI: ``pause <vmname>``
718
+
719
+ Tag-scoped API keys may run this command.
720
+ """
721
+ result: t.PauseResponse = await self._t.call("pause", [self.name], json=True)
722
+ self._absorb(result)
723
+ return result
724
+
725
+ async def resume(self, *, wait_until: WaitUntil = "ssh", timeout: float | None = None) -> t.ResumeResponse:
726
+ """Resume a paused VM (restore RAM from disk).
727
+
728
+ CLI: ``resume <vmname>``
729
+
730
+ Tag-scoped API keys may run this command.
731
+
732
+ Args:
733
+ wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
734
+ also waits for its setup script to finish, and succeed; "created"
735
+ returns at once. Waiting needs ssh permission, and raises VMReadyError
736
+ if the VM does not become ready.
737
+ timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
738
+ """
739
+ check_ready(wait_until, timeout)
740
+ result: t.ResumeResponse = await self._t.call("resume", [self.name], json=True)
741
+ self._absorb(result)
742
+ await self._ready(wait_until, timeout)
743
+ return result
744
+
745
+ async def rename(self, new_name: str) -> t.RenameResponse:
746
+ """Rename the VM. Its ssh_dest and https_url change with its name.
747
+
748
+ CLI: ``rename <oldname> <newname>``
749
+
750
+ Tag-scoped API keys may run this command.
751
+ """
752
+ result: t.RenameResponse = await self._t.call("rename", [self.name, new_name], json=True)
753
+ if not isinstance(result, dict) or result.get("old_name") != self.name or result.get("new_name") != new_name:
754
+ raise ProtocolError("rename")
755
+ self._renamed(result)
756
+ return result
757
+
758
+ async def tag(self, *tag_names: str) -> t.TagResponse:
759
+ """Add tags to the VM.
760
+
761
+ CLI: ``tag [-d] <vm> <tag-name> [tag-name...]``
762
+
763
+ Tag-scoped API keys may run this command.
764
+ """
765
+ result: t.TagResponse = await self._t.call("tag", [self.name, required("tag name", tag_names)], json=True)
766
+ self._absorb(result)
767
+ return result
768
+
769
+ async def untag(self, *tag_names: str) -> t.TagResponse:
770
+ """Remove tags from the VM.
771
+
772
+ CLI: ``tag [-d] <vm> <tag-name> [tag-name...]``
773
+
774
+ Tag-scoped API keys may run this command.
775
+ """
776
+ result: t.TagResponse = await self._t.call(
777
+ "tag",
778
+ [self.name, required("tag name", tag_names)],
779
+ [("d", "bool", True)],
780
+ json=True,
781
+ )
782
+ self._absorb(result)
783
+ return result
784
+
785
+ @creates
786
+ async def copy(
787
+ self,
788
+ name: str | None = None,
789
+ *,
790
+ copy_tags: bool = True,
791
+ cpu: int | None = None,
792
+ disk: int | str | None = None,
793
+ memory: int | str | None = None,
794
+ pool: str | None = None,
795
+ standalone: bool = False,
796
+ flush: bool = True,
797
+ wait_until: WaitUntil = "ssh",
798
+ timeout: float | None = None,
799
+ keep_on_failure: bool = False,
800
+ ) -> AsyncVM:
801
+ """Copy the VM: a new VM with a copy of its disk, and by default its tags.
802
+
803
+ CLI: ``cp <source-vm> [new-name] [--pool=<name>] [--memory=<size>] [--cpu=<count>] [--disk=<size>]``
804
+
805
+ Tag-scoped API keys may run this command.
806
+
807
+ Args:
808
+ copy_tags: copy tags from source VM (use ``copy_tags=False`` to disable)
809
+ cpu: number of CPUs
810
+ disk: disk size (e.g., 20, 20GB, 50G)
811
+ memory: memory allocation (e.g., 4, 4GB, 8G)
812
+ pool: create the copy in one of your team's pools (see ``exe.pool.list()``)
813
+ standalone: create the copy as a standalone VM with its own reserved
814
+ capacity (requires a plan with standalone VMs and team approval)
815
+ flush: first flush the VM's recent writes to its disk, so that the copy has
816
+ them. Best effort: it needs ssh permission, and a running VM.
817
+ wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
818
+ also waits for its setup script to finish, and succeed; "created"
819
+ returns at once. Waiting needs ssh permission, and raises VMReadyError
820
+ if the VM does not become ready.
821
+ timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
822
+ keep_on_failure: keep a VM that does not become ready, for debugging:
823
+ VMReadyError.vm is it. By default, it is deleted, as it is when the wait
824
+ is interrupted.
825
+ """
826
+ check_ready(wait_until, timeout)
827
+ if flush:
828
+ await self._flush()
829
+ result = await self._t.call(
830
+ "cp",
831
+ [self.name, name],
832
+ [
833
+ ("copy-tags", "bool", None if copy_tags else False),
834
+ ("cpu", "number", cpu),
835
+ ("disk", "string", disk),
836
+ ("memory", "string", memory),
837
+ ("pool", "string", pool),
838
+ ("standalone", "bool", standalone or None),
839
+ ],
840
+ json=True,
841
+ )
842
+ vm = AsyncVM(self._t, vm_info(result, "cp"), owned=True)
843
+ return await vm._created(wait_until, timeout, keep_on_failure)
844
+
845
+ async def resize(
846
+ self,
847
+ *,
848
+ cpu: int | None = None,
849
+ disk: int | str | None = None,
850
+ memory: int | str | None = None,
851
+ restart: bool = False,
852
+ ) -> t.ResizeResponse:
853
+ """Resize the VM's memory, CPUs, or disk. Memory and CPU changes take effect when it restarts.
854
+
855
+ CLI: ``resize <vmname> [--memory=<size>] [--cpu=<count>] [--disk=<size>]``
856
+
857
+ Tag-scoped API keys may run this command.
858
+
859
+ Args:
860
+ cpu: number of CPUs
861
+ disk: new total disk size (e.g., 25, 25GB) - disk size can only increase;
862
+ billed by usage above your plan's included disk, not by size
863
+ memory: memory allocation (e.g., 4, 4GB, 8G)
864
+ restart: restart the VM afterwards, so that memory and cpu changes take
865
+ effect, and wait until it runs commands.
866
+ """
867
+ result: t.ResizeResponse = await self._t.call(
868
+ "resize",
869
+ [self.name],
870
+ [
871
+ ("cpu", "number", cpu),
872
+ ("disk", "string", disk),
873
+ ("memory", "string", memory),
874
+ ],
875
+ json=True,
876
+ )
877
+ if restart:
878
+ await self.restart()
879
+ return result
880
+
881
+ async def set_comment(self, text: str) -> t.CommentResponse:
882
+ """Set or clear a short comment on a VM.
883
+
884
+ CLI: ``comment <hostname> <text>``
885
+
886
+ Tag-scoped API keys may run this command.
887
+ """
888
+ result: t.CommentResponse = await self._t.call("comment", [self.name, text], json=True)
889
+ self._absorb(result)
890
+ return result
891
+
892
+ async def grant_support_root(self, enabled: bool) -> Any:
893
+ """Allow exe.dev support to log in to a VM.
894
+
895
+ CLI: ``grant-support-root <vmname> on|off``
896
+
897
+ Tag-scoped API keys may run this command.
898
+ """
899
+ return await self._t.call("grant-support-root", [self.name, on_off(enabled)])
900
+
901
+ async def stats(self, *, range: str | None = None) -> t.StatResponse:
902
+ """Show vCPU, disk, IO, and network (RX/TX) metrics for a VM.
903
+
904
+ CLI: ``stat <vm-name> [--range=24h|7d|30d]``
905
+
906
+ Tag-scoped API keys may run this command.
907
+
908
+ Args:
909
+ range: time range: 24h, 7d, or 30d (server default: 24h)
910
+ """
911
+ result: t.StatResponse = await self._t.call("stat", [self.name], [("range", "string", range)], json=True)
912
+ return result
913
+
914
+
915
+ async def _find(transport: _AsyncTransport, name: str) -> VMInfo:
916
+ result = await transport.call("ls", [name], json=True)
917
+ for info in vm_list(result):
918
+ if info.name == name:
919
+ return info
920
+ raise NotFoundError("ls", 404, f'{{"error": "VM {name} not found"}}')
921
+
922
+
923
+ class AsyncProcess:
924
+ """A process that AsyncVM.spawn() started on a VM.
925
+
926
+ Like subprocess.Popen, but the process does not depend on this program:
927
+ it runs until it exits or is killed. Find it again later with
928
+ AsyncVM.process(name). Its attributes are a snapshot; poll() and wait()
929
+ update them.
930
+
931
+ As a context manager, it kills the process at the end of the block.
932
+ """
933
+
934
+ def __init__(self, vm: AsyncVM, name: str, state: ProcessState) -> None:
935
+ self.vm = vm
936
+ """The VM it runs on."""
937
+ self.name = name
938
+ """Its name, which AsyncVM.process() finds it by."""
939
+ self._state = state
940
+
941
+ @property
942
+ def pid(self) -> int:
943
+ """Its process ID on the VM, which is also its process group's."""
944
+ return self._state.pid
945
+
946
+ @property
947
+ def exit_code(self) -> int | None:
948
+ """Its exit status, or None if it was running when last seen.
949
+ A process killed by a signal has 128 plus the signal's number."""
950
+ return self._state.exit_code
951
+
952
+ def __repr__(self) -> str:
953
+ state = "lost" if self._state.lost else "running" if self.exit_code is None else f"exit_code={self.exit_code}"
954
+ return f"<{type(self).__name__} {self.name!r} on {self.vm.name!r} pid={self.pid} {state}>"
955
+
956
+ async def __aenter__(self) -> AsyncProcess:
957
+ return self
958
+
959
+ async def __aexit__(
960
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
961
+ ) -> None:
962
+ with contextlib.suppress(NotFoundError, ProcessLookupError):
963
+ await self.kill()
964
+
965
+ def _check(self) -> int | None:
966
+ if self._state.lost:
967
+ raise ProcessLostError(self)
968
+ return self._state.exit_code
969
+
970
+ async def poll(self) -> int | None:
971
+ """Its exit status, or None if it is still running. Raises
972
+ ProcessLostError if it ended without one."""
973
+ result = await self.vm._script(sh(STATE_SCRIPT, self.name), ok=(MISSING,))
974
+ if result.exit_code == MISSING:
975
+ raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
976
+ self._state = process_state(result.stdout)
977
+ return self._check()
978
+
979
+ async def wait(
980
+ self, *, timeout: float | None = None, check: bool = False, capture: bool = True, max_output: int = MAX_OUTPUT
981
+ ) -> CommandResult:
982
+ """Wait for it to exit, and return how it ended, with its output.
983
+
984
+ A lost connection does not matter: wait() reconnects until it knows.
985
+ timeout is in seconds: TimeoutError if it is still running then
986
+ (which it still is). check, capture, and max_output are as for
987
+ AsyncVM.run(); with capture=False, the result has no output.
988
+ Raises ProcessLostError if it ended without an exit status.
989
+ """
990
+ deadline = None if timeout is None else monotonic() + timeout
991
+ nonce = secrets.token_hex(8)
992
+ backoff = 0.1
993
+ while True:
994
+ remaining = None if deadline is None else deadline - monotonic()
995
+ if remaining is not None and remaining <= 0:
996
+ raise TimeoutError(f"process {self.name} on VM {self.vm.name} is still running")
997
+ try:
998
+ command = sh(WAIT_SCRIPT, self.name, nonce, "1" if capture else "")
999
+ result = await self.vm._t.ssh(self.vm.name, command, max_output=max_output, timeout=remaining)
1000
+ break
1001
+ except CommandTimeoutError:
1002
+ continue
1003
+ except (CommandInterruptedError, NetworkError) as err:
1004
+ if isinstance(err, OutputLimitError):
1005
+ raise
1006
+ await asyncio.sleep(backoff)
1007
+ backoff = min(backoff * 2, 2.0)
1008
+ if result.exit_code == 0 and result.stdout == f"{nonce} missing":
1009
+ raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
1010
+ if result.exit_code == 0 and result.stdout == f"{nonce} lost":
1011
+ self._state.lost = True
1012
+ raise ProcessLostError(self)
1013
+ self._state.exit_code = result.exit_code
1014
+ if check and result.exit_code != 0:
1015
+ raise CommandError(result)
1016
+ return result
1017
+
1018
+ async def logs(self, *, tail: int | None = None) -> Logs:
1019
+ """Its output so far; with tail, at most that many lines of each stream."""
1020
+ if tail is not None and (not isinstance(tail, int) or isinstance(tail, bool) or tail < 0):
1021
+ raise ValueError("tail must be a nonnegative int")
1022
+ result = await self.vm._script(
1023
+ sh(LOGS_SCRIPT, self.name, "" if tail is None else str(tail)), UNLIMITED, ok=(MISSING,)
1024
+ )
1025
+ if result.exit_code == MISSING:
1026
+ raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
1027
+ return Logs(result.stdout, result.stderr)
1028
+
1029
+ async def send_signal(self, sig: int | str) -> None:
1030
+ """Send a signal, such as signal.SIGTERM or "HUP", to the process and
1031
+ everything it started, if it is running."""
1032
+ result = await self.vm._script(sh(KILL_SCRIPT, self.name, signal_name(sig)), ok=(MISSING,))
1033
+ if result.exit_code == MISSING:
1034
+ raise ProcessLookupError(f"no process {self.name} on VM {self.vm.name}")
1035
+
1036
+ async def terminate(self) -> None:
1037
+ """Send SIGTERM to the process and everything it started."""
1038
+ await self.send_signal(signal.SIGTERM)
1039
+
1040
+ async def kill(self) -> None:
1041
+ """Send SIGKILL to the process and everything it started."""
1042
+ await self.send_signal(signal.SIGKILL)
1043
+
1044
+
1045
+ class AsyncClient:
1046
+ """An exe.dev API client.
1047
+
1048
+ token is an exe.dev API token; by default, the EXE_TOKEN environment
1049
+ variable. Create one with ``ssh exe.dev ssh-key generate-api-key``. Its
1050
+ permissions decide which methods work.
1051
+
1052
+ http_client is an httpx.AsyncClient to send requests with, for proxies,
1053
+ custom TLS, or tests. The SDK does not close a client it was given.
1054
+
1055
+ A request refused for exceeding the rate limit, which runs nothing, is
1056
+ retried up to max_retries times, with backoff.
1057
+
1058
+ Use it as a context manager, or call aclose(), to release connections.
1059
+ """
1060
+
1061
+ def __init__(
1062
+ self,
1063
+ token: str | None = None,
1064
+ *,
1065
+ base_url: str = DEFAULT_BASE_URL,
1066
+ http_client: httpx.AsyncClient | None = None,
1067
+ max_retries: int = 5,
1068
+ ) -> None:
1069
+ token = token if token is not None else os.environ.get("EXE_TOKEN")
1070
+ if not token:
1071
+ raise ExeError("an exe.dev API token is required: pass token= or set EXE_TOKEN")
1072
+ self._t = _AsyncTransport(token, base_url, http_client, max_retries)
1073
+ self.domain = _AsyncDomain(self._t)
1074
+ self.ssh_key = _AsyncSshKey(self._t)
1075
+ self.integrations = _AsyncIntegrations(self._t)
1076
+ self.team = _AsyncTeam(self._t)
1077
+ self.pool = _AsyncPool(self._t)
1078
+ self.billing = _AsyncBilling(self._t)
1079
+ self.invite = _AsyncInvite(self._t)
1080
+
1081
+ def __repr__(self) -> str:
1082
+ return f"<{type(self).__name__} {self._t._url.removesuffix('/exec')!r}>"
1083
+
1084
+ async def aclose(self) -> None:
1085
+ """Close the HTTP client this client made, if it made one."""
1086
+ await self._t.aclose()
1087
+
1088
+ async def __aenter__(self) -> AsyncClient:
1089
+ return self
1090
+
1091
+ async def __aexit__(
1092
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
1093
+ ) -> None:
1094
+ await self.aclose()
1095
+
1096
+ async def vm(self, name: str) -> AsyncVM:
1097
+ """The VM with this name. NotFoundError if there is none. Requires ls permission."""
1098
+ return AsyncVM(self._t, await _find(self._t, vm_name(name)))
1099
+
1100
+ async def vms(self, pattern: str | None = None) -> list[AsyncVM]:
1101
+ """Your VMs; with a pattern such as ``"web-*"``, those it matches. Requires ls permission."""
1102
+ result = await self._t.call("ls", [pattern], json=True)
1103
+ return [AsyncVM(self._t, info) for info in vm_list(result)]
1104
+
1105
+ async def delete(self, *vms: str | AsyncVM) -> t.RmResponse:
1106
+ """Delete VMs in one request.
1107
+
1108
+ Unlike AsyncVM.delete(), VMs that were missing or could not be
1109
+ deleted do not raise: they are listed in failed, with reasons in
1110
+ errors. Requires rm permission.
1111
+ """
1112
+ names = list(dict.fromkeys(vm.name if isinstance(vm, AsyncVM) else vm_name(vm) for vm in vms))
1113
+ if not names:
1114
+ return {"deleted": [], "failed": [], "not_found": [], "errors": {}}
1115
+ _, _, docs = await self._t.documents("rm", [names])
1116
+ return rm_result(docs)
1117
+
1118
+ async def doc(self, *queries: str) -> Any:
1119
+ """Browse documentation.
1120
+
1121
+ CLI: ``doc [slug-or-query...]``
1122
+
1123
+ Tag-scoped API keys may run this command.
1124
+ """
1125
+ return await self._t.call("doc", [queries])
1126
+
1127
+ @creates
1128
+ async def new(
1129
+ self,
1130
+ *,
1131
+ comment: str | None = None,
1132
+ cpu: int | None = None,
1133
+ disk: int | str | None = None,
1134
+ env: Mapping[str, str] | None = None,
1135
+ image: str | None = None,
1136
+ integrations: Sequence[str] | None = None,
1137
+ memory: int | str | None = None,
1138
+ name: str | None = None,
1139
+ no_pool: bool = False,
1140
+ pool: str | None = None,
1141
+ prompt: str | None = None,
1142
+ registry_auth: str | None = None,
1143
+ setup_script: str | None = None,
1144
+ standalone: bool = False,
1145
+ tags: Sequence[str] | None = None,
1146
+ email: bool = False,
1147
+ wait_until: WaitUntil = "ssh",
1148
+ timeout: float | None = None,
1149
+ keep_on_failure: bool = False,
1150
+ ) -> AsyncVM:
1151
+ """Create a VM. By default, it returns once the VM runs commands.
1152
+
1153
+ Tag-scoped API keys may run this command.
1154
+
1155
+ Args:
1156
+ comment: short note about the VM (max 200 bytes)
1157
+ cpu: number of CPUs (default 2; run 'billing plan' for plan limits)
1158
+ disk: disk size (e.g., 20, 20GB, 50G)
1159
+ env: environment variable in KEY=VALUE format (can be specified multiple
1160
+ times)
1161
+ image: container image (server default: exeuntu)
1162
+ integrations: integration name to attach
1163
+ memory: memory allocation (e.g., 4, 4GB, 8G)
1164
+ name: VM name (auto-generated if not specified)
1165
+ no_pool: use ordinary placement (not available on plans that place new VMs
1166
+ in pools)
1167
+ pool: create the VM in one of your team's pools (see ``exe.pool.list()``)
1168
+ prompt: initial prompt to send to Shelley after VM creation (requires an
1169
+ image with Shelley, like exeuntu)
1170
+ registry_auth: private container registry credentials as USERNAME:PASSWORD
1171
+ (e.g. octocat:ghp_xxx) for the registry hosting ``image``
1172
+ setup_script: setup script to run on first boot (max 10KiB)
1173
+ standalone: create a standalone VM with its own reserved capacity (requires
1174
+ a plan with standalone VMs and team approval)
1175
+ tags: tag to add to the VM
1176
+ email: email you about the VM, as the command line does. By default, it does
1177
+ not.
1178
+ wait_until: "ssh" (the default) returns once the VM runs commands; "setup"
1179
+ also waits for its setup script to finish, and succeed; "created"
1180
+ returns at once. Waiting needs ssh permission, and raises VMReadyError
1181
+ if the VM does not become ready.
1182
+ timeout: seconds to wait for readiness; by default 60, or 600 for "setup".
1183
+ keep_on_failure: keep a VM that does not become ready, for debugging:
1184
+ VMReadyError.vm is it. By default, it is deleted, as it is when the wait
1185
+ is interrupted.
1186
+ """
1187
+ check_ready(wait_until, timeout)
1188
+ result = await self._t.call(
1189
+ "new",
1190
+ [],
1191
+ [
1192
+ ("comment", "string", comment),
1193
+ ("cpu", "number", cpu),
1194
+ ("disk", "string", disk),
1195
+ ("env", "repeated", env_flags(env)),
1196
+ ("image", "string", image),
1197
+ ("integration", "repeated", integrations),
1198
+ ("memory", "string", memory),
1199
+ ("name", "string", name),
1200
+ ("no-pool", "bool", no_pool or None),
1201
+ ("pool", "string", pool),
1202
+ ("prompt", "string", prompt),
1203
+ ("registry-auth", "string", registry_auth),
1204
+ ("setup-script", "string", setup_script.replace("\\", "\\\\") if setup_script is not None else None),
1205
+ ("standalone", "bool", standalone or None),
1206
+ ("tag", "repeated", tags),
1207
+ ("no-email", "bool", None if email else True),
1208
+ ],
1209
+ json=True,
1210
+ )
1211
+ vm = AsyncVM(self._t, vm_info(result, "new"), owned=True)
1212
+ return await vm._created(wait_until, timeout, keep_on_failure)
1213
+
1214
+ async def whoami(self) -> t.WhoamiResponse:
1215
+ """Show user information (email, keys, etc).
1216
+
1217
+ CLI: ``whoami``
1218
+
1219
+ Tag-scoped API keys may run this command.
1220
+ """
1221
+ result: t.WhoamiResponse = await self._t.call("whoami", [], json=True)
1222
+ return result
1223
+
1224
+ async def set_region(self, region_code: str) -> Any:
1225
+ """Set your preferred region for new VMs.
1226
+
1227
+ CLI: ``set-region <region-code>``
1228
+ """
1229
+ return await self._t.call("set-region", [region_code])
1230
+
1231
+ async def browser(self, *, qr: bool = False) -> Any:
1232
+ """Generate a magic link to log in to the website.
1233
+
1234
+ CLI: ``browser``
1235
+
1236
+ Args:
1237
+ qr: show QR code for the URL
1238
+ """
1239
+ return await self._t.call("browser", [], [("qr", "bool", qr or None)])
1240
+
1241
+
1242
+ class _AsyncDomain:
1243
+ """``domain`` commands."""
1244
+
1245
+ def __init__(self, t: _AsyncTransport) -> None:
1246
+ self._t = t
1247
+
1248
+ async def ls(self, *args: str, all: bool = False) -> Any:
1249
+ """List custom domains for a VM (or -a for all your VMs).
1250
+
1251
+ CLI: ``domain ls <vm> | domain ls -a``
1252
+
1253
+ Args:
1254
+ all: list domains across all your VMs
1255
+ """
1256
+ return await self._t.call("domain ls", [args], [("a", "bool", all or None)])
1257
+
1258
+
1259
+ class _AsyncSshKey:
1260
+ """``ssh-key`` commands."""
1261
+
1262
+ def __init__(self, t: _AsyncTransport) -> None:
1263
+ self._t = t
1264
+
1265
+ async def list(self) -> Any:
1266
+ """List all SSH keys associated with your account.
1267
+
1268
+ CLI: ``ssh-key list``
1269
+
1270
+ Tag-scoped API keys may run this command.
1271
+ """
1272
+ return await self._t.call("ssh-key list", [])
1273
+
1274
+ async def add(self, public_key: str, *, tag: str | None = None) -> Any:
1275
+ """Add a new SSH key to your account.
1276
+
1277
+ CLI: ``ssh-key add [--tag=TAG] <public-key>``
1278
+
1279
+ Tag-scoped API keys may run this command.
1280
+
1281
+ Args:
1282
+ tag: scope key to VMs with this tag
1283
+ """
1284
+ return await self._t.call("ssh-key add", [public_key], [("tag", "string", tag)])
1285
+
1286
+ async def remove(self, key: str) -> Any:
1287
+ """Remove an SSH key from your account.
1288
+
1289
+ CLI: ``ssh-key remove <name|fingerprint|public-key>``
1290
+
1291
+ Tag-scoped API keys may run this command.
1292
+ """
1293
+ return await self._t.call("ssh-key remove", [key])
1294
+
1295
+ async def rename(self, old_name: str, new_name: str) -> Any:
1296
+ """Rename an SSH key.
1297
+
1298
+ CLI: ``ssh-key rename <old-name> <new-name>``
1299
+
1300
+ Tag-scoped API keys may run this command.
1301
+ """
1302
+ return await self._t.call("ssh-key rename", [old_name, new_name])
1303
+
1304
+ async def generate_api_key(
1305
+ self,
1306
+ *,
1307
+ cmds: str | None = None,
1308
+ exp: str | None = None,
1309
+ label: str | None = None,
1310
+ vm: str | None = None,
1311
+ ) -> Any:
1312
+ """Generate an API key for the exe.dev HTTPS API or for a specific VM.
1313
+
1314
+ CLI: ``ssh-key generate-api-key [--label=NAME] [--vm=VMNAME] [--cmds=CMD1,CMD2] [--exp=30d]``
1315
+
1316
+ Tag-scoped API keys may run this command.
1317
+
1318
+ Args:
1319
+ cmds: comma-separated list of allowed commands (empty = defaults)
1320
+ exp: expiry duration (e.g. 30d, 1y) or 'never'
1321
+ label: label for this token's SSH key
1322
+ vm: scope key to a VM (authenticates to its HTTPS endpoints instead of
1323
+ exe.dev commands)
1324
+ """
1325
+ return await self._t.call(
1326
+ "ssh-key generate-api-key",
1327
+ [],
1328
+ [
1329
+ ("cmds", "string", cmds),
1330
+ ("exp", "string", exp),
1331
+ ("label", "string", label),
1332
+ ("vm", "string", vm),
1333
+ ],
1334
+ )
1335
+
1336
+
1337
+ class _AsyncIntegrations:
1338
+ """``integrations`` commands."""
1339
+
1340
+ def __init__(self, t: _AsyncTransport) -> None:
1341
+ self._t = t
1342
+ self.setup = _AsyncIntegrationsSetup(self._t)
1343
+
1344
+ async def list(self, *, usage: bool = False) -> Any:
1345
+ """List your integrations.
1346
+
1347
+ CLI: ``integrations list [--json [--usage]]``
1348
+
1349
+ Tag-scoped API keys may run this command.
1350
+
1351
+ Args:
1352
+ usage: include per-VM usage (lastUsedAt, usedByVMs); JSON only
1353
+ """
1354
+ return await self._t.call("integrations list", [], [("usage", "bool", usage or None)])
1355
+
1356
+ async def add(
1357
+ self,
1358
+ type: str,
1359
+ *,
1360
+ act_as_user: bool = False,
1361
+ attach: Sequence[str] | None = None,
1362
+ bearer: str | None = None,
1363
+ comment: str | None = None,
1364
+ default: bool = False,
1365
+ fields: str | None = None,
1366
+ for_: str | None = None,
1367
+ headers: Sequence[str] | None = None,
1368
+ host_keys: Sequence[str] | None = None,
1369
+ name: str | None = None,
1370
+ no_auth: bool = False,
1371
+ peer: bool = False,
1372
+ readonly: bool = False,
1373
+ repository: str | None = None,
1374
+ strip_prefix: str | None = None,
1375
+ target: str | None = None,
1376
+ team: bool = False,
1377
+ ) -> Any:
1378
+ """Add a new integration.
1379
+
1380
+
1381
+
1382
+ CLI: ``integrations add <type> [--default | --name=<name> [--team] [args...]]``
1383
+
1384
+ Args:
1385
+ act_as_user: authenticate as your GitHub user instead of the exe.dev app
1386
+ (github only); pushes and API calls are attributed to your GitHub
1387
+ account
1388
+ attach: attach to a spec (vm:<name>, tag:<name>, or auto:all)
1389
+ bearer: bearer token (shorthand for ``headers=["Authorization:Bearer
1390
+ TOKEN"]``; combines with ``headers``)
1391
+ comment: optional free-form comment stored with the integration
1392
+ default: install the canonical reflection integration with all fields;
1393
+ attaches to auto:all unless ``attach=["vm:<name>"]`` is supplied; no
1394
+ other customization
1395
+ fields: comma-separated reflection fields to expose (email, integrations,
1396
+ tags, comment, default_port); 'all' exposes every field including ones
1397
+ added in the future; 'none' disables every field (server default: all)
1398
+ for_: time-box every ``attach`` to a duration from now (e.g. 2h, 45m);
1399
+ access lapses automatically
1400
+ headers: header to inject (e.g. X-Auth:secret)
1401
+ host_keys: host keys for ssh-key: authorized_keys or known_hosts lines
1402
+ (hostnames are ignored), or '*' for any
1403
+ name: integration name (required unless ``default=True``)
1404
+ no_auth: create an endpoint integration with no injected authentication
1405
+ peer: authenticate with a generated API key scoped to the target VM;
1406
+ combines with ``headers``/``bearer``
1407
+ readonly: restrict the integration to read access (github only); git push
1408
+ and write API calls are rejected
1409
+ repository: GitHub repository in owner/repo format (required for github)
1410
+ strip_prefix: path prefix to strip from each request before forwarding
1411
+ (http-proxy only; e.g. /api/v3 makes /api/v3/users reach the target as
1412
+ /users)
1413
+ target: target URL (required for http-proxy)
1414
+ team: create as a team integration
1415
+ """
1416
+ return await self._t.call(
1417
+ "integrations add",
1418
+ [type],
1419
+ [
1420
+ ("act-as-user", "bool", act_as_user or None),
1421
+ ("attach", "repeated", attach),
1422
+ ("bearer", "string", bearer),
1423
+ ("comment", "string", comment),
1424
+ ("default", "bool", default or None),
1425
+ ("fields", "string", fields),
1426
+ ("for", "string", for_),
1427
+ ("header", "repeated", headers),
1428
+ ("host-key", "repeated", host_keys),
1429
+ ("name", "string", name),
1430
+ ("no-auth", "bool", no_auth or None),
1431
+ ("peer", "bool", peer or None),
1432
+ ("readonly", "bool", readonly or None),
1433
+ ("repository", "string", repository),
1434
+ ("strip-prefix", "string", strip_prefix),
1435
+ ("target", "string", target),
1436
+ ("team", "bool", team or None),
1437
+ ],
1438
+ )
1439
+
1440
+ async def remove(self, name: str, *, team: bool = False) -> Any:
1441
+ """Remove an integration.
1442
+
1443
+ CLI: ``integrations remove <name> [--team]``
1444
+
1445
+ Args:
1446
+ team: operate on a team integration
1447
+ """
1448
+ return await self._t.call("integrations remove", [name], [("team", "bool", team or None)])
1449
+
1450
+ async def test(self, name: str, *, team: bool = False) -> Any:
1451
+ """Test an integration's credential (connection check).
1452
+
1453
+ CLI: ``integrations test <name> [--team]``
1454
+
1455
+ Args:
1456
+ team: operate on a team integration
1457
+ """
1458
+ return await self._t.call("integrations test", [name], [("team", "bool", team or None)])
1459
+
1460
+ async def edit(
1461
+ self,
1462
+ name: str,
1463
+ *args: str,
1464
+ act_as_user: bool = False,
1465
+ bearer: str | None = None,
1466
+ clear_header: bool = False,
1467
+ comment: str | None = None,
1468
+ fields: str | None = None,
1469
+ headers: Sequence[str] | None = None,
1470
+ host_keys: Sequence[str] | None = None,
1471
+ no_auth: bool = False,
1472
+ readonly: bool = False,
1473
+ repository: str | None = None,
1474
+ strip_prefix: str | None = None,
1475
+ target: str | None = None,
1476
+ team: bool = False,
1477
+ webhook_url: str | None = None,
1478
+ ) -> Any:
1479
+ """Edit an integration.
1480
+
1481
+
1482
+
1483
+ CLI: ``integrations edit <name> [--team] [args...]``
1484
+
1485
+ Args:
1486
+ act_as_user: authenticate as your GitHub user instead of the exe.dev app
1487
+ (github only)
1488
+ bearer: bearer token (shorthand for ``headers=["Authorization:Bearer
1489
+ TOKEN"]``; combines with ``headers``)
1490
+ clear_header: remove injected headers for http-proxy
1491
+ comment: free-form comment stored with the integration
1492
+ fields: comma-separated reflection fields to expose (email, integrations,
1493
+ tags, comment, default_port); 'all' exposes every field including ones
1494
+ added in the future; 'none' disables every field
1495
+ headers: header to inject for http-proxy; replaces all existing headers
1496
+ host_keys: replacement host keys for ssh-key; replaces the whole list; one -
1497
+ reads them piped in
1498
+ no_auth: remove injected authentication for http-proxy
1499
+ readonly: restrict the integration to read access (github only)
1500
+ repository: GitHub repository in owner/repo format
1501
+ strip_prefix: path prefix to strip from each request before forwarding
1502
+ (http-proxy only; e.g. /api/v3; empty clears it)
1503
+ target: target URL for http-proxy
1504
+ team: edit a team integration
1505
+ webhook_url: replacement Slack or Discord webhook URL
1506
+ """
1507
+ return await self._t.call(
1508
+ "integrations edit",
1509
+ [name, args],
1510
+ [
1511
+ ("act-as-user", "bool", act_as_user or None),
1512
+ ("bearer", "string", bearer),
1513
+ ("clear-header", "bool", clear_header or None),
1514
+ ("comment", "string", comment),
1515
+ ("fields", "string", fields),
1516
+ ("header", "repeated", headers),
1517
+ ("host-key", "repeated", host_keys),
1518
+ ("no-auth", "bool", no_auth or None),
1519
+ ("readonly", "bool", readonly or None),
1520
+ ("repository", "string", repository),
1521
+ ("strip-prefix", "string", strip_prefix),
1522
+ ("target", "string", target),
1523
+ ("team", "bool", team or None),
1524
+ ("webhook-url", "string", webhook_url),
1525
+ ],
1526
+ )
1527
+
1528
+ async def attach(
1529
+ self,
1530
+ name: str,
1531
+ spec: str,
1532
+ *,
1533
+ for_: str | None = None,
1534
+ team: bool = False,
1535
+ until: str | None = None,
1536
+ ) -> Any:
1537
+ """Attach an integration to a VM, tag, or all VMs.
1538
+
1539
+ A <spec> controls where the integration is mounted:
1540
+ vm:<vm-name> attach to a specific VM (personal only)
1541
+ tag:<tag-name> attach to every VM with the given tag
1542
+ auto:all attach to all current and future VMs (personal only)
1543
+
1544
+ You can attach the same integration multiple times with different specs.
1545
+ Team integrations only support tag:<tag-name>.
1546
+
1547
+ A grant can be time-boxed with ``for_=<duration>`` or ``until=<RFC3339>``;
1548
+ when it lapses, access stops immediately, with nothing to revoke.
1549
+ Re-attaching the same spec with a new ``for_``/``until`` extends or shortens it.
1550
+
1551
+ CLI: ``integrations attach <name> <spec> [--team] [--for <duration> | --until <time>]``
1552
+
1553
+ Args:
1554
+ for_: time-box the attachment to a duration from now (e.g. 2h, 45m); access
1555
+ lapses automatically
1556
+ team: operate on a team integration
1557
+ until: time-box the attachment until an RFC3339 instant (e.g.
1558
+ 2026-08-03T20:00:00Z)
1559
+ """
1560
+ return await self._t.call(
1561
+ "integrations attach",
1562
+ [name, spec],
1563
+ [
1564
+ ("for", "string", for_),
1565
+ ("team", "bool", team or None),
1566
+ ("until", "string", until),
1567
+ ],
1568
+ )
1569
+
1570
+ async def detach(self, name: str, spec: str, *, team: bool = False) -> Any:
1571
+ """Detach an integration from a VM, tag, or all VMs.
1572
+
1573
+ CLI: ``integrations detach <name> <spec> [--team]``
1574
+
1575
+ Args:
1576
+ team: operate on a team integration
1577
+ """
1578
+ return await self._t.call("integrations detach", [name, spec], [("team", "bool", team or None)])
1579
+
1580
+ async def rename(self, name: str, new_name: str, *, team: bool = False) -> Any:
1581
+ """Rename an integration.
1582
+
1583
+ CLI: ``integrations rename <name> <new-name> [--team]``
1584
+
1585
+ Args:
1586
+ team: operate on a team integration
1587
+ """
1588
+ return await self._t.call(
1589
+ "integrations rename",
1590
+ [name, new_name],
1591
+ [("team", "bool", team or None)],
1592
+ )
1593
+
1594
+ async def catalog(self, query: str | None = None) -> Any:
1595
+ """Browse the catalog of ready-made service integrations.
1596
+
1597
+ CLI: ``integrations catalog [service-or-search-term]``
1598
+ """
1599
+ return await self._t.call("integrations catalog", [query])
1600
+
1601
+
1602
+ class _AsyncIntegrationsSetup:
1603
+ """``integrations setup`` commands."""
1604
+
1605
+ def __init__(self, t: _AsyncTransport) -> None:
1606
+ self._t = t
1607
+
1608
+ async def github(self, *, delete: bool = False, list: bool = False, verify: bool = False) -> Any:
1609
+ """Set up GitHub integration.
1610
+
1611
+ CLI: ``integrations setup github [--list|--verify|-d]``
1612
+
1613
+ Args:
1614
+ delete: disconnect GitHub account
1615
+ list: list connected GitHub accounts
1616
+ verify: verify GitHub connections are working
1617
+ """
1618
+ return await self._t.call(
1619
+ "integrations setup github",
1620
+ [],
1621
+ [
1622
+ ("delete", "bool", delete or None),
1623
+ ("list", "bool", list or None),
1624
+ ("verify", "bool", verify or None),
1625
+ ],
1626
+ )
1627
+
1628
+ async def chatgpt(
1629
+ self,
1630
+ *,
1631
+ delete: bool = False,
1632
+ list: bool = False,
1633
+ name: str | None = None,
1634
+ verify: bool = False,
1635
+ ) -> Any:
1636
+ """Set up ChatGPT account access for LLM integrations.
1637
+
1638
+ CLI: ``integrations setup chatgpt [--name=<account-name>|--list|--verify|-d]``
1639
+
1640
+ Args:
1641
+ delete: disconnect ChatGPT account
1642
+ list: list connected ChatGPT accounts
1643
+ name: local ChatGPT account name (default chatgpt)
1644
+ verify: verify ChatGPT connections are working
1645
+ """
1646
+ return await self._t.call(
1647
+ "integrations setup chatgpt",
1648
+ [],
1649
+ [
1650
+ ("delete", "bool", delete or None),
1651
+ ("list", "bool", list or None),
1652
+ ("name", "string", name),
1653
+ ("verify", "bool", verify or None),
1654
+ ],
1655
+ )
1656
+
1657
+
1658
+ class _AsyncTeam:
1659
+ """``team`` commands."""
1660
+
1661
+ def __init__(self, t: _AsyncTransport) -> None:
1662
+ self._t = t
1663
+ self.billing = _AsyncTeamBilling(self._t)
1664
+ self.auth = _AsyncTeamAuth(self._t)
1665
+ self.settings = _AsyncTeamSettings(self._t)
1666
+ self.vm = _AsyncTeamVm(self._t)
1667
+
1668
+ async def __call__(self) -> Any:
1669
+ """View and manage your team."""
1670
+ return await self._t.call("team", [])
1671
+
1672
+ async def disable(self, *, yes: bool = False) -> Any:
1673
+ """Disband your team.
1674
+
1675
+ CLI: ``team disable``
1676
+
1677
+ Args:
1678
+ yes: skip the confirmation prompt (required for non-interactive use, e.g.
1679
+ the web UI)
1680
+ """
1681
+ return await self._t.call("team disable", [], [("yes", "bool", yes or None)])
1682
+
1683
+ async def members(self) -> Any:
1684
+ """List team members.
1685
+
1686
+ CLI: ``team members``
1687
+ """
1688
+ return await self._t.call("team members", [])
1689
+
1690
+ async def usage(self) -> Any:
1691
+ """Show team pool, disk, and bandwidth usage for the billing period.
1692
+
1693
+ CLI: ``team usage``
1694
+ """
1695
+ return await self._t.call("team usage", [])
1696
+
1697
+ async def add(self, email: str, role: Literal["user", "admin", "billing_owner"] | None = None) -> Any:
1698
+ """Add a user to the team.
1699
+
1700
+ CLI: ``team add <email> [<user|admin|billing_owner>]``
1701
+ """
1702
+ return await self._t.call("team add", [email, role])
1703
+
1704
+ async def remove(self, email: str, *, transfer_vms_to: str | None = None) -> Any:
1705
+ """Remove a user from the team.
1706
+
1707
+ CLI: ``team remove <email> [--transfer-vms-to <email>]``
1708
+
1709
+ Args:
1710
+ transfer_vms_to: transfer the member's VMs to this team member before
1711
+ removing them
1712
+ """
1713
+ return await self._t.call(
1714
+ "team remove",
1715
+ [email],
1716
+ [("transfer-vms-to", "string", transfer_vms_to)],
1717
+ )
1718
+
1719
+ async def role(self, email: str, role: Literal["user", "admin", "billing_owner"]) -> Any:
1720
+ """Change a team member's role.
1721
+
1722
+ CLI: ``team role <email> <user|admin|billing_owner>``
1723
+ """
1724
+ return await self._t.call("team role", [email, role])
1725
+
1726
+ async def rename(self, name: str) -> Any:
1727
+ """Rename your team.
1728
+
1729
+ CLI: ``team rename <name>``
1730
+ """
1731
+ return await self._t.call("team rename", [name])
1732
+
1733
+ async def transfer(self, vm_name: str, target_email: str) -> Any:
1734
+ """Transfer a VM to another team member.
1735
+
1736
+ CLI: ``team transfer <vm_name> <target_email>``
1737
+ """
1738
+ return await self._t.call("team transfer", [vm_name, target_email])
1739
+
1740
+
1741
+ class _AsyncTeamBilling:
1742
+ """``team billing`` commands."""
1743
+
1744
+ def __init__(self, t: _AsyncTransport) -> None:
1745
+ self._t = t
1746
+
1747
+ async def plan(self, *, all: bool = False) -> Any:
1748
+ """Show your team's plan.
1749
+
1750
+ CLI: ``team billing plan [--all]``
1751
+
1752
+ Args:
1753
+ all: list the plans you can switch to
1754
+ """
1755
+ return await self._t.call("team billing plan", [], [("all", "bool", all or None)])
1756
+
1757
+ async def update(
1758
+ self,
1759
+ *,
1760
+ address_city: str | None = None,
1761
+ address_country: str | None = None,
1762
+ address_line1: str | None = None,
1763
+ address_line2: str | None = None,
1764
+ address_postal_code: str | None = None,
1765
+ address_state: str | None = None,
1766
+ business_name: str | None = None,
1767
+ email: str | None = None,
1768
+ name: str | None = None,
1769
+ phone: str | None = None,
1770
+ tax_id_type: str | None = None,
1771
+ tax_id_value: str | None = None,
1772
+ ) -> Any:
1773
+ """Update team billing information.
1774
+
1775
+ CLI: ``team billing update [--email=<email>] [--name=<name>] [--business-name=<name>] [--phone=<phone>] [--address-line1=<line>] [--address-line2=<line>] [--address-city=<city>] [--address-state=<state>] [--address-postal-code=<code>] [--address-country=<country>] [--tax-id-type=<type>] [--tax-id-value=<value>]``
1776
+
1777
+ Args:
1778
+ address_city: billing address city
1779
+ address_country: billing address country code
1780
+ address_line1: billing address line 1
1781
+ address_line2: billing address line 2
1782
+ address_postal_code: billing address postal code
1783
+ address_state: billing address state or province
1784
+ business_name: business name shown on invoices
1785
+ email: billing email shown on invoices
1786
+ name: customer name shown on invoices
1787
+ phone: phone number shown on invoices
1788
+ tax_id_type: tax ID type shown on invoices (e.g. eu_vat, pl_nip, us_ein)
1789
+ tax_id_value: tax ID value shown on invoices
1790
+ """
1791
+ return await self._t.call(
1792
+ "team billing update",
1793
+ [],
1794
+ [
1795
+ ("address-city", "string", address_city),
1796
+ ("address-country", "string", address_country),
1797
+ ("address-line1", "string", address_line1),
1798
+ ("address-line2", "string", address_line2),
1799
+ ("address-postal-code", "string", address_postal_code),
1800
+ ("address-state", "string", address_state),
1801
+ ("business-name", "string", business_name),
1802
+ ("email", "string", email),
1803
+ ("name", "string", name),
1804
+ ("phone", "string", phone),
1805
+ ("tax-id-type", "string", tax_id_type),
1806
+ ("tax-id-value", "string", tax_id_value),
1807
+ ],
1808
+ )
1809
+
1810
+
1811
+ class _AsyncTeamAuth:
1812
+ """``team auth`` commands."""
1813
+
1814
+ def __init__(self, t: _AsyncTransport) -> None:
1815
+ self._t = t
1816
+
1817
+ async def __call__(self) -> Any:
1818
+ """View and manage team auth settings.
1819
+
1820
+ CLI: ``team auth``
1821
+ """
1822
+ return await self._t.call("team auth", [])
1823
+
1824
+ async def require_oidc(self, mode: Literal["web", "off"]) -> Any:
1825
+ """Require OIDC for web login (web|off).
1826
+
1827
+ CLI: ``team auth require-oidc <web|off>``
1828
+ """
1829
+ return await self._t.call("team auth require-oidc", [mode])
1830
+
1831
+ async def set(
1832
+ self,
1833
+ provider: Literal["default", "google", "oidc"],
1834
+ *,
1835
+ client_id: str | None = None,
1836
+ client_secret: str | None = None,
1837
+ display_name: str | None = None,
1838
+ issuer_url: str | None = None,
1839
+ ) -> Any:
1840
+ """Set the team auth provider (default, google, oidc).
1841
+
1842
+ CLI: ``team auth set <default|google|oidc> [--issuer-url=<url> --client-id=<id> --client-secret=<secret>]``
1843
+
1844
+ Args:
1845
+ client_id: OIDC client ID
1846
+ client_secret: OIDC client secret
1847
+ display_name: display name for the SSO provider
1848
+ issuer_url: OIDC issuer URL (e.g. https://accounts.google.com)
1849
+ """
1850
+ return await self._t.call(
1851
+ "team auth set",
1852
+ [provider],
1853
+ [
1854
+ ("client-id", "string", client_id),
1855
+ ("client-secret", "string", client_secret),
1856
+ ("display-name", "string", display_name),
1857
+ ("issuer-url", "string", issuer_url),
1858
+ ],
1859
+ )
1860
+
1861
+
1862
+ class _AsyncTeamSettings:
1863
+ """``team settings`` commands."""
1864
+
1865
+ def __init__(self, t: _AsyncTransport) -> None:
1866
+ self._t = t
1867
+ self.vm_placement = _AsyncTeamSettingsVmPlacement(self._t)
1868
+
1869
+ async def __call__(self) -> Any:
1870
+ """View and manage team settings.
1871
+
1872
+ CLI: ``team settings``
1873
+ """
1874
+ return await self._t.call("team settings", [])
1875
+
1876
+ async def standalone(self, who: Literal["off", "admins-only", "all-users"]) -> Any:
1877
+ """Set who can create standalone VMs.
1878
+
1879
+ CLI: ``team settings standalone <off|admins-only|all-users>``
1880
+ """
1881
+ return await self._t.call("team settings standalone", [who])
1882
+
1883
+ async def llm_gateway(self, enabled: bool) -> Any:
1884
+ """Allow or block the exe.dev LLM gateway (your own API keys are unaffected).
1885
+
1886
+ CLI: ``team settings llm-gateway <on|off>``
1887
+ """
1888
+ return await self._t.call("team settings llm-gateway", [on_off(enabled)])
1889
+
1890
+ async def core_sharing(self, enabled: bool) -> Any:
1891
+ """Allow team VMs to share CPU cores with each other.
1892
+
1893
+ CLI: ``team settings core-sharing <on|off>``
1894
+ """
1895
+ return await self._t.call("team settings core-sharing", [on_off(enabled)])
1896
+
1897
+ async def vm_sharing(self, who: Literal["admins-only", "all-members"]) -> Any:
1898
+ """Set who can share team VMs.
1899
+
1900
+ CLI: ``team settings vm-sharing <admins-only|all-members>``
1901
+ """
1902
+ return await self._t.call("team settings vm-sharing", [who])
1903
+
1904
+ async def session_length(self, length: str) -> Any:
1905
+ """Set how long members stay signed in on the web and mobile apps.
1906
+
1907
+ CLI: ``team settings session-length <length>``
1908
+ """
1909
+ return await self._t.call("team settings session-length", [length])
1910
+
1911
+ async def auto_join(self, enabled: bool) -> Any:
1912
+ """Allow users from your email domain to join this team on signup.
1913
+
1914
+ CLI: ``team settings auto-join <on|off>``
1915
+ """
1916
+ return await self._t.call("team settings auto-join", [on_off(enabled)])
1917
+
1918
+
1919
+ class _AsyncTeamSettingsVmPlacement:
1920
+ """``team settings vm-placement`` commands."""
1921
+
1922
+ def __init__(self, t: _AsyncTransport) -> None:
1923
+ self._t = t
1924
+
1925
+ async def __call__(self) -> Any:
1926
+ """View and manage automatic VM placement.
1927
+
1928
+ CLI: ``team settings vm-placement``
1929
+ """
1930
+ return await self._t.call("team settings vm-placement", [])
1931
+
1932
+ async def default(self) -> Any:
1933
+ """Use ordinary VM placement.
1934
+
1935
+ CLI: ``team settings vm-placement default``
1936
+ """
1937
+ return await self._t.call("team settings vm-placement default", [])
1938
+
1939
+ async def pool(self, name: str) -> Any:
1940
+ """Send bare new commands to a shared team pool.
1941
+
1942
+ CLI: ``team settings vm-placement pool <name>``
1943
+ """
1944
+ return await self._t.call("team settings vm-placement pool", [name])
1945
+
1946
+ async def member_pool(self, *, cpus: int | None = None, max_vms: int | None = None) -> Any:
1947
+ """Create or reuse one team pool per member for bare new commands.
1948
+
1949
+ CLI: ``team settings vm-placement member-pool --cpus=N [--max-vms=N]``
1950
+
1951
+ Args:
1952
+ cpus: CPUs to reserve for each member pool (required)
1953
+ max_vms: maximum VMs in each member pool (Work defaults to 25 per vCPU, up
1954
+ to 1000) (server default: 100)
1955
+ """
1956
+ return await self._t.call(
1957
+ "team settings vm-placement member-pool",
1958
+ [],
1959
+ [("cpus", "number", cpus), ("max-vms", "number", max_vms)],
1960
+ )
1961
+
1962
+ async def poolless(self) -> Any:
1963
+ """Keep bare new commands outside pools while migrating (legacy).
1964
+
1965
+ CLI: ``team settings vm-placement poolless``
1966
+ """
1967
+ return await self._t.call("team settings vm-placement poolless", [])
1968
+
1969
+
1970
+ class _AsyncTeamVm:
1971
+ """``team vm`` commands."""
1972
+
1973
+ def __init__(self, t: _AsyncTransport) -> None:
1974
+ self._t = t
1975
+
1976
+ async def ls(
1977
+ self,
1978
+ value: str | None = None,
1979
+ *,
1980
+ group: Literal["none", "tag", "region", "type", "user", "access"] | None = None,
1981
+ long: bool = False,
1982
+ ) -> Any:
1983
+ """List all VMs across your team.
1984
+
1985
+ CLI: ``team vm ls [-l] [--group=tag|region|type|user|access] [name|pattern]``
1986
+
1987
+ Tag-scoped API keys may run this command.
1988
+
1989
+ Args:
1990
+ group: group VMs by none|tag|region|type|user|access (server default: none)
1991
+ long: show detailed information
1992
+ """
1993
+ return await self._t.call(
1994
+ "team vm ls",
1995
+ [value],
1996
+ [("group", "string", group), ("l", "bool", long or None)],
1997
+ )
1998
+
1999
+
2000
+ class _AsyncPool:
2001
+ """``pool`` commands."""
2002
+
2003
+ def __init__(self, t: _AsyncTransport) -> None:
2004
+ self._t = t
2005
+
2006
+ async def new(
2007
+ self,
2008
+ name: str,
2009
+ *,
2010
+ cpus: int | None = None,
2011
+ max_vms: int | None = None,
2012
+ region: str | None = None,
2013
+ ) -> Any:
2014
+ """Create a pool: reserved capacity for your team's VMs.
2015
+
2016
+ CLI: ``pool new <name> --cpus=N [--region=<region>] [--max-vms=M]``
2017
+
2018
+ Args:
2019
+ cpus: number of CPUs reserved for the pool (required; an even number of
2020
+ vCPUs from 2 through 512 (2, 4, 6, and so on; memory is 2 GiB per vCPU))
2021
+ max_vms: maximum VMs in the pool (Work defaults to the size limit of up to
2022
+ 25 VMs per vCPU, capped at 1000 VMs) (server default: 100)
2023
+ region: region code for the pool (required unless your team has dedicated
2024
+ hosts, which decide the region)
2025
+ """
2026
+ return await self._t.call(
2027
+ "pool new",
2028
+ [name],
2029
+ [
2030
+ ("cpus", "number", cpus),
2031
+ ("max-vms", "number", max_vms),
2032
+ ("region", "string", region),
2033
+ ],
2034
+ )
2035
+
2036
+ async def hosts(self) -> Any:
2037
+ """Show your team's dedicated hosts, where its new pools are placed.
2038
+
2039
+ CLI: ``pool hosts``
2040
+ """
2041
+ return await self._t.call("pool hosts", [])
2042
+
2043
+ async def list(self, pool_name: str | None = None, *, range: str | None = None, usage: bool = False) -> Any:
2044
+ """List your team's pools, or show live or historical usage with ``usage=True``.
2045
+
2046
+ CLI: ``pool list [pool-name] [--usage [--range=live|24h|7d|30d]]``
2047
+
2048
+ Args:
2049
+ range: statistics range: live, 24h, 7d, or 30d (requires ``usage=True``)
2050
+ (server default: live)
2051
+ usage: show live or historical CPU and starvation statistics
2052
+ """
2053
+ return await self._t.call(
2054
+ "pool list",
2055
+ [pool_name],
2056
+ [("range", "string", range), ("usage", "bool", usage or None)],
2057
+ )
2058
+
2059
+ async def adopt(self, *, pool: str | None = None, vm: str | None = None) -> Any:
2060
+ """Adopt a VM into a pool; this can take several minutes.
2061
+
2062
+ CLI: ``pool adopt --vm=<vm-name> --pool=<pool-name>``
2063
+
2064
+ Args:
2065
+ pool: pool to adopt into (required)
2066
+ vm: VM to adopt (required)
2067
+ """
2068
+ return await self._t.call("pool adopt", [], [("pool", "string", pool), ("vm", "string", vm)])
2069
+
2070
+ async def detach(self, *, poolless: bool = False, vm: str | None = None) -> Any:
2071
+ """Detach a VM from its pool into its own standalone capacity (``poolless=True`` leaves it outside any pool).
2072
+
2073
+ CLI: ``pool detach --vm=<vm-name> [--poolless]``
2074
+
2075
+ Args:
2076
+ poolless: leave the VM outside any pool instead of making it standalone
2077
+ (only while your team has poolless VMs)
2078
+ vm: VM to detach from its pool (required)
2079
+ """
2080
+ return await self._t.call(
2081
+ "pool detach",
2082
+ [],
2083
+ [("poolless", "bool", poolless or None), ("vm", "string", vm)],
2084
+ )
2085
+
2086
+ async def resize(
2087
+ self,
2088
+ *args: str,
2089
+ cpus: int | None = None,
2090
+ force: bool = False,
2091
+ max_vms: str | None = None,
2092
+ ) -> Any:
2093
+ """Change a pool's CPU capacity or VM cap.
2094
+
2095
+ CLI: ``pool resize <name> --cpus=N | pool resize <name> --max-vms=N [--force]``
2096
+
2097
+ Args:
2098
+ cpus: new pool size in vCPUs (required unless ``max_vms`` is set; even, from
2099
+ 2 up to the placement limit)
2100
+ force: allow ``max_vms`` below the current VM count (does not stop or evict
2101
+ VMs)
2102
+ max_vms: maximum VMs in the pool (Work: at most 25 per vCPU, up to 1000)
2103
+ """
2104
+ return await self._t.call(
2105
+ "pool resize",
2106
+ [args],
2107
+ [
2108
+ ("cpus", "number", cpus),
2109
+ ("force", "bool", force or None),
2110
+ ("max-vms", "string", max_vms),
2111
+ ],
2112
+ )
2113
+
2114
+ async def rename(self, old_name: str, new_name: str) -> Any:
2115
+ """Rename a pool without changing its capacity or VMs.
2116
+
2117
+ CLI: ``pool rename <old-name> <new-name>``
2118
+ """
2119
+ return await self._t.call("pool rename", [old_name, new_name])
2120
+
2121
+ async def delete(self, name: str) -> Any:
2122
+ """Delete a pool (refused while it has VMs; ``force`` detaches them).
2123
+
2124
+ CLI: ``pool delete <name> [--force]``
2125
+ """
2126
+ return await self._t.call("pool delete", [name])
2127
+
2128
+
2129
+ class _AsyncBilling:
2130
+ """``billing`` commands."""
2131
+
2132
+ def __init__(self, t: _AsyncTransport) -> None:
2133
+ self._t = t
2134
+ self.credits = _AsyncBillingCredits(self._t)
2135
+ self.payment = _AsyncBillingPayment(self._t)
2136
+ self.invoices = _AsyncBillingInvoices(self._t)
2137
+ self.provider = _AsyncBillingProvider(self._t)
2138
+
2139
+ async def plan(self, *, all: bool = False) -> Any:
2140
+ """Show your current plan and resource limits.
2141
+
2142
+ CLI: ``billing plan [--all]``
2143
+
2144
+ Args:
2145
+ all: list the plans you can switch to
2146
+ """
2147
+ return await self._t.call("billing plan", [], [("all", "bool", all or None)])
2148
+
2149
+ async def usage(self, *, group: str | None = None, range: str | None = None) -> Any:
2150
+ """Show VM resource usage against your plan quotas.
2151
+
2152
+ CLI: ``billing usage [--range=cycle|24h|7d|30d] [--group=vm]``
2153
+
2154
+ Args:
2155
+ group: group usage by: vm
2156
+ range: time range: cycle (current billing cycle), 24h, 7d, or 30d (server
2157
+ default: cycle)
2158
+ """
2159
+ return await self._t.call(
2160
+ "billing usage",
2161
+ [],
2162
+ [("group", "string", group), ("range", "string", range)],
2163
+ )
2164
+
2165
+ async def rewards(self) -> Any:
2166
+ """Show invite rewards you've earned.
2167
+
2168
+ CLI: ``billing rewards``
2169
+ """
2170
+ return await self._t.call("billing rewards", [])
2171
+
2172
+ async def capacity(self, *, cpu: int | None = None, yes: bool = False) -> Any:
2173
+ """Change your subscription capacity.
2174
+
2175
+ CLI: ``billing capacity [--cpu=N] [--yes]``
2176
+
2177
+ Args:
2178
+ cpu: Personal capacity in vCPUs: 2, 4, 8, or 16
2179
+ yes: skip interactive confirmation (required for non-interactive changes)
2180
+ """
2181
+ return await self._t.call(
2182
+ "billing capacity",
2183
+ [],
2184
+ [("cpu", "number", cpu), ("yes", "bool", yes or None)],
2185
+ )
2186
+
2187
+ async def manage(self) -> Any:
2188
+ """Open the billing management page.
2189
+
2190
+ CLI: ``billing manage``
2191
+ """
2192
+ return await self._t.call("billing manage", [])
2193
+
2194
+ async def activate(self) -> Any:
2195
+ """Open the personal plan activation page.
2196
+
2197
+ CLI: ``billing activate``
2198
+ """
2199
+ return await self._t.call("billing activate", [])
2200
+
2201
+ async def update(
2202
+ self,
2203
+ *,
2204
+ address_city: str | None = None,
2205
+ address_country: str | None = None,
2206
+ address_line1: str | None = None,
2207
+ address_line2: str | None = None,
2208
+ address_postal_code: str | None = None,
2209
+ address_state: str | None = None,
2210
+ business_name: str | None = None,
2211
+ email: str | None = None,
2212
+ name: str | None = None,
2213
+ phone: str | None = None,
2214
+ tax_id_type: str | None = None,
2215
+ tax_id_value: str | None = None,
2216
+ ) -> Any:
2217
+ """Update your billing contact information.
2218
+
2219
+ CLI: ``billing update [--email=<email>] [--name=<name>] [--business-name=<name>] [--phone=<phone>] [--address-line1=<line>] [--address-line2=<line>] [--address-city=<city>] [--address-state=<state>] [--address-postal-code=<code>] [--address-country=<country>] [--tax-id-type=<type>] [--tax-id-value=<value>]``
2220
+
2221
+ Args:
2222
+ address_city: billing address city
2223
+ address_country: billing address country code
2224
+ address_line1: billing address line 1
2225
+ address_line2: billing address line 2
2226
+ address_postal_code: billing address postal code
2227
+ address_state: billing address state or province
2228
+ business_name: business name shown on invoices
2229
+ email: billing email shown on invoices
2230
+ name: customer name shown on invoices
2231
+ phone: phone number shown on invoices
2232
+ tax_id_type: tax ID type shown on invoices (e.g. eu_vat, pl_nip, us_ein)
2233
+ tax_id_value: tax ID value shown on invoices
2234
+ """
2235
+ return await self._t.call(
2236
+ "billing update",
2237
+ [],
2238
+ [
2239
+ ("address-city", "string", address_city),
2240
+ ("address-country", "string", address_country),
2241
+ ("address-line1", "string", address_line1),
2242
+ ("address-line2", "string", address_line2),
2243
+ ("address-postal-code", "string", address_postal_code),
2244
+ ("address-state", "string", address_state),
2245
+ ("business-name", "string", business_name),
2246
+ ("email", "string", email),
2247
+ ("name", "string", name),
2248
+ ("phone", "string", phone),
2249
+ ("tax-id-type", "string", tax_id_type),
2250
+ ("tax-id-value", "string", tax_id_value),
2251
+ ],
2252
+ )
2253
+
2254
+ async def receipts(self, *, from_: str | None = None, to: str | None = None) -> Any:
2255
+ """Show credit purchase receipts and purchases (this billing account only).
2256
+
2257
+ CLI: ``billing receipts [--from=<YYYY-MM-DD>] [--to=<YYYY-MM-DD>]``
2258
+
2259
+ Args:
2260
+ from_: start of the period, YYYY-MM-DD
2261
+ to: end of the period, YYYY-MM-DD
2262
+ """
2263
+ return await self._t.call(
2264
+ "billing receipts",
2265
+ [],
2266
+ [("from", "string", from_), ("to", "string", to)],
2267
+ )
2268
+
2269
+ async def statement(self, *, from_: str | None = None, to: str | None = None) -> Any:
2270
+ """Open a consolidated credit purchase statement.
2271
+
2272
+ CLI: ``billing statement [--from=<YYYY-MM-DD>] [--to=<YYYY-MM-DD>]``
2273
+
2274
+ Args:
2275
+ from_: start of the period, YYYY-MM-DD
2276
+ to: end of the period, YYYY-MM-DD
2277
+ """
2278
+ return await self._t.call(
2279
+ "billing statement",
2280
+ [],
2281
+ [("from", "string", from_), ("to", "string", to)],
2282
+ )
2283
+
2284
+
2285
+ class _AsyncBillingCredits:
2286
+ """``billing credits`` commands."""
2287
+
2288
+ def __init__(self, t: _AsyncTransport) -> None:
2289
+ self._t = t
2290
+
2291
+ async def __call__(self) -> Any:
2292
+ """Show Shelley credit balances.
2293
+
2294
+ CLI: ``billing credits [usage|transactions|buy]``
2295
+ """
2296
+ return await self._t.call("billing credits", [])
2297
+
2298
+ async def usage(self, *, detail: bool = False, group: str | None = None, month: str | None = None) -> Any:
2299
+ """Show Shelley (LLM) credit spend by model, day, or VM.
2300
+
2301
+ CLI: ``billing credits usage [--month=YYYY-MM] [--group=model|day|box] [--detail]``
2302
+
2303
+ Args:
2304
+ detail: break each group down: models under a day or VM, VMs under a model
2305
+ group: group spend by model, day, or box (server default: model)
2306
+ month: calendar month to report, as YYYY-MM (default: this month, UTC)
2307
+ """
2308
+ return await self._t.call(
2309
+ "billing credits usage",
2310
+ [],
2311
+ [
2312
+ ("detail", "bool", detail or None),
2313
+ ("group", "string", group),
2314
+ ("month", "string", month),
2315
+ ],
2316
+ )
2317
+
2318
+ async def transactions(self, *, limit: str | None = None) -> Any:
2319
+ """Show your credit purchases and gifts.
2320
+
2321
+ CLI: ``billing credits transactions [--limit=N]``
2322
+
2323
+ Args:
2324
+ limit: how many transactions to show, 1-100 (default 20)
2325
+ """
2326
+ return await self._t.call("billing credits transactions", [], [("limit", "string", limit)])
2327
+
2328
+ async def buy(self, dollars: str, *, idempotency_key: str | None = None, yes: bool = False) -> Any:
2329
+ """Buy Shelley credits with your personal payment method.
2330
+
2331
+ CLI: ``billing credits buy <dollars> [--yes]``
2332
+
2333
+ Args:
2334
+ idempotency_key: retry key so a repeated purchase of the same amount charges
2335
+ once
2336
+ yes: skip the confirmation prompt (required when not running interactively)
2337
+ """
2338
+ return await self._t.call(
2339
+ "billing credits buy",
2340
+ [dollars],
2341
+ [
2342
+ ("idempotency-key", "string", idempotency_key),
2343
+ ("yes", "bool", yes or None),
2344
+ ],
2345
+ )
2346
+
2347
+
2348
+ class _AsyncBillingPayment:
2349
+ """``billing payment`` commands."""
2350
+
2351
+ def __init__(self, t: _AsyncTransport) -> None:
2352
+ self._t = t
2353
+
2354
+ async def __call__(self) -> Any:
2355
+ """Show your payment methods.
2356
+
2357
+ CLI: ``billing payment [list]``
2358
+ """
2359
+ return await self._t.call("billing payment", [])
2360
+
2361
+ async def list(self) -> Any:
2362
+ """List all payment methods on file.
2363
+
2364
+ CLI: ``billing payment list``
2365
+ """
2366
+ return await self._t.call("billing payment list", [])
2367
+
2368
+ async def remove(self, ref: str) -> Any:
2369
+ """Remove a saved payment method.
2370
+
2371
+ CLI: ``billing payment remove <ref>``
2372
+ """
2373
+ return await self._t.call("billing payment remove", [ref])
2374
+
2375
+ async def default(self, ref: str) -> Any:
2376
+ """Make a saved payment method the default.
2377
+
2378
+ CLI: ``billing payment default <ref>``
2379
+ """
2380
+ return await self._t.call("billing payment default", [ref])
2381
+
2382
+
2383
+ class _AsyncBillingInvoices:
2384
+ """``billing invoices`` commands."""
2385
+
2386
+ def __init__(self, t: _AsyncTransport) -> None:
2387
+ self._t = t
2388
+
2389
+ async def __call__(self) -> Any:
2390
+ """Show recent invoices.
2391
+
2392
+ CLI: ``billing invoices``
2393
+ """
2394
+ return await self._t.call("billing invoices", [])
2395
+
2396
+ async def list(self, *, account: str | None = None) -> Any:
2397
+ """List invoices for your personal or team account.
2398
+
2399
+ CLI: ``billing invoices list [--account=personal|team]``
2400
+
2401
+ Args:
2402
+ account: account whose invoices to list: personal or team (server default:
2403
+ personal)
2404
+ """
2405
+ return await self._t.call("billing invoices list", [], [("account", "string", account)])
2406
+
2407
+ async def show(self, id: str, *, vms: bool = False) -> Any:
2408
+ """Show one invoice with its usage breakdown.
2409
+
2410
+ CLI: ``billing invoices show <id> [--vms]``
2411
+
2412
+ Args:
2413
+ vms: list VMs without a pool individually
2414
+ """
2415
+ return await self._t.call("billing invoices show", [id], [("vms", "bool", vms or None)])
2416
+
2417
+
2418
+ class _AsyncBillingProvider:
2419
+ """``billing provider`` commands."""
2420
+
2421
+ def __init__(self, t: _AsyncTransport) -> None:
2422
+ self._t = t
2423
+
2424
+ async def link(
2425
+ self,
2426
+ provider: Literal["aws", "azure"],
2427
+ *,
2428
+ size: str | None = None,
2429
+ team_name: str | None = None,
2430
+ token: str | None = None,
2431
+ ) -> Any:
2432
+ """Connect a marketplace subscription to your team.
2433
+
2434
+ CLI: ``billing provider link <aws|azure> --token=<link-token> [--size=<team-size>] [--team-name=<name>]``
2435
+
2436
+ Args:
2437
+ size: team size: no marketplace team plans are available
2438
+ team_name: name for the team to create (only when you have no team yet)
2439
+ token: marketplace link token from the exe.dev link page
2440
+ """
2441
+ return await self._t.call(
2442
+ "billing provider link",
2443
+ [provider],
2444
+ [
2445
+ ("size", "string", size),
2446
+ ("team-name", "string", team_name),
2447
+ ("token", "string", token),
2448
+ ],
2449
+ )
2450
+
2451
+
2452
+ class _AsyncInvite:
2453
+ """``invite`` commands."""
2454
+
2455
+ def __init__(self, t: _AsyncTransport) -> None:
2456
+ self._t = t
2457
+
2458
+ async def __call__(self) -> Any:
2459
+ """Manage your invite link and rewards."""
2460
+ return await self._t.call("invite", [])
2461
+
2462
+ async def show(self) -> Any:
2463
+ """Show your active invite link and reward.
2464
+
2465
+ CLI: ``invite show``
2466
+ """
2467
+ return await self._t.call("invite show", [])
2468
+
2469
+ async def link(self) -> Any:
2470
+ """Print only your active invite link.
2471
+
2472
+ CLI: ``invite link``
2473
+ """
2474
+ return await self._t.call("invite link", [])
2475
+
2476
+ async def rewards(self) -> Any:
2477
+ """List invite rewards you can use.
2478
+
2479
+ CLI: ``invite rewards``
2480
+ """
2481
+ return await self._t.call("invite rewards", [])
2482
+
2483
+ async def set_reward(self, reward: Literal["standard", "bonus-credits", "extra-memory", "extra-disk"]) -> Any:
2484
+ """Choose the reward for your invite link.
2485
+
2486
+ CLI: ``invite set-reward standard|bonus-credits|extra-memory|extra-disk``
2487
+ """
2488
+ return await self._t.call("invite set-reward", [reward])
2489
+
2490
+ async def activity(self) -> Any:
2491
+ """Show signups, upgrades, and reward status.
2492
+
2493
+ CLI: ``invite activity``
2494
+ """
2495
+ return await self._t.call("invite activity", [])
2496
+
2497
+ async def request(self) -> Any:
2498
+ """Request more trial invites.
2499
+
2500
+ CLI: ``invite request``
2501
+ """
2502
+ return await self._t.call("invite request", [])
2503
+
2504
+ async def manage(self) -> Any:
2505
+ """Open the invites page.
2506
+
2507
+ CLI: ``invite manage``
2508
+ """
2509
+ return await self._t.call("invite manage", [])
2510
+
2511
+
2512
+ class _AsyncVMDomain:
2513
+ """The VM's ``domain`` commands."""
2514
+
2515
+ def __init__(self, vm: AsyncVM) -> None:
2516
+ self._vm = vm
2517
+
2518
+ async def add(self, domain: str, *, wildcard: bool = False) -> t.DomainAddResponse:
2519
+ """Link a custom domain to a VM.
2520
+
2521
+ CLI: ``domain add [--wildcard] <vm> <domain>``
2522
+
2523
+ Args:
2524
+ wildcard: issue a wildcard (*.<parent>) certificate via DNS-01 delegation
2525
+ """
2526
+ result: t.DomainAddResponse = await self._vm._t.call(
2527
+ "domain add",
2528
+ [self._vm.name, domain],
2529
+ [("wildcard", "bool", wildcard or None)],
2530
+ json=True,
2531
+ )
2532
+ return result
2533
+
2534
+ async def remove(self, domain: str) -> t.DomainRmResponse:
2535
+ """Remove a custom domain from a VM.
2536
+
2537
+ CLI: ``domain rm <vm> <domain>``
2538
+ """
2539
+ result: t.DomainRmResponse = await self._vm._t.call("domain rm", [self._vm.name, domain], json=True)
2540
+ return result
2541
+
2542
+
2543
+ class _AsyncVMShare:
2544
+ """The VM's ``share`` commands."""
2545
+
2546
+ def __init__(self, vm: AsyncVM) -> None:
2547
+ self._vm = vm
2548
+
2549
+ async def show(self, *, qr: bool = False) -> t.ShareShowResponse:
2550
+ """Show current shares for a VM.
2551
+
2552
+ CLI: ``share show <vm>``
2553
+
2554
+ Tag-scoped API keys may run this command.
2555
+
2556
+ Args:
2557
+ qr: show QR code for the URL
2558
+ """
2559
+ result: t.ShareShowResponse = await self._vm._t.call("share show", [self._vm.name], [("qr", "bool", qr or None)], json=True)
2560
+ return result
2561
+
2562
+ async def port(self, port: int | str | None = None) -> t.SharePortResponse:
2563
+ """Set the HTTP proxy port for a VM.
2564
+
2565
+ CLI: ``share port <vm> [port]``
2566
+
2567
+ Tag-scoped API keys may run this command.
2568
+ """
2569
+ result: t.SharePortResponse = await self._vm._t.call("share port", [self._vm.name, port], json=True)
2570
+ return result
2571
+
2572
+ async def set_public(self) -> t.ShareRouteUpdate:
2573
+ """Make the HTTP proxy publicly accessible.
2574
+
2575
+ CLI: ``share set-public <vm>``
2576
+
2577
+ Tag-scoped API keys may run this command.
2578
+ """
2579
+ result: t.ShareRouteUpdate = await self._vm._t.call("share set-public", [self._vm.name], json=True)
2580
+ return result
2581
+
2582
+ async def set_private(self) -> t.ShareRouteUpdate:
2583
+ """Restrict the HTTP proxy to authenticated users.
2584
+
2585
+ CLI: ``share set-private <vm>``
2586
+
2587
+ Tag-scoped API keys may run this command.
2588
+ """
2589
+ result: t.ShareRouteUpdate = await self._vm._t.call("share set-private", [self._vm.name], json=True)
2590
+ return result
2591
+
2592
+ async def add(
2593
+ self,
2594
+ email: str,
2595
+ *,
2596
+ message: str | None = None,
2597
+ qr: bool = False,
2598
+ root: bool = False,
2599
+ ) -> t.ShareAddResponse:
2600
+ """Share VM with a user via email, or grant shell access with ``root=True``.
2601
+
2602
+ CLI: ``share add <vm> <email|team> [--root] [--message='...']``
2603
+
2604
+ Tag-scoped API keys may run this command.
2605
+
2606
+ Args:
2607
+ message: message to include in share invitation
2608
+ qr: show QR code for the URL
2609
+ root: grant/revoke shell (SSH, Terminal, Shelley) access instead of web-only
2610
+ """
2611
+ result: t.ShareAddResponse = await self._vm._t.call(
2612
+ "share add",
2613
+ [self._vm.name, email],
2614
+ [
2615
+ ("message", "string", message),
2616
+ ("qr", "bool", qr or None),
2617
+ ("root", "bool", root or None),
2618
+ ],
2619
+ json=True,
2620
+ )
2621
+ return result
2622
+
2623
+ async def remove(self, email: str, *, root: bool = False) -> t.ShareRemoveResponse:
2624
+ """Revoke a user's access to a VM, or downgrade shell access to web with ``root=True``.
2625
+
2626
+ CLI: ``share remove <vm> <email|team> [--root]``
2627
+
2628
+ Tag-scoped API keys may run this command.
2629
+
2630
+ Args:
2631
+ root: grant/revoke shell (SSH, Terminal, Shelley) access instead of web-only
2632
+ """
2633
+ result: t.ShareRemoveResponse = await self._vm._t.call(
2634
+ "share remove",
2635
+ [self._vm.name, email],
2636
+ [("root", "bool", root or None)],
2637
+ json=True,
2638
+ )
2639
+ return result
2640
+
2641
+ async def add_link(self, *, qr: bool = False) -> t.ShareLinkCreated:
2642
+ """Create a shareable link for a VM.
2643
+
2644
+ CLI: ``share add-link <vm>``
2645
+
2646
+ Tag-scoped API keys may run this command.
2647
+
2648
+ Args:
2649
+ qr: show QR code for the URL
2650
+ """
2651
+ result: t.ShareLinkCreated = await self._vm._t.call(
2652
+ "share add-link",
2653
+ [self._vm.name],
2654
+ [("qr", "bool", qr or None)],
2655
+ json=True,
2656
+ )
2657
+ return result
2658
+
2659
+ async def remove_link(self, token: str) -> t.ShareMessage:
2660
+ """Revoke a shareable link.
2661
+
2662
+ CLI: ``share remove-link <vm> <token>``
2663
+
2664
+ Tag-scoped API keys may run this command.
2665
+ """
2666
+ result: t.ShareMessage = await self._vm._t.call("share remove-link", [self._vm.name, token], json=True)
2667
+ return result
2668
+
2669
+ async def receive_email(
2670
+ self,
2671
+ enabled: bool | None = None,
2672
+ *,
2673
+ reply_policy: str | None = None,
2674
+ ) -> t.ShareReceiveEmailResponse:
2675
+ """Enable or disable inbound email for a VM.
2676
+
2677
+ CLI: ``share receive-email <vm> [on|off] [--reply-policy=all|known|owner|none]``
2678
+
2679
+ Tag-scoped API keys may run this command.
2680
+
2681
+ Args:
2682
+ reply_policy: restrict who the VM may email: all|known|owner|none
2683
+ """
2684
+ result: t.ShareReceiveEmailResponse = await self._vm._t.call(
2685
+ "share receive-email",
2686
+ [self._vm.name, on_off(enabled)],
2687
+ [("reply-policy", "string", reply_policy)],
2688
+ json=True,
2689
+ )
2690
+ return result
2691
+
2692
+
2693
+ class _AsyncVMShelley:
2694
+ """The VM's ``shelley`` commands."""
2695
+
2696
+ def __init__(self, vm: AsyncVM) -> None:
2697
+ self._vm = vm
2698
+
2699
+ async def install(self) -> t.ShelleyInstallResponse:
2700
+ """Install or upgrade Shelley to the current version.
2701
+
2702
+ CLI: ``shelley install <vm>``
2703
+
2704
+ Tag-scoped API keys may run this command.
2705
+ """
2706
+ result: t.ShelleyInstallResponse = await self._vm._t.call("shelley install", [self._vm.name], json=True)
2707
+ return result