kloudlite-sdk 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kloudlite_sdk-0.2.0/PKG-INFO +25 -0
- kloudlite_sdk-0.2.0/README.md +13 -0
- kloudlite_sdk-0.2.0/kloudlite.py +1090 -0
- kloudlite_sdk-0.2.0/kloudlite_sdk.egg-info/PKG-INFO +25 -0
- kloudlite_sdk-0.2.0/kloudlite_sdk.egg-info/SOURCES.txt +7 -0
- kloudlite_sdk-0.2.0/kloudlite_sdk.egg-info/dependency_links.txt +1 -0
- kloudlite_sdk-0.2.0/kloudlite_sdk.egg-info/top_level.txt +1 -0
- kloudlite_sdk-0.2.0/pyproject.toml +20 -0
- kloudlite_sdk-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kloudlite-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Kloudlite SDK for Python
|
|
5
|
+
Author: Kloudlite
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/kloudlite/sdk
|
|
8
|
+
Project-URL: Source, https://github.com/kloudlite/sdk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Kloudlite SDK for Python
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install kloudlite-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from kloudlite import Kloudlite
|
|
21
|
+
|
|
22
|
+
kl = Kloudlite() # uses the credentials of `kloudlite login`
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The distribution is `kloudlite-sdk`; the import name is `kloudlite`. The PyPI project named `kloudlite` is a different package.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Kloudlite SDK for Python
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
pip install kloudlite-sdk
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from kloudlite import Kloudlite
|
|
9
|
+
|
|
10
|
+
kl = Kloudlite() # uses the credentials of `kloudlite login`
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The distribution is `kloudlite-sdk`; the import name is `kloudlite`. The PyPI project named `kloudlite` is a different package.
|
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
"""Kloudlite SDK for Python — stdlib only. Needs Python 3.9 or later.
|
|
2
|
+
|
|
3
|
+
Auth is a Bearer token (a CLI token). Every module hangs off the one client::
|
|
4
|
+
|
|
5
|
+
from kloudlite import Kloudlite
|
|
6
|
+
|
|
7
|
+
kl = Kloudlite() # the login of `kloudlite`; see "Discovery" below
|
|
8
|
+
workspaces = kl.workspaces.list()
|
|
9
|
+
|
|
10
|
+
Discovery: the API origin and the token each come from the first source that has one:
|
|
11
|
+
1. the arguments ``base_url=`` / ``token=``; 2. the environment ``KLOUDLITE_URL`` /
|
|
12
|
+
``KLOUDLITE_TOKEN``; 3. ``api`` / ``token`` in ``config.json`` in ``KLOUDLITE_CONFIG_DIR``, else
|
|
13
|
+
``~/.config/kloudlite``. ``/v1`` is added to the origin. With no token the constructor raises
|
|
14
|
+
``RuntimeError`` carrying the sentence ``no Kloudlite login: pass token=, set KLOUDLITE_TOKEN, or run kloudlite login``.
|
|
15
|
+
|
|
16
|
+
Every public call takes its inputs BY NAME (keyword-only), so no caller ever has to remember
|
|
17
|
+
argument order.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
if sys.version_info < (3, 9): # asyncio.to_thread; annotations stay lazy, so this runs first
|
|
25
|
+
raise ImportError("kloudlite.py needs Python 3.9 or later, found %d.%d" % sys.version_info[:2])
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
import functools
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.parse
|
|
36
|
+
import urllib.request
|
|
37
|
+
|
|
38
|
+
__all__ = ["Kloudlite", "ApiError", "SandboxTerminated"]
|
|
39
|
+
|
|
40
|
+
VERSION = "0.2.0"
|
|
41
|
+
USER_AGENT = f"kloudlite-sdk-python/{VERSION}"
|
|
42
|
+
NO_LOGIN = "no Kloudlite login: pass token=, set KLOUDLITE_TOKEN, or run kloudlite login"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ApiError(Exception):
|
|
46
|
+
"""A non-2xx answer. ``status`` is the HTTP code, ``message`` the server's text."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, status: int, message: str):
|
|
49
|
+
super().__init__(f"{status}: {message}")
|
|
50
|
+
self.status = status
|
|
51
|
+
self.message = message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _login() -> dict:
|
|
55
|
+
"""``config.json`` of the ``kloudlite`` login; {} when it is absent or unreadable."""
|
|
56
|
+
d = os.environ.get("KLOUDLITE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".config", "kloudlite")
|
|
57
|
+
try:
|
|
58
|
+
with open(os.path.join(d, "config.json")) as f:
|
|
59
|
+
doc = json.load(f)
|
|
60
|
+
return doc if isinstance(doc, dict) else {}
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Kloudlite:
|
|
66
|
+
def __init__(self, *, base_url: str | None = None, token: str | None = None,
|
|
67
|
+
timeout: float = 30.0):
|
|
68
|
+
env, cfg = os.environ, None
|
|
69
|
+
base_url = base_url or env.get("KLOUDLITE_URL")
|
|
70
|
+
token = token or env.get("KLOUDLITE_TOKEN")
|
|
71
|
+
if not base_url or not token:
|
|
72
|
+
cfg = _login()
|
|
73
|
+
base_url = base_url or cfg.get("api")
|
|
74
|
+
token = token or cfg.get("token")
|
|
75
|
+
if not token:
|
|
76
|
+
raise RuntimeError(NO_LOGIN)
|
|
77
|
+
if not base_url:
|
|
78
|
+
raise RuntimeError("no Kloudlite API URL: pass base_url=, set KLOUDLITE_URL, or run kloudlite login")
|
|
79
|
+
base_url = base_url.rstrip("/")
|
|
80
|
+
self.base_url = base_url[:-3] if base_url.endswith("/v1") else base_url # paths carry /v1
|
|
81
|
+
self._token = token
|
|
82
|
+
self._timeout = timeout
|
|
83
|
+
self.workspaces = Workspaces(self)
|
|
84
|
+
self.environments = Environments(self)
|
|
85
|
+
self.sessions = Sessions(self)
|
|
86
|
+
self.volumes = Volumes(self)
|
|
87
|
+
self.secrets = Secrets(self)
|
|
88
|
+
self.connectors = Connectors(self)
|
|
89
|
+
self.sandboxes = Sandboxes(self)
|
|
90
|
+
self.pipelines = Pipelines(self)
|
|
91
|
+
|
|
92
|
+
def quota(self, *, owner: str | None = None):
|
|
93
|
+
"""The ceiling and what is against it; ``owner`` defaults to the caller's own."""
|
|
94
|
+
return self.request("GET", "/v1/quota", query={"owner": owner})
|
|
95
|
+
|
|
96
|
+
# ── request core ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def request(self, method: str, path: str, *, body=None, query: dict | None = None):
|
|
99
|
+
"""One HTTP round trip. Returns parsed JSON (or None on an empty body);
|
|
100
|
+
raises :class:`ApiError` on any non-2xx status."""
|
|
101
|
+
return self._request(method, path, body, query)[0]
|
|
102
|
+
|
|
103
|
+
def _request(self, method: str, path: str, body=None, query: dict | None = None,
|
|
104
|
+
*, text: bool = False, timeout="default"):
|
|
105
|
+
"""(decoded body, response headers). ``text`` returns the body as a str, not JSON."""
|
|
106
|
+
req = self._prepare(self.base_url, method, path, body, query,
|
|
107
|
+
"text/plain" if text else "application/json")
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(req, timeout=self._timeout if timeout == "default" else timeout) as resp:
|
|
110
|
+
raw, headers = resp.read(), resp.headers
|
|
111
|
+
except urllib.error.HTTPError as e:
|
|
112
|
+
raise ApiError(e.code, _error_text(e.read())) from None
|
|
113
|
+
if text:
|
|
114
|
+
return raw.decode(errors="replace"), headers
|
|
115
|
+
return (json.loads(raw) if raw else None), headers
|
|
116
|
+
|
|
117
|
+
def _prepare(self, base: str, method: str, path: str, body, query: dict | None, accept: str):
|
|
118
|
+
url = base + path
|
|
119
|
+
if query:
|
|
120
|
+
q = {k: v for k, v in query.items() if v is not None}
|
|
121
|
+
if q:
|
|
122
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
123
|
+
data = None
|
|
124
|
+
headers = {"User-Agent": USER_AGENT, "Accept": accept}
|
|
125
|
+
if self._token:
|
|
126
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
127
|
+
if body is not None:
|
|
128
|
+
data = json.dumps(body).encode()
|
|
129
|
+
headers["Content-Type"] = "application/json"
|
|
130
|
+
return urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _seg(s: str) -> str:
|
|
134
|
+
return urllib.parse.quote(str(s), safe="")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _drop(d: dict) -> dict:
|
|
138
|
+
"""The body without its ``None`` values: an unset input is the server's default."""
|
|
139
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Workspaces:
|
|
143
|
+
"""Btrfs-backed dev workspaces, over ``/v1/workspaces``. A workspace is named like an
|
|
144
|
+
environment — ``"name"`` for the caller's own, ``"team/name"`` for one in a team — or by its
|
|
145
|
+
id; pushes cut snapshots, kept on the workspace's VOLUME — see :class:`Volumes` for history,
|
|
146
|
+
restore targets and deletion."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, client: Kloudlite):
|
|
149
|
+
self._c = client
|
|
150
|
+
|
|
151
|
+
def create(self, *, workspace: str, quota_gb: int, image: str | None = None,
|
|
152
|
+
packages: list | None = None, ttl_secs: int | None = None,
|
|
153
|
+
env: dict | None = None):
|
|
154
|
+
"""``workspace`` is ``"name"`` or ``"team/name"``. ``env`` maps variable names to literal
|
|
155
|
+
values or ``"${{ secrets.NAME }}"`` references to the owner's secrets. ``ttl_secs``
|
|
156
|
+
(600..=2592000) deletes it after that long unused; absent, never. ``packages`` are
|
|
157
|
+
nixpkgs attributes, optionally pinned (``attr@version``). 202."""
|
|
158
|
+
team, name = _owned(workspace)
|
|
159
|
+
return self._c.request("POST", "/v1/workspaces", body=_drop({
|
|
160
|
+
"name": name, "quota_gb": quota_gb, "team": team, "image": image,
|
|
161
|
+
"packages": packages, "ttl_secs": ttl_secs, "env": env}))
|
|
162
|
+
|
|
163
|
+
def list(self, *, team: str | None = None):
|
|
164
|
+
"""The caller's workspaces; ``team`` scopes to their workspaces in that team."""
|
|
165
|
+
return self._c.request("GET", "/v1/workspaces", query={"team": team})
|
|
166
|
+
|
|
167
|
+
def get(self, *, workspace: str):
|
|
168
|
+
return self._at("GET", workspace)
|
|
169
|
+
|
|
170
|
+
def delete(self, *, workspace: str):
|
|
171
|
+
"""Runs the worktree finalizer: the volume survives while a pushed snapshot references it."""
|
|
172
|
+
return self._at("DELETE", workspace)
|
|
173
|
+
|
|
174
|
+
def start(self, *, workspace: str):
|
|
175
|
+
return self._at("POST", workspace, "/start")
|
|
176
|
+
|
|
177
|
+
def stop(self, *, workspace: str):
|
|
178
|
+
"""A stop cuts a sync point and tears the workspace down — seconds, never waiting on a replica."""
|
|
179
|
+
return self._at("POST", workspace, "/stop")
|
|
180
|
+
|
|
181
|
+
def restart(self, *, workspace: str):
|
|
182
|
+
"""Restart a running workspace so it gets the current env (202); 409 when not running."""
|
|
183
|
+
return self._at("POST", workspace, "/restart")
|
|
184
|
+
|
|
185
|
+
def take_snapshot(self, *, workspace: str, message: str | None = None, volume: str | None = None,
|
|
186
|
+
ttl_secs: int | None = None, never: bool | None = None):
|
|
187
|
+
"""Push a SNAPSHOT of the working copy — the one durable, restorable state (202). ``volume``
|
|
188
|
+
names the volume and is required on its first push; ``ttl_secs`` (3600..=31536000) or
|
|
189
|
+
``never=True`` set its expiry. ``push`` is the old name."""
|
|
190
|
+
return self._at("POST", workspace, "/snapshots", body=_drop(
|
|
191
|
+
{"message": message, "volume": volume, "ttl_secs": ttl_secs, "never": never}))
|
|
192
|
+
|
|
193
|
+
push = take_snapshot
|
|
194
|
+
|
|
195
|
+
def clone(self, *, workspace: str, to: str, task: str | None = None):
|
|
196
|
+
"""A new workspace ``to`` from this one's state, cut at the moment of the request; the
|
|
197
|
+
answer's ``based_on`` names exactly what it grafted onto."""
|
|
198
|
+
return self._at("POST", workspace, "/clone", body={"name": to, "task": task})
|
|
199
|
+
|
|
200
|
+
def restore(self, *, snapshot: str, to: str, team: str | None = None, image: str | None = None,
|
|
201
|
+
packages: list | None = None, quota_gb: int | None = None):
|
|
202
|
+
"""A new workspace ``to`` grafted onto a PUSHED ``snapshot`` (a volume name, ``name@N`` or a
|
|
203
|
+
snapshot id) — also one whose workspace is long gone. ``team`` (or ``to="team/name"``)
|
|
204
|
+
is required for a team's volume. Absent overrides mean "whatever the snapshot froze"."""
|
|
205
|
+
owner, name = _owned(to)
|
|
206
|
+
return self._c.request("POST", "/v1/workspaces/restore", body=_drop({
|
|
207
|
+
"name": name, "snapshot_id": snapshot, "team": team or owner,
|
|
208
|
+
"image": image, "packages": packages, "quota_gb": quota_gb}))
|
|
209
|
+
|
|
210
|
+
def set_packages(self, *, workspace: str, packages: list):
|
|
211
|
+
"""Replace the package list; pinned entries are locked before anything is written."""
|
|
212
|
+
return self._at("PATCH", workspace, body={"packages": packages})
|
|
213
|
+
|
|
214
|
+
def set_ttl(self, *, workspace: str, ttl_secs: int | None):
|
|
215
|
+
"""Seconds unused before the workspace is deleted (600..=2592000); ``None`` clears it. Not
|
|
216
|
+
for a sandbox or a bench."""
|
|
217
|
+
return self._at("PUT", workspace, "/ttl", body={"ttl_secs": ttl_secs})
|
|
218
|
+
|
|
219
|
+
def set_env(self, *, workspace: str, env: dict, restart_on_env_change: bool | None = None):
|
|
220
|
+
"""Replace the whole env: literals or ``"${{ secrets.NAME }}"`` references. Takes effect on the
|
|
221
|
+
next start; a running workspace is only marked ``restart_required`` unless
|
|
222
|
+
``restart_on_env_change=True``."""
|
|
223
|
+
return self._at("PUT", workspace, "/env", body=_drop(
|
|
224
|
+
{"env": env, "restartOnEnvChange": restart_on_env_change}))
|
|
225
|
+
|
|
226
|
+
def update_packages(self, *, workspace: str):
|
|
227
|
+
"""Re-resolve every pinned package — the only call that moves existing locks."""
|
|
228
|
+
return self._at("POST", workspace, "/packages/update")
|
|
229
|
+
|
|
230
|
+
def tools(self, *, workspace: str):
|
|
231
|
+
"""The workspace's tool-server address and token — answered to the owner alone."""
|
|
232
|
+
return self._at("GET", workspace, "/tools")
|
|
233
|
+
|
|
234
|
+
def ide(self, *, workspace: str) -> "Ide":
|
|
235
|
+
"""An :class:`Ide` bound to this workspace's tool server. The address is a private IP:
|
|
236
|
+
reachable from inside Kloudlite (a bench); from a laptop, tunnel with
|
|
237
|
+
``kl-connect ws ide`` and use ``Ide(url="http://127.0.0.1:7788", token=…)`` instead."""
|
|
238
|
+
doc = self.tools(workspace=workspace)
|
|
239
|
+
return Ide(url=f"http://{doc['address']}", token=doc.get("token"))
|
|
240
|
+
|
|
241
|
+
def _at(self, method: str, workspace: str, tail: str = "", body=None):
|
|
242
|
+
team, name = _owned(workspace)
|
|
243
|
+
return self._c.request(method, f"/v1/workspaces/{_seg(name)}{tail}", body=body,
|
|
244
|
+
query={"owner": team})
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Ide:
|
|
248
|
+
"""A workspace's tool server (``kl ide serve``): the full tool set over plain HTTP —
|
|
249
|
+
``GET /tools`` lists every tool with its schema, ``POST /tools/{name}`` runs one. The typed
|
|
250
|
+
methods below cover them all; :meth:`call` takes anything the listing names. ``None``
|
|
251
|
+
arguments are dropped, so every optional means "the tool's own default"."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, *, url: str, token: str | None = None, timeout: float = 630.0):
|
|
254
|
+
self.url = url.rstrip("/")
|
|
255
|
+
self._token = token
|
|
256
|
+
self._timeout = timeout
|
|
257
|
+
|
|
258
|
+
def list(self):
|
|
259
|
+
"""Every tool, with its JSON schema — the authority on arguments."""
|
|
260
|
+
return self._http("GET", "/tools")
|
|
261
|
+
|
|
262
|
+
def call(self, *, tool: str, **args):
|
|
263
|
+
"""Run ``tool``; every other keyword is that tool's own argument."""
|
|
264
|
+
return self._http("POST", f"/tools/{_seg(tool)}", {k: v for k, v in args.items() if v is not None})
|
|
265
|
+
|
|
266
|
+
# ── files ─────────────────────────────────────────────────────────────
|
|
267
|
+
def read(self, *, path: str | None = None, paths: list | None = None,
|
|
268
|
+
offset: int | None = None, limit: int | None = None):
|
|
269
|
+
return self.call(tool="read", path=path, paths=paths, offset=offset, limit=limit)
|
|
270
|
+
|
|
271
|
+
def write(self, *, path: str, content: str):
|
|
272
|
+
return self.call(tool="write", path=path, content=content)
|
|
273
|
+
|
|
274
|
+
def edit(self, *, path: str | None = None, edits: list | None = None, files: list | None = None):
|
|
275
|
+
"""One file as ``path=, edits=``, several as ``files=[{path, edits}]``; each edit is
|
|
276
|
+
``{"old", "new", "replace_all"?}`` and the whole call is atomic."""
|
|
277
|
+
return self.call(tool="edit", path=path, edits=edits, files=files)
|
|
278
|
+
|
|
279
|
+
def patch(self, *, diff: str, cwd: str | None = None):
|
|
280
|
+
return self.call(tool="patch", diff=diff, cwd=cwd)
|
|
281
|
+
|
|
282
|
+
def glob(self, *, pattern: str, cwd: str | None = None):
|
|
283
|
+
return self.call(tool="glob", pattern=pattern, cwd=cwd)
|
|
284
|
+
|
|
285
|
+
def grep(self, *, pattern: str, cwd: str | None = None, glob: str | None = None,
|
|
286
|
+
mode: str | None = None, context: int | None = None,
|
|
287
|
+
ignore_case: bool | None = None, max: int | None = None):
|
|
288
|
+
return self.call(tool="grep", pattern=pattern, cwd=cwd, glob=glob, mode=mode,
|
|
289
|
+
context=context, ignore_case=ignore_case, max=max)
|
|
290
|
+
|
|
291
|
+
# ── commands and processes ────────────────────────────────────────────
|
|
292
|
+
def exec(self, *, cmd, cwd: str | None = None, env: dict | None = None,
|
|
293
|
+
timeout_ms: int | None = None, head: int | None = None, tail: int | None = None,
|
|
294
|
+
quiet: bool | None = None, detach: bool | None = None):
|
|
295
|
+
"""``cmd`` is a shell string or an argv list. ``detach=True`` answers ``{id}`` and the
|
|
296
|
+
command becomes a process for the ``process_*`` calls (pass that id as ``process``)."""
|
|
297
|
+
return self.call(tool="exec", cmd=cmd, cwd=cwd, env=env, timeout_ms=timeout_ms,
|
|
298
|
+
head=head, tail=tail, quiet=quiet, detach=detach)
|
|
299
|
+
|
|
300
|
+
def process_list(self):
|
|
301
|
+
return self.call(tool="process_list")
|
|
302
|
+
|
|
303
|
+
def process_output(self, *, process: str, since: int | None = None, since_err: int | None = None):
|
|
304
|
+
return self.call(tool="process_output", id=process, since=since, since_err=since_err)
|
|
305
|
+
|
|
306
|
+
def process_write(self, *, process: str, data: str):
|
|
307
|
+
return self.call(tool="process_write", id=process, data=data)
|
|
308
|
+
|
|
309
|
+
def process_kill(self, *, process: str, signal: str | None = None):
|
|
310
|
+
return self.call(tool="process_kill", id=process, signal=signal)
|
|
311
|
+
|
|
312
|
+
# ── watches ───────────────────────────────────────────────────────────
|
|
313
|
+
def watch(self, *, paths: list | None = None, cmd=None, pattern: str | None = None,
|
|
314
|
+
once: bool | None = None):
|
|
315
|
+
return self.call(tool="watch", paths=paths, cmd=cmd, pattern=pattern, once=once)
|
|
316
|
+
|
|
317
|
+
def watch_poll(self, *, watch: str, since: int | None = None):
|
|
318
|
+
return self.call(tool="watch_poll", id=watch, since=since)
|
|
319
|
+
|
|
320
|
+
def watch_stop(self, *, watch: str):
|
|
321
|
+
return self.call(tool="watch_stop", id=watch)
|
|
322
|
+
|
|
323
|
+
# ── containers (the hidden per-owner builder) ─────────────────────────
|
|
324
|
+
def container_build(self, *, tags: list, context: str | None = None, file: str | None = None,
|
|
325
|
+
build_args: list | None = None, platform: str | None = None,
|
|
326
|
+
no_cache: bool | None = None, timeout_ms: int | None = None,
|
|
327
|
+
head: int | None = None, tail: int | None = None):
|
|
328
|
+
return self.call(tool="container_build", tags=tags, context=context, file=file,
|
|
329
|
+
build_args=build_args, platform=platform, no_cache=no_cache,
|
|
330
|
+
timeout_ms=timeout_ms, head=head, tail=tail)
|
|
331
|
+
|
|
332
|
+
def container_push(self, *, src: str, dst: list):
|
|
333
|
+
return self.call(tool="container_push", src=src, dst=dst)
|
|
334
|
+
|
|
335
|
+
def _http(self, method: str, path: str, body=None):
|
|
336
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
337
|
+
if self._token:
|
|
338
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
339
|
+
data = None
|
|
340
|
+
if body is not None:
|
|
341
|
+
data = json.dumps(body).encode()
|
|
342
|
+
headers["Content-Type"] = "application/json"
|
|
343
|
+
req = urllib.request.Request(self.url + path, data=data, headers=headers, method=method)
|
|
344
|
+
try:
|
|
345
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
346
|
+
raw = resp.read()
|
|
347
|
+
except urllib.error.HTTPError as e:
|
|
348
|
+
raise ApiError(e.code, _error_text(e.read())) from None
|
|
349
|
+
return json.loads(raw) if raw else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class Environments:
|
|
353
|
+
"""Multi-service environments, over ``/v1/environments``: a set of containers sharing one
|
|
354
|
+
disk. An environment is named by ONE string — ``"name"`` for the caller's own, ``"team/name"``
|
|
355
|
+
for a team's — or by its id (``env-…``). Each service is a dict: ``name`` and ``image``, plus
|
|
356
|
+
optional ``ports``, ``env``, ``command`` and ``mounts`` (``[{"folder", "path"}]``, a folder of
|
|
357
|
+
the environment's disk)."""
|
|
358
|
+
|
|
359
|
+
def __init__(self, client: Kloudlite):
|
|
360
|
+
self._c = client
|
|
361
|
+
|
|
362
|
+
def create(self, *, environment: str, services: list | None = None,
|
|
363
|
+
quota_gb: int | None = None, snapshot: str | None = None,
|
|
364
|
+
ttl_secs: int | None = None):
|
|
365
|
+
"""``snapshot`` grows it from a pushed snapshot — also one of a deleted environment —
|
|
366
|
+
and then ``services`` default to the ones that snapshot froze. ``ttl_secs``
|
|
367
|
+
(600..=2592000) deletes it after that long unused; absent, never."""
|
|
368
|
+
owner, name = _owned(environment)
|
|
369
|
+
return self._c.request("POST", "/v1/environments", body=_drop({
|
|
370
|
+
"name": name, "owner": owner, "services": _services(services) if services else None,
|
|
371
|
+
"quota_gb": quota_gb, "snapshot_id": snapshot, "ttl_secs": ttl_secs}))
|
|
372
|
+
|
|
373
|
+
def list(self, *, owner: str | None = None):
|
|
374
|
+
"""The caller's environments and their teams'; ``owner`` narrows to one handle or team."""
|
|
375
|
+
return self._c.request("GET", "/v1/environments", query={"owner": owner})
|
|
376
|
+
|
|
377
|
+
def get(self, *, environment: str):
|
|
378
|
+
return self._at("GET", environment)
|
|
379
|
+
|
|
380
|
+
def set_services(self, *, environment: str, services: list,
|
|
381
|
+
restart_on_env_change: bool | None = None):
|
|
382
|
+
"""Replace the WHOLE service list. A removed service keeps its folders on the disk, so
|
|
383
|
+
adding it back finds its data. A changed ``env`` only marks a running service
|
|
384
|
+
``restartRequired`` unless ``restart_on_env_change=True``."""
|
|
385
|
+
return self._at("PATCH", environment, body=_drop(
|
|
386
|
+
{"services": _services(services), "restartOnEnvChange": restart_on_env_change}))
|
|
387
|
+
|
|
388
|
+
def start(self, *, environment: str):
|
|
389
|
+
return self._at("POST", environment, "/start")
|
|
390
|
+
|
|
391
|
+
def stop(self, *, environment: str):
|
|
392
|
+
return self._at("POST", environment, "/stop")
|
|
393
|
+
|
|
394
|
+
def restart(self, *, environment: str, service: str):
|
|
395
|
+
"""Restart one service so it gets the current env (202); 409 when the environment is not running."""
|
|
396
|
+
return self._at("POST", environment, f"/services/{_seg(service)}/restart")
|
|
397
|
+
|
|
398
|
+
def set_ttl(self, *, environment: str, ttl_secs: int | None):
|
|
399
|
+
"""Seconds the environment may sit unused before it is deleted (600..=2592000); None clears."""
|
|
400
|
+
return self._at("PUT", environment, "/ttl", body={"ttl_secs": ttl_secs})
|
|
401
|
+
|
|
402
|
+
def delete(self, *, environment: str):
|
|
403
|
+
"""The volume survives while a pushed snapshot references it."""
|
|
404
|
+
return self._at("DELETE", environment)
|
|
405
|
+
|
|
406
|
+
def take_snapshot(self, *, environment: str, message: str | None = None,
|
|
407
|
+
volume: str | None = None, ttl_secs: int | None = None,
|
|
408
|
+
never: bool | None = None):
|
|
409
|
+
"""Push a SNAPSHOT of every service's data and the service list (202). ``volume`` names
|
|
410
|
+
the volume and is required on its first push. ``push`` is the old name."""
|
|
411
|
+
return self._at("POST", environment, "/snapshots", body=_drop(
|
|
412
|
+
{"message": message, "volume": volume, "ttl_secs": ttl_secs, "never": never}))
|
|
413
|
+
|
|
414
|
+
push = take_snapshot
|
|
415
|
+
|
|
416
|
+
def clone(self, *, environment: str, to: str, snapshot: str | None = None,
|
|
417
|
+
services: list | None = None, quota_gb: int | None = None,
|
|
418
|
+
ttl_secs: int | None = None):
|
|
419
|
+
"""A new environment ``to`` (``"name"``; ``"team/name"`` only with ``snapshot``): a copy of
|
|
420
|
+
the live disk, or with ``snapshot`` of one of this environment's snapshots. ``services``
|
|
421
|
+
and ``quota_gb`` apply only with ``snapshot``."""
|
|
422
|
+
owner, name = _owned(to)
|
|
423
|
+
return self._at("POST", environment, "/clone", body=_drop({
|
|
424
|
+
"name": name, "owner": owner, "snapshot_id": snapshot,
|
|
425
|
+
"services": _services(services) if services else None, "quota_gb": quota_gb,
|
|
426
|
+
"ttl_secs": ttl_secs}))
|
|
427
|
+
|
|
428
|
+
def restore(self, *, environment: str, snapshot: str, discard: list | None = None):
|
|
429
|
+
"""Put THIS environment back to one of its own snapshots, services and data (202). A
|
|
430
|
+
persistent service the snapshot lacks is a 409 naming it; list it in ``discard`` to
|
|
431
|
+
accept losing its data."""
|
|
432
|
+
return self._at("POST", environment, "/restore",
|
|
433
|
+
body=_drop({"snapshot_id": snapshot, "discard": discard}))
|
|
434
|
+
|
|
435
|
+
def _at(self, method: str, environment: str, tail: str = "", body=None):
|
|
436
|
+
owner, name = _owned(environment)
|
|
437
|
+
return self._c.request(method, f"/v1/environments/{_seg(name)}{tail}", body=body,
|
|
438
|
+
query={"owner": owner})
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _owned(s: str) -> tuple[str | None, str]:
|
|
442
|
+
"""``"name"`` → (None, name); ``"team/name"`` → (team, name)."""
|
|
443
|
+
owner, sep, name = s.rpartition("/")
|
|
444
|
+
if sep and (not owner or not name or "/" in owner):
|
|
445
|
+
raise ValueError(f"expected name or team/name, got {s!r}")
|
|
446
|
+
return (owner or None), name
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _services(services: list | None) -> list:
|
|
450
|
+
"""The server requires every field; fill the ones a caller leaves out."""
|
|
451
|
+
return [{"command": [], "env": {}, "mounts": [], "ports": [], **sv} for sv in services or []]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class Sessions:
|
|
455
|
+
"""Working sessions, over ``/v1/sessions``: some of YOUR workspaces in one team, connected to
|
|
456
|
+
one environment. Many people's sessions connect to the same environment and collaborate
|
|
457
|
+
through it; an intercept a session takes is environment-wide, and the session owns it — only
|
|
458
|
+
it can release it, and removing the workspace or deleting the session releases it.
|
|
459
|
+
|
|
460
|
+
A session is named like an environment — ``"name"`` (personal) or ``"team/name"`` — or by its
|
|
461
|
+
id (``ses-…``); a workspace by its id, name or ``"team/name"``, sent verbatim."""
|
|
462
|
+
|
|
463
|
+
def __init__(self, client: Kloudlite):
|
|
464
|
+
self._c = client
|
|
465
|
+
|
|
466
|
+
def create(self, *, session: str, environment: str | None = None, ttl_secs: int | None = None):
|
|
467
|
+
"""``environment``, when given, must be the session's team's: an id, a name or
|
|
468
|
+
``"team/name"``; without one the session connects to nothing until ``connect``.
|
|
469
|
+
``ttl_secs`` (600..=2592000) deletes the session after that long unused."""
|
|
470
|
+
team, name = _owned(session)
|
|
471
|
+
return self._c.request("POST", "/v1/sessions",
|
|
472
|
+
body={"name": name, "team": team, "environment": environment,
|
|
473
|
+
"ttl_secs": ttl_secs})
|
|
474
|
+
|
|
475
|
+
def set_ttl(self, *, session: str, ttl_secs: int | None):
|
|
476
|
+
"""Seconds unused before the session is deleted (600..=2592000); ``None`` clears it."""
|
|
477
|
+
return self._at("PUT", session, "/ttl", body={"ttl_secs": ttl_secs})
|
|
478
|
+
|
|
479
|
+
def list(self, *, team: str | None = None):
|
|
480
|
+
return self._c.request("GET", "/v1/sessions", query={"team": team})
|
|
481
|
+
|
|
482
|
+
def get(self, *, session: str):
|
|
483
|
+
"""The session with its ``workspaces`` and the ``intercepts`` it holds."""
|
|
484
|
+
return self._at("GET", session)
|
|
485
|
+
|
|
486
|
+
def delete(self, *, session: str):
|
|
487
|
+
"""Releases its intercepts and lets its workspaces go — never deletes a workspace."""
|
|
488
|
+
return self._at("DELETE", session)
|
|
489
|
+
|
|
490
|
+
def connect(self, *, session: str, environment: str | None):
|
|
491
|
+
"""Sets, switches or (``None``) clears the session's environment. Its intercepts in the old
|
|
492
|
+
environment are released first; deleting an environment disconnects its sessions the same
|
|
493
|
+
way."""
|
|
494
|
+
return self._at("PUT", session, "/environment", body={"environment": environment})
|
|
495
|
+
|
|
496
|
+
def stop(self, *, session: str):
|
|
497
|
+
"""Stop the session and every workspace in it (202, ``{"stopped": [ids]}``); sandboxes and
|
|
498
|
+
the environment are untouched."""
|
|
499
|
+
return self._at("POST", session, "/stop")
|
|
500
|
+
|
|
501
|
+
def create_workspace(self, *, session: str, workspace: str, quota_gb: int,
|
|
502
|
+
image: str | None = None, packages: list | None = None,
|
|
503
|
+
ttl_secs: int | None = None, env: dict | None = None):
|
|
504
|
+
"""Create a workspace ``workspace`` (a bare name) managed by the session, in its team
|
|
505
|
+
(201). The session controls it from then on; it takes the inputs of
|
|
506
|
+
:meth:`Workspaces.create`."""
|
|
507
|
+
return self._at("POST", session, "/workspaces", body=_drop({
|
|
508
|
+
"name": workspace, "quota_gb": quota_gb, "image": image, "packages": packages,
|
|
509
|
+
"ttl_secs": ttl_secs, "env": env}))
|
|
510
|
+
|
|
511
|
+
def delete_workspace(self, *, session: str, workspace: str):
|
|
512
|
+
"""Delete a workspace the session manages, releasing its intercepts first (204); snapshots stay."""
|
|
513
|
+
return self._at("DELETE", session, f"/workspaces/{_seg(workspace)}")
|
|
514
|
+
|
|
515
|
+
def create_sandbox(self, *, session: str, cmd: list, **options) -> "Sandbox":
|
|
516
|
+
"""Create a sandbox managed by the session (202), in its team and reaching its environment.
|
|
517
|
+
Same inputs as :meth:`Sandboxes.create` (``team`` must be the session's)."""
|
|
518
|
+
doc = self._at("POST", session, "/sandboxes", body=_sandbox_body(cmd=cmd, **options))
|
|
519
|
+
return Sandbox(self._c, doc)
|
|
520
|
+
|
|
521
|
+
def intercept(self, *, session: str, service: str, workspace: str, ports: dict | None = None):
|
|
522
|
+
"""Send everything that calls ``service`` in the environment to ``workspace`` (a member),
|
|
523
|
+
``ports`` mapping each service port to the workspace port that answers it
|
|
524
|
+
(``{8080: 3000}``); an unmapped port answers on the same number."""
|
|
525
|
+
return self._at("POST", session, "/intercepts", body={
|
|
526
|
+
"service": service, "workspace": workspace,
|
|
527
|
+
"ports": [{"service": int(k), "workspace": int(v)} for k, v in (ports or {}).items()],
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
def release(self, *, session: str, service: str):
|
|
531
|
+
return self._at("DELETE", session, f"/intercepts/{_seg(service)}")
|
|
532
|
+
|
|
533
|
+
def _at(self, method: str, session: str, tail: str = "", body=None):
|
|
534
|
+
team, name = _owned(session)
|
|
535
|
+
return self._c.request(method, f"/v1/sessions/{_seg(name)}{tail}", body=body,
|
|
536
|
+
query={"owner": team})
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class Secrets:
|
|
540
|
+
"""An owner's named secrets, over ``/v1/secrets``. A value goes in and never comes back:
|
|
541
|
+
``list`` returns names with who and when. Reference one from a workspace env as
|
|
542
|
+
``"${{ secrets.NAME }}"``."""
|
|
543
|
+
|
|
544
|
+
def __init__(self, client: Kloudlite):
|
|
545
|
+
self._c = client
|
|
546
|
+
|
|
547
|
+
def set(self, *, owner: str, name: str, value: str, scope: str = "mine") -> None:
|
|
548
|
+
"""Store ``name``. ``scope`` ``"mine"`` (default) is your own secret in the team ``owner``
|
|
549
|
+
(``PUT /v1/secrets/{owner}/mine/{name}``; for your own handle, your personal one);
|
|
550
|
+
``"team"`` is the team's shared secret and needs an admin."""
|
|
551
|
+
self._c.request("PUT", self._path(owner, scope, name), body={"value": value})
|
|
552
|
+
|
|
553
|
+
def list(self, *, owner: str, scope: str = "mine"):
|
|
554
|
+
"""Names with ``updated_at`` and ``updated_by``, never values."""
|
|
555
|
+
return self._c.request("GET", self._path(owner, scope))
|
|
556
|
+
|
|
557
|
+
def delete(self, *, owner: str, name: str, scope: str = "mine") -> None:
|
|
558
|
+
"""409 while a workspace or environment still references it; the body names them."""
|
|
559
|
+
self._c.request("DELETE", self._path(owner, scope, name))
|
|
560
|
+
|
|
561
|
+
@staticmethod
|
|
562
|
+
def _path(owner: str, scope: str, name: str | None = None) -> str:
|
|
563
|
+
if scope not in ("mine", "team"):
|
|
564
|
+
raise ValueError(f"scope is 'mine' or 'team', got {scope!r}")
|
|
565
|
+
p = f"/v1/secrets/{_seg(owner)}" + ("/mine" if scope == "mine" else "")
|
|
566
|
+
return p + (f"/{_seg(name)}" if name is not None else "")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class Connectors:
|
|
570
|
+
"""The provider accounts linked to an owner, over ``/v1/connectors``."""
|
|
571
|
+
|
|
572
|
+
def __init__(self, client: Kloudlite):
|
|
573
|
+
self._c = client
|
|
574
|
+
|
|
575
|
+
def list(self, *, owner: str | None = None):
|
|
576
|
+
"""Connectors by name; ``owner`` is a handle or team slug, default your own."""
|
|
577
|
+
return self._c.request("GET", "/v1/connectors", query={"owner": owner})
|
|
578
|
+
|
|
579
|
+
def delete(self, *, name: str) -> None:
|
|
580
|
+
"""Remove a connector (204); a team's needs an admin."""
|
|
581
|
+
self._c.request("DELETE", f"/v1/connectors/{_seg(name)}")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class Volumes:
|
|
585
|
+
"""The btrfs volumes behind workspaces and environments: snapshot history, restore targets,
|
|
586
|
+
and the two deletes — over ``/v1/volumes``."""
|
|
587
|
+
|
|
588
|
+
def __init__(self, client: Kloudlite):
|
|
589
|
+
self._c = client
|
|
590
|
+
|
|
591
|
+
def list(self, *, kind: str | None = None, owner: str | None = None):
|
|
592
|
+
"""``kind`` is ``workspace`` or ``environment``; ``owner`` a handle or team slug."""
|
|
593
|
+
return self._c.request("GET", "/v1/volumes", query={"kind": kind, "owner": owner})
|
|
594
|
+
|
|
595
|
+
def history(self, *, volume: str):
|
|
596
|
+
"""The chain of PUSHED snapshots, newest first — each row a restore target."""
|
|
597
|
+
return self._c.request("GET", f"/v1/volumes/{_seg(volume)}/history")
|
|
598
|
+
|
|
599
|
+
def refs(self, *, volume: str):
|
|
600
|
+
return self._c.request("GET", f"/v1/volumes/{_seg(volume)}/refs")
|
|
601
|
+
|
|
602
|
+
def delete_snapshot(self, *, volume: str, snapshot: str):
|
|
603
|
+
"""Refused (409) for a sync point or a running worktree's base; deleting a detached
|
|
604
|
+
volume's last snapshot deletes the volume."""
|
|
605
|
+
return self._c.request("DELETE", f"/v1/volumes/{_seg(volume)}/snapshots/{_seg(snapshot)}")
|
|
606
|
+
|
|
607
|
+
def delete_by_id(self, *, volume: str):
|
|
608
|
+
"""Takes a DETACHED volume with all its snapshots; refused while a working copy remains."""
|
|
609
|
+
return self._c.request("DELETE", f"/v1/volumes/{_seg(volume)}")
|
|
610
|
+
|
|
611
|
+
def get(self, *, owner: str, name: str):
|
|
612
|
+
"""One named volume; ``owner`` is your handle or a team slug."""
|
|
613
|
+
return self._c.request("GET", self._named(owner, name))
|
|
614
|
+
|
|
615
|
+
def versions(self, *, owner: str, name: str):
|
|
616
|
+
"""The versions of a named volume, newest first."""
|
|
617
|
+
return self._c.request("GET", self._named(owner, name, "/versions"))
|
|
618
|
+
|
|
619
|
+
def delete(self, *, owner: str, name: str):
|
|
620
|
+
"""Delete a named volume and every snapshot on it (204); the name is free again."""
|
|
621
|
+
return self._c.request("DELETE", self._named(owner, name))
|
|
622
|
+
|
|
623
|
+
def delete_version(self, *, owner: str, name: str, n: int):
|
|
624
|
+
"""Delete version ``n`` (204); deleting the last one deletes the volume."""
|
|
625
|
+
return self._c.request("DELETE", self._named(owner, name, f"/versions/{int(n)}"))
|
|
626
|
+
|
|
627
|
+
@staticmethod
|
|
628
|
+
def _named(owner: str, name: str, tail: str = "") -> str:
|
|
629
|
+
return f"/v1/volumes/{_seg(owner)}/{_seg(name)}{tail}"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class SandboxTerminated(ApiError):
|
|
633
|
+
"""A call needed the sandbox ``running`` and it is ``terminated``. ``reason`` is the server's
|
|
634
|
+
(``Timeout``, ``Idle``, ``Exited``, ``Failed``, ``Unplaceable``, ``SourceGone``, ``Lost``,
|
|
635
|
+
``EnvironmentGone`` or ``Terminated``); ``status`` is 409, as the tool route answers a sandbox that is not running."""
|
|
636
|
+
|
|
637
|
+
def __init__(self, sandbox: str, reason: str | None, message: str | None = None):
|
|
638
|
+
text = f"sandbox {sandbox} terminated: {reason or 'unknown reason'}"
|
|
639
|
+
super().__init__(409, f"{text} ({message})" if message else text)
|
|
640
|
+
self.sandbox = sandbox
|
|
641
|
+
self.reason = reason
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class _aio:
|
|
645
|
+
"""``obj.call(...)`` blocks; ``await obj.call.aio(...)`` runs the very same call on a worker
|
|
646
|
+
thread (:func:`asyncio.to_thread`) — one implementation per call, so the async spelling can
|
|
647
|
+
never drift from the blocking one."""
|
|
648
|
+
|
|
649
|
+
def __init__(self, fn):
|
|
650
|
+
self._fn = fn
|
|
651
|
+
functools.update_wrapper(self, fn)
|
|
652
|
+
|
|
653
|
+
def __call__(self, *args, **kwargs): # Class.method(obj, ...) still works
|
|
654
|
+
return self._fn(*args, **kwargs)
|
|
655
|
+
|
|
656
|
+
def __get__(self, obj, owner=None):
|
|
657
|
+
if obj is None:
|
|
658
|
+
return self
|
|
659
|
+
fn = self._fn
|
|
660
|
+
|
|
661
|
+
@functools.wraps(fn)
|
|
662
|
+
def call(*args, **kwargs):
|
|
663
|
+
return fn(obj, *args, **kwargs)
|
|
664
|
+
|
|
665
|
+
async def aio(*args, **kwargs):
|
|
666
|
+
return await asyncio.to_thread(fn, obj, *args, **kwargs)
|
|
667
|
+
|
|
668
|
+
call.aio = aio
|
|
669
|
+
return call
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _backoff(timeout_s: float | None, what: str):
|
|
673
|
+
"""Sleep 0.5 s growing to 2 s between polls; TimeoutError naming ``what`` past ``timeout_s``."""
|
|
674
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
675
|
+
delay = 0.5
|
|
676
|
+
while True:
|
|
677
|
+
yield
|
|
678
|
+
if deadline is not None and time.monotonic() + delay > deadline:
|
|
679
|
+
raise TimeoutError(f"{what} after {timeout_s} s")
|
|
680
|
+
time.sleep(delay)
|
|
681
|
+
delay = min(delay * 1.5, 2.0)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _sandbox_body(*, cmd, image: str | None = None, packages: list | None = None,
|
|
685
|
+
from_workspace: str | None = None, from_snapshot: str | None = None,
|
|
686
|
+
team: str | None = None, tty: bool | None = None, stdin: bool | None = None,
|
|
687
|
+
timeout_s: int | None = None, network: str | None = None,
|
|
688
|
+
env: dict | None = None, cpu: int | None = None, memory_gb: int | None = None,
|
|
689
|
+
disk_gb: int | None = None) -> dict:
|
|
690
|
+
"""The ``POST /v1/sandboxes`` body; refuses a missing ``cmd`` before anything is sent."""
|
|
691
|
+
if isinstance(cmd, (list, tuple)) and cmd and all(isinstance(a, str) for a in cmd):
|
|
692
|
+
cmd = list(cmd)
|
|
693
|
+
else:
|
|
694
|
+
raise ValueError("cmd is required: the program and its arguments as a non-empty list of strings")
|
|
695
|
+
return _drop({"cmd": cmd, "image": image, "packages": packages,
|
|
696
|
+
"from_workspace": from_workspace, "from_snapshot": from_snapshot, "team": team,
|
|
697
|
+
"tty": tty, "stdin": stdin, "timeout_secs": timeout_s, "network": network,
|
|
698
|
+
"env": env, "cpu": cpu, "memory_gb": memory_gb, "disk_gb": disk_gb})
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class Sandboxes:
|
|
702
|
+
"""Throwaway, isolated machines that run ONE command, over ``/v1/sandboxes``. A sandbox starts
|
|
703
|
+
from an image, from one of your workspaces (``from_workspace``) or from a pushed snapshot
|
|
704
|
+
(``from_snapshot``), runs ``cmd``, and ends when it exits or after ``timeout_s`` (server
|
|
705
|
+
default 300). Every call answers a :class:`Sandbox` handle, and every call here and on the
|
|
706
|
+
handles also has an async twin, ``.aio``::
|
|
707
|
+
|
|
708
|
+
sb = kl.sandboxes.create(cmd=["python3", "-c", "print(6 * 7)"], packages=["python3"])
|
|
709
|
+
code = sb.wait()
|
|
710
|
+
print(code, sb.logs())
|
|
711
|
+
"""
|
|
712
|
+
|
|
713
|
+
def __init__(self, client: Kloudlite):
|
|
714
|
+
self._c = client
|
|
715
|
+
|
|
716
|
+
@_aio
|
|
717
|
+
def create(self, *, cmd: list, image: str | None = None, packages: list | None = None,
|
|
718
|
+
from_workspace: str | None = None, from_snapshot: str | None = None,
|
|
719
|
+
team: str | None = None, tty: bool | None = None, stdin: bool | None = None,
|
|
720
|
+
timeout_s: int | None = None, network: str | None = None, env: dict | None = None,
|
|
721
|
+
cpu: int | None = None, memory_gb: int | None = None,
|
|
722
|
+
disk_gb: int | None = None) -> "Sandbox":
|
|
723
|
+
"""Ask for a sandbox and return at once (202); it is usually still ``pending``.
|
|
724
|
+
``cmd`` (required) is the program then its arguments. ``tty`` runs it on a pty; ``stdin``
|
|
725
|
+
keeps input open for ``attach``. ``timeout_s`` is 10..=86400. ``from_workspace`` is an id,
|
|
726
|
+
``"name"`` or ``"team/name"``; ``from_snapshot`` a volume name, ``name@N`` or snapshot id
|
|
727
|
+
(exclusive with it). ``network`` is ``internet`` or ``none``; ``cpu`` 1..=8, ``memory_gb``
|
|
728
|
+
1..=32. A session's sandbox is made with :meth:`Sessions.create_sandbox`. Unset inputs are
|
|
729
|
+
left to the server's defaults."""
|
|
730
|
+
body = _sandbox_body(cmd=cmd, image=image, packages=packages, from_workspace=from_workspace,
|
|
731
|
+
from_snapshot=from_snapshot, team=team, tty=tty, stdin=stdin,
|
|
732
|
+
timeout_s=timeout_s, network=network, env=env, cpu=cpu,
|
|
733
|
+
memory_gb=memory_gb, disk_gb=disk_gb)
|
|
734
|
+
return Sandbox(self._c, self._c.request("POST", "/v1/sandboxes", body=body))
|
|
735
|
+
|
|
736
|
+
@_aio
|
|
737
|
+
def get(self, *, sandbox: str) -> "Sandbox":
|
|
738
|
+
"""One sandbox by id (a name does not resolve)."""
|
|
739
|
+
return Sandbox(self._c, self._c.request("GET", f"/v1/sandboxes/{_seg(sandbox)}"))
|
|
740
|
+
|
|
741
|
+
@_aio
|
|
742
|
+
def list(self, *, team: str | None = None, limit: int | None = None,
|
|
743
|
+
cursor: str | None = None) -> list:
|
|
744
|
+
"""ONE page of the caller's sandboxes, newest first (``limit`` 1..=200, default 50);
|
|
745
|
+
``team`` scopes to that team. :meth:`list_page` also answers the next cursor."""
|
|
746
|
+
return self.list_page(team=team, limit=limit, cursor=cursor)[0]
|
|
747
|
+
|
|
748
|
+
@_aio
|
|
749
|
+
def list_page(self, *, team: str | None = None, limit: int | None = None,
|
|
750
|
+
cursor: str | None = None) -> tuple:
|
|
751
|
+
"""``(sandboxes, next_cursor)``: a page, and the ``X-Next-Cursor`` to pass as ``cursor`` for
|
|
752
|
+
the next one (``None`` on the last page)."""
|
|
753
|
+
docs, headers = self._c._request("GET", "/v1/sandboxes",
|
|
754
|
+
query={"team": team, "limit": limit, "cursor": cursor})
|
|
755
|
+
return [Sandbox(self._c, d) for d in docs or []], headers.get("X-Next-Cursor")
|
|
756
|
+
|
|
757
|
+
@_aio
|
|
758
|
+
def run(self, *, cmd: list, timeout_s: float | None = None, **options) -> dict:
|
|
759
|
+
"""One command in a fresh sandbox: create, wait for it to exit, read the output, and
|
|
760
|
+
terminate — ALWAYS, also when anything before it raised. Answers
|
|
761
|
+
``{"exit_code", "stdout", "stderr", "sandbox"}``: the sandbox keeps one stream, so
|
|
762
|
+
``stdout`` holds the output and error output together and ``stderr`` is ``""``.
|
|
763
|
+
``timeout_s`` bounds the wait (TimeoutError; None waits as long as it takes) and, when
|
|
764
|
+
given, sizes the sandbox's own lifetime to cover it plus a minute. ``options`` are the
|
|
765
|
+
other inputs of :meth:`create` (``image``, ``packages``, ``env``, …)."""
|
|
766
|
+
if timeout_s is not None and "timeout_s" not in options:
|
|
767
|
+
options["timeout_s"] = min(86_400, max(10, math.ceil(timeout_s) + 60))
|
|
768
|
+
sb = self.create(cmd=cmd, **options)
|
|
769
|
+
try:
|
|
770
|
+
code = sb.wait(timeout_s=timeout_s)
|
|
771
|
+
out = sb.logs()
|
|
772
|
+
except BaseException:
|
|
773
|
+
with contextlib.suppress(Exception): # never mask the failure that got us here
|
|
774
|
+
sb.terminate()
|
|
775
|
+
raise
|
|
776
|
+
sb.terminate()
|
|
777
|
+
return {"exit_code": code, "stdout": out, "stderr": "", "sandbox": sb.id}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class Sandbox:
|
|
781
|
+
"""A handle on one sandbox: ``id``, ``phase`` (``pending``, ``starting``, ``running``,
|
|
782
|
+
``terminated``), ``reason`` once terminated, ``exit_code``, and the whole server ``doc`` as of
|
|
783
|
+
the last answer — :meth:`refresh` re-reads it."""
|
|
784
|
+
|
|
785
|
+
def __init__(self, client: Kloudlite, doc: dict):
|
|
786
|
+
self._c = client
|
|
787
|
+
self.doc = doc
|
|
788
|
+
|
|
789
|
+
id = property(lambda self: self.doc["id"])
|
|
790
|
+
phase = property(lambda self: self.doc.get("phase"))
|
|
791
|
+
reason = property(lambda self: self.doc.get("reason"))
|
|
792
|
+
exit_code = property(lambda self: self.doc.get("exit_code"))
|
|
793
|
+
|
|
794
|
+
def __repr__(self):
|
|
795
|
+
return f"<Sandbox {self.id} {self.phase}{' ' + self.reason if self.reason else ''}>"
|
|
796
|
+
|
|
797
|
+
@_aio
|
|
798
|
+
def refresh(self) -> "Sandbox":
|
|
799
|
+
self.doc = self._c.request("GET", self._path())
|
|
800
|
+
return self
|
|
801
|
+
|
|
802
|
+
@_aio
|
|
803
|
+
def wait_running(self, *, timeout_s: float = 120) -> "Sandbox":
|
|
804
|
+
"""Poll until ``running``; :class:`SandboxTerminated` naming the reason if it terminates
|
|
805
|
+
instead (an image that does not pull, a quota, a node that cannot place it, or a command
|
|
806
|
+
that already exited)."""
|
|
807
|
+
for _ in _backoff(timeout_s, f"sandbox {self.id} not running"):
|
|
808
|
+
self.refresh()
|
|
809
|
+
if self.phase == "running":
|
|
810
|
+
return self
|
|
811
|
+
if self.phase == "terminated":
|
|
812
|
+
raise SandboxTerminated(self.id, self.reason, self.doc.get("message"))
|
|
813
|
+
|
|
814
|
+
@_aio
|
|
815
|
+
def logs(self, *, follow: bool = False) -> str:
|
|
816
|
+
"""The main process's output (stdout and stderr together) as text: the live output while it
|
|
817
|
+
runs, the last 256 KiB after. ``follow=True`` holds until the process ends. 409 while the
|
|
818
|
+
sandbox has no output yet (still starting)."""
|
|
819
|
+
return self._c._request("GET", self._path("/logs"), query={"follow": "true" if follow else None},
|
|
820
|
+
text=True, timeout=None if follow else "default")[0]
|
|
821
|
+
|
|
822
|
+
def attach(self, *, cols: int | None = None, rows: int | None = None):
|
|
823
|
+
"""Not in this SDK: ``GET /v1/sandboxes/{id}/attach`` is a WebSocket and this client uses
|
|
824
|
+
only the standard library."""
|
|
825
|
+
raise NotImplementedError("sandbox attach is a WebSocket and is not in the Python SDK")
|
|
826
|
+
|
|
827
|
+
@_aio
|
|
828
|
+
def terminate(self) -> "Sandbox":
|
|
829
|
+
"""Stop it for good (202, or 200 if already ended); the record stays until :meth:`delete`."""
|
|
830
|
+
self.doc = self._c.request("POST", self._path("/terminate")) or self.doc
|
|
831
|
+
return self
|
|
832
|
+
|
|
833
|
+
@_aio
|
|
834
|
+
def wait(self, *, timeout_s: float | None = None) -> int | None:
|
|
835
|
+
"""Block until it has ended and answer its exit code (``None`` when it ended some other way
|
|
836
|
+
than the command exiting; ``reason`` says how). Waits in steps of at most 20 s, the most the
|
|
837
|
+
api holds one open; TimeoutError past ``timeout_s``."""
|
|
838
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
839
|
+
while True:
|
|
840
|
+
left_ms = 20_000 if deadline is None else int((deadline - time.monotonic()) * 1000)
|
|
841
|
+
if left_ms <= 0:
|
|
842
|
+
raise TimeoutError(f"sandbox {self.id} still {self.phase} after {timeout_s} s")
|
|
843
|
+
out = self._c.request("POST", self._path("/wait"), body={"timeout_ms": min(left_ms, 20_000)})
|
|
844
|
+
self.doc = {**self.doc, **{k: v for k, v in (out or {}).items() if k in
|
|
845
|
+
("phase", "reason", "exit_code", "message")}}
|
|
846
|
+
if self.phase == "terminated":
|
|
847
|
+
return self.exit_code
|
|
848
|
+
|
|
849
|
+
@_aio
|
|
850
|
+
def delete(self):
|
|
851
|
+
"""Delete the record (204); a sandbox still running dies with it."""
|
|
852
|
+
return self._c.request("DELETE", self._path())
|
|
853
|
+
|
|
854
|
+
def _path(self, tail: str = "") -> str:
|
|
855
|
+
return f"/v1/sandboxes/{_seg(self.id)}{tail}"
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
_RUN_TERMINAL = ("success", "failure", "cancelled")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
class Pipelines:
|
|
862
|
+
"""Pipelines, over ``/v1/pipelines``: a pipeline is a JSON definition stored as numbered
|
|
863
|
+
versions; a run belongs to a pipeline and executes one version, each job in its own sandbox.
|
|
864
|
+
:meth:`run`, :meth:`runs` and :meth:`get_run` answer :class:`Run` handles, and every call here
|
|
865
|
+
and on a handle also has an async twin, ``.aio``::
|
|
866
|
+
|
|
867
|
+
kl.pipelines.create(owner="acme", definition=definition)
|
|
868
|
+
run = kl.pipelines.run(owner="acme", pipeline="test", inputs={"target": "staging"})
|
|
869
|
+
for chunk in run.follow(job="test"):
|
|
870
|
+
print(chunk, end="")
|
|
871
|
+
assert run.wait() == "success"
|
|
872
|
+
"""
|
|
873
|
+
|
|
874
|
+
def __init__(self, client: Kloudlite):
|
|
875
|
+
self._c = client
|
|
876
|
+
|
|
877
|
+
@_aio
|
|
878
|
+
def create(self, *, owner: str, definition: dict) -> dict:
|
|
879
|
+
"""Save ``definition`` (its ``name`` is the pipeline's) as version 1 (201). A webhook
|
|
880
|
+
trigger's secret URL is in the answer's ``webhookUrl``, this once. 409 if the name exists."""
|
|
881
|
+
return self._c.request("POST", f"/v1/pipelines/{_seg(owner)}", body=definition)
|
|
882
|
+
|
|
883
|
+
@_aio
|
|
884
|
+
def update(self, *, owner: str, name: str, definition: dict) -> dict:
|
|
885
|
+
"""Replace the definition: a changed one is the next version, an equal one saves nothing
|
|
886
|
+
and answers the latest. To roll back, send an old definition. A concurrent save is an
|
|
887
|
+
:class:`ApiError` 409 (``changed; retry``)."""
|
|
888
|
+
return self._c.request("PUT", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}", body=definition)
|
|
889
|
+
|
|
890
|
+
@_aio
|
|
891
|
+
def list(self, *, owner: str) -> list:
|
|
892
|
+
"""``owner``'s pipelines by name: ``name``, ``latestVersion``, ``updatedAtMs``, ``triggers``."""
|
|
893
|
+
return self._c.request("GET", f"/v1/pipelines/{_seg(owner)}")
|
|
894
|
+
|
|
895
|
+
@_aio
|
|
896
|
+
def get(self, *, owner: str, name: str, version: int | None = None) -> dict:
|
|
897
|
+
"""The pipeline document of ``version`` (default the latest), ``definition`` included."""
|
|
898
|
+
return self._c.request("GET", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}",
|
|
899
|
+
query={"version": version})
|
|
900
|
+
|
|
901
|
+
@_aio
|
|
902
|
+
def versions(self, *, owner: str, name: str) -> list:
|
|
903
|
+
"""The versions, oldest first: ``version``, ``createdBy``, ``createdAtMs``, ``definitionSha256``."""
|
|
904
|
+
return self._c.request("GET", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}/versions")
|
|
905
|
+
|
|
906
|
+
@_aio
|
|
907
|
+
def delete(self, *, owner: str, name: str) -> None:
|
|
908
|
+
"""Delete the pipeline and its versions; finished runs stay. 409 while a run is queued or running."""
|
|
909
|
+
self._c.request("DELETE", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}")
|
|
910
|
+
|
|
911
|
+
@_aio
|
|
912
|
+
def delete_version(self, *, owner: str, name: str, version: int) -> None:
|
|
913
|
+
"""Delete one version; 409 for the latest or one a queued or running run uses."""
|
|
914
|
+
self._c.request("DELETE", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}/versions/{int(version)}")
|
|
915
|
+
|
|
916
|
+
@_aio
|
|
917
|
+
def rotate_webhook(self, *, owner: str, name: str) -> dict:
|
|
918
|
+
"""Make a new webhook secret; the old URL stops at once. ``{"webhookUrl"}``, shown only here."""
|
|
919
|
+
return self._c.request("POST", f"/v1/pipelines/{_seg(owner)}/{_seg(name)}/webhook/rotate")
|
|
920
|
+
|
|
921
|
+
@_aio
|
|
922
|
+
def run(self, *, owner: str, pipeline: str, version: int | None = None,
|
|
923
|
+
inputs: dict | None = None, environment: str | None = None,
|
|
924
|
+
clone: str | None = None) -> "Run":
|
|
925
|
+
"""Start ``pipeline`` by hand and answer the :class:`Run` at once (202), ``queued``.
|
|
926
|
+
``version`` defaults to the newest; ``inputs`` are string values of the inputs it declares.
|
|
927
|
+
``environment`` runs every job that names none in that environment; ``clone`` instead in a
|
|
928
|
+
per-run copy of it — one or the other. A version that cannot run is an :class:`ApiError`
|
|
929
|
+
422 whose message lists every reason, and nothing is recorded."""
|
|
930
|
+
body = {"version": version, "inputs": inputs,
|
|
931
|
+
"environment": _run_environment(environment, clone)}
|
|
932
|
+
doc = self._c.request("POST", f"/v1/pipelines/{_seg(owner)}/{_seg(pipeline)}/runs",
|
|
933
|
+
body={k: v for k, v in body.items() if v is not None})
|
|
934
|
+
return Run(self._c, doc)
|
|
935
|
+
|
|
936
|
+
@_aio
|
|
937
|
+
def runs(self, *, owner: str, pipeline: str, limit: int | None = None) -> list:
|
|
938
|
+
"""The pipeline's runs, newest first (``limit`` default 50, at most 200)."""
|
|
939
|
+
docs = self._c.request("GET", f"/v1/pipelines/{_seg(owner)}/{_seg(pipeline)}/runs",
|
|
940
|
+
query={"limit": limit})
|
|
941
|
+
return [Run(self._c, d) for d in docs or []]
|
|
942
|
+
|
|
943
|
+
@_aio
|
|
944
|
+
def get_run(self, *, owner: str, pipeline: str, run: str) -> "Run":
|
|
945
|
+
"""One run by its id (``1760400000000-0a1b2c``), ``definition`` set while its version exists."""
|
|
946
|
+
return Run(self._c, self._c.request(
|
|
947
|
+
"GET", f"/v1/pipelines/{_seg(owner)}/{_seg(pipeline)}/runs/{_seg(run)}"))
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _run_environment(environment: str | None, clone: str | None):
|
|
951
|
+
"""The wire's one ``environment`` field: a name, or ``{"clone": name}``."""
|
|
952
|
+
if environment is not None and clone is not None:
|
|
953
|
+
raise ValueError("pass environment or clone, not both")
|
|
954
|
+
return {"clone": clone} if clone is not None else environment
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
class Run:
|
|
958
|
+
"""A handle on one pipeline run: its fields as of the last answer (``id``, ``number``,
|
|
959
|
+
``state`` — ``queued``, ``running``, ``success``, ``failure`` or ``cancelled`` — ``jobs`` and
|
|
960
|
+
the rest; ``doc`` is the whole server answer, camelCase as the wire spells it) and the calls
|
|
961
|
+
on it. :meth:`refresh` re-reads it. Each job in ``jobs`` is a dict with ``key``, ``job``,
|
|
962
|
+
``matrix``, ``needs``, ``state`` (``pending``, ``running``, ``success``, ``failure``,
|
|
963
|
+
``skipped``, ``cancelled``), ``sandbox``, ``startedAtMs``, ``finishedAtMs``, ``exitCode``,
|
|
964
|
+
``failedStep`` and ``detail``; :meth:`logs` and :meth:`follow` name one by its index or its
|
|
965
|
+
``key``."""
|
|
966
|
+
|
|
967
|
+
def __init__(self, client: Kloudlite, doc: dict):
|
|
968
|
+
self._c = client
|
|
969
|
+
self.doc = doc
|
|
970
|
+
|
|
971
|
+
id = property(lambda self: self.doc["id"])
|
|
972
|
+
number = property(lambda self: self.doc.get("number"))
|
|
973
|
+
pipeline = property(lambda self: self.doc.get("pipeline"))
|
|
974
|
+
owner = property(lambda self: self.doc.get("owner"))
|
|
975
|
+
pipeline_version = property(lambda self: self.doc.get("pipelineVersion"))
|
|
976
|
+
definition_sha256 = property(lambda self: self.doc.get("definitionSha256"))
|
|
977
|
+
event = property(lambda self: self.doc.get("event"))
|
|
978
|
+
inputs = property(lambda self: self.doc.get("inputs") or {})
|
|
979
|
+
environment = property(lambda self: self.doc.get("environment"))
|
|
980
|
+
requested_by = property(lambda self: self.doc.get("requestedBy"))
|
|
981
|
+
requested_at_ms = property(lambda self: self.doc.get("requestedAtMs"))
|
|
982
|
+
state = property(lambda self: self.doc.get("state"))
|
|
983
|
+
cancel_requested = property(lambda self: bool(self.doc.get("cancelRequested")))
|
|
984
|
+
started_at_ms = property(lambda self: self.doc.get("startedAtMs"))
|
|
985
|
+
finished_at_ms = property(lambda self: self.doc.get("finishedAtMs"))
|
|
986
|
+
detail = property(lambda self: self.doc.get("detail"))
|
|
987
|
+
jobs = property(lambda self: self.doc.get("jobs") or [])
|
|
988
|
+
|
|
989
|
+
def __repr__(self):
|
|
990
|
+
return f"<Run {self.owner}/{self.pipeline}#{self.number} {self.state}>"
|
|
991
|
+
|
|
992
|
+
@_aio
|
|
993
|
+
def refresh(self) -> "Run":
|
|
994
|
+
self.doc = self._c.request("GET", self._path())
|
|
995
|
+
return self
|
|
996
|
+
|
|
997
|
+
@_aio
|
|
998
|
+
def cancel(self) -> "Run":
|
|
999
|
+
"""Ask it to stop: a queued run is cancelled at once, a running one once its worker has
|
|
1000
|
+
ended its sandboxes — :meth:`wait` sees it reach ``cancelled``."""
|
|
1001
|
+
self.doc = self._c.request("POST", self._path("/cancel")) or self.doc
|
|
1002
|
+
return self
|
|
1003
|
+
|
|
1004
|
+
@_aio
|
|
1005
|
+
def wait(self, *, timeout_s: float | None = None, poll_s: float = 5) -> str:
|
|
1006
|
+
"""Poll every ``poll_s`` until the run has ended and answer its final ``state``;
|
|
1007
|
+
TimeoutError past ``timeout_s`` (None waits as long as it takes)."""
|
|
1008
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
1009
|
+
while self.state not in _RUN_TERMINAL:
|
|
1010
|
+
self.refresh()
|
|
1011
|
+
if self.state in _RUN_TERMINAL:
|
|
1012
|
+
break
|
|
1013
|
+
left = None if deadline is None else deadline - time.monotonic()
|
|
1014
|
+
if left is not None and left <= 0:
|
|
1015
|
+
raise TimeoutError(f"run {self.id} still {self.state} after {timeout_s} s")
|
|
1016
|
+
time.sleep(poll_s if left is None else min(poll_s, left))
|
|
1017
|
+
return self.state
|
|
1018
|
+
|
|
1019
|
+
@_aio
|
|
1020
|
+
def logs(self, *, job: int | str) -> str:
|
|
1021
|
+
"""The job's whole log so far (combined stdout and stderr), every page of it. ``job`` is
|
|
1022
|
+
its index in ``jobs`` or its ``key`` (``"test (postgres16)"``)."""
|
|
1023
|
+
index, offset, out = self._job_index(job), 0, []
|
|
1024
|
+
while True:
|
|
1025
|
+
page = self._page(index, offset)
|
|
1026
|
+
out.append(page.get("data") or "")
|
|
1027
|
+
nxt = int(page.get("next") or offset)
|
|
1028
|
+
if page.get("complete") or nxt <= offset:
|
|
1029
|
+
return "".join(out)
|
|
1030
|
+
offset = nxt
|
|
1031
|
+
|
|
1032
|
+
def follow(self, *, job: int | str, poll_s: float = 5):
|
|
1033
|
+
"""Yield the job's log in chunks as it is written — from the first byte, polling every
|
|
1034
|
+
``poll_s`` while nothing new has landed — and stop once the job has ended and every byte
|
|
1035
|
+
was yielded. :meth:`aio_follow` is the same for ``async for``."""
|
|
1036
|
+
index, offset = self._job_index(job), 0
|
|
1037
|
+
while True:
|
|
1038
|
+
page = self._page(index, offset)
|
|
1039
|
+
if page.get("data"):
|
|
1040
|
+
yield page["data"]
|
|
1041
|
+
if page.get("complete"):
|
|
1042
|
+
return
|
|
1043
|
+
nxt = int(page.get("next") or offset)
|
|
1044
|
+
if nxt <= offset:
|
|
1045
|
+
time.sleep(poll_s)
|
|
1046
|
+
offset = max(offset, nxt)
|
|
1047
|
+
|
|
1048
|
+
async def aio_follow(self, *, job: int | str, poll_s: float = 5):
|
|
1049
|
+
"""``async for chunk in run.aio_follow(job=…)``: :meth:`follow` with each page read on a
|
|
1050
|
+
worker thread and the waits on the event loop."""
|
|
1051
|
+
index, offset = self._job_index(job), 0
|
|
1052
|
+
while True:
|
|
1053
|
+
page = await asyncio.to_thread(self._page, index, offset)
|
|
1054
|
+
if page.get("data"):
|
|
1055
|
+
yield page["data"]
|
|
1056
|
+
if page.get("complete"):
|
|
1057
|
+
return
|
|
1058
|
+
nxt = int(page.get("next") or offset)
|
|
1059
|
+
if nxt <= offset:
|
|
1060
|
+
await asyncio.sleep(poll_s)
|
|
1061
|
+
offset = max(offset, nxt)
|
|
1062
|
+
|
|
1063
|
+
def _path(self, tail: str = "") -> str:
|
|
1064
|
+
return f"/v1/pipelines/{_seg(self.owner)}/{_seg(self.pipeline)}/runs/{_seg(self.id)}{tail}"
|
|
1065
|
+
|
|
1066
|
+
def _page(self, index: int, offset: int) -> dict:
|
|
1067
|
+
"""``{data, next, complete, state}``: at most a page of the log from byte ``offset``."""
|
|
1068
|
+
return self._c.request("GET", self._path(f"/jobs/{index}/logs"), query={"offset": offset})
|
|
1069
|
+
|
|
1070
|
+
def _job_index(self, job: int | str) -> int:
|
|
1071
|
+
keys = [j.get("key") for j in self.jobs]
|
|
1072
|
+
if isinstance(job, int) and not isinstance(job, bool):
|
|
1073
|
+
if not 0 <= job < len(keys):
|
|
1074
|
+
raise ValueError(f"run {self.id} has {len(keys)} jobs, no job {job}")
|
|
1075
|
+
return job
|
|
1076
|
+
if job in keys:
|
|
1077
|
+
return keys.index(job)
|
|
1078
|
+
raise ValueError(f"run {self.id} has no job {job!r}; its jobs are {keys}")
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _error_text(raw: bytes) -> str:
|
|
1082
|
+
"""The server answers errors as ``{"error": …}`` or plain text; take either."""
|
|
1083
|
+
text = raw.decode(errors="replace").strip()
|
|
1084
|
+
try:
|
|
1085
|
+
parsed = json.loads(text)
|
|
1086
|
+
if isinstance(parsed, dict) and "error" in parsed:
|
|
1087
|
+
return str(parsed["error"])
|
|
1088
|
+
except ValueError:
|
|
1089
|
+
pass
|
|
1090
|
+
return text or "request failed"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kloudlite-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Kloudlite SDK for Python
|
|
5
|
+
Author: Kloudlite
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/kloudlite/sdk
|
|
8
|
+
Project-URL: Source, https://github.com/kloudlite/sdk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Kloudlite SDK for Python
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install kloudlite-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from kloudlite import Kloudlite
|
|
21
|
+
|
|
22
|
+
kl = Kloudlite() # uses the credentials of `kloudlite login`
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The distribution is `kloudlite-sdk`; the import name is `kloudlite`. The PyPI project named `kloudlite` is a different package.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kloudlite
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kloudlite-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Kloudlite SDK for Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Kloudlite" }]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/kloudlite/sdk"
|
|
17
|
+
Source = "https://github.com/kloudlite/sdk"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
py-modules = ["kloudlite"]
|