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.
@@ -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,7 @@
1
+ README.md
2
+ kloudlite.py
3
+ pyproject.toml
4
+ kloudlite_sdk.egg-info/PKG-INFO
5
+ kloudlite_sdk.egg-info/SOURCES.txt
6
+ kloudlite_sdk.egg-info/dependency_links.txt
7
+ kloudlite_sdk.egg-info/top_level.txt
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+