runtime-sdk 0.4.49__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
runtime_sdk/client.py ADDED
@@ -0,0 +1,616 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any, BinaryIO, Callable
7
+ from urllib.parse import urlencode
8
+
9
+ import httpx
10
+
11
+
12
+ class RuntimeAPIError(RuntimeError):
13
+ def __init__(self, message: str, status_code: int | None = None) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+
17
+
18
+ DEFAULT_WARMUP_TIMEOUT = 180.0
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class RuntimeClient:
23
+ base_url: str
24
+ credential: str | Callable[[], str] | None = None
25
+ timeout: float = 10.0
26
+ transport: httpx.BaseTransport | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ self.base_url = self.base_url.rstrip("/")
30
+
31
+ def start_device_login(self) -> dict[str, Any]:
32
+ return self._request("POST", "/api/auth/device/start")
33
+
34
+ def poll_device_login(self, device_code: str) -> dict[str, Any]:
35
+ return self._request(
36
+ "POST",
37
+ "/api/auth/device/token",
38
+ json={"device_code": device_code},
39
+ success_statuses={200},
40
+ )
41
+
42
+ def refresh_session(
43
+ self, refresh_token: str, organization_id: str
44
+ ) -> dict[str, Any]:
45
+ return self._request(
46
+ "POST",
47
+ "/api/auth/refresh",
48
+ json={
49
+ "refresh_token": refresh_token,
50
+ "organization_id": organization_id,
51
+ },
52
+ )
53
+
54
+ def whoami(self) -> dict[str, Any]:
55
+ return self._request("GET", "/api/auth/whoami", auth_required=True)
56
+
57
+ def logout(self) -> dict[str, Any]:
58
+ return self._request("POST", "/api/auth/logout", auth_required=True)
59
+
60
+ def list_api_keys(self) -> dict[str, Any]:
61
+ return self._request("GET", "/api/api-keys", auth_required=True)
62
+
63
+ def create_api_key(self, name: str) -> dict[str, Any]:
64
+ return self._request("POST", "/api/api-keys", json={"name": name}, auth_required=True)
65
+
66
+ def revoke_api_key(self, key_id: str) -> dict[str, Any]:
67
+ return self._request("DELETE", f"/api/api-keys/{key_id}", auth_required=True)
68
+
69
+ # --- GitHub App ---
70
+
71
+ def start_github_connect(self) -> dict[str, Any]:
72
+ return self._request("POST", "/api/github/connect", auth_required=True)
73
+
74
+ def get_github_connect_session(self, session_id: int) -> dict[str, Any]:
75
+ return self._request("GET", f"/api/github/connect/{session_id}", auth_required=True)
76
+
77
+ def list_github_installations(self) -> dict[str, Any]:
78
+ return self._request("GET", "/api/github/installations", auth_required=True)
79
+
80
+ def disconnect_github_installation(self, installation_id: int) -> dict[str, Any]:
81
+ return self._request("DELETE", f"/api/github/installations/{installation_id}", auth_required=True)
82
+
83
+ def list_secret_sets(self) -> dict[str, Any]:
84
+ return self._request("GET", "/api/secrets", auth_required=True)
85
+
86
+ def get_secret_set(self, slug: str) -> dict[str, Any]:
87
+ return self._request("GET", f"/api/secrets/{slug}", auth_required=True)
88
+
89
+ def put_secret_set(self, slug: str, values: dict[str, str], delivery: dict[str, Any] | None = None) -> dict[str, Any]:
90
+ payload: dict[str, Any] = {"values": values}
91
+ if delivery:
92
+ payload["deliveries"] = delivery
93
+ return self._request("PUT", f"/api/secrets/{slug}", json=payload, auth_required=True)
94
+
95
+ def delete_secret_set(self, slug: str) -> dict[str, Any]:
96
+ return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
97
+
98
+ def list_provider_credentials(self) -> dict[str, Any]:
99
+ return self._request("GET", "/api/provider-credentials", auth_required=True)
100
+
101
+ def put_provider_credential(self, provider: str, auth_json: str) -> dict[str, Any]:
102
+ return self._request("PUT", f"/api/provider-credentials/{provider}", json={"auth_json": auth_json}, auth_required=True)
103
+
104
+ def delete_provider_credential(self, provider: str) -> dict[str, Any]:
105
+ return self._request("DELETE", f"/api/provider-credentials/{provider}", auth_required=True)
106
+
107
+ # --- Computers ---
108
+
109
+ def switch_workspace(
110
+ self,
111
+ *,
112
+ repo: str,
113
+ branch: str,
114
+ create: bool = False,
115
+ base_branch: str | None = None,
116
+ ) -> dict[str, Any]:
117
+ body: dict[str, Any] = {"repo": repo, "branch": branch, "create": create}
118
+ if base_branch is not None:
119
+ body["from"] = base_branch
120
+ return self._request(
121
+ "POST",
122
+ "/api/workspaces/switch",
123
+ json=body,
124
+ auth_required=True,
125
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
126
+ )
127
+
128
+ def create_computer(
129
+ self,
130
+ *,
131
+ slug: str | None = None,
132
+ command: str | None = None,
133
+ cwd: str | None = None,
134
+ port: int | None = None,
135
+ network_mode: str | None = None,
136
+ allowed_hosts: list[str] | None = None,
137
+ ) -> dict[str, Any]:
138
+ body: dict[str, Any] = {}
139
+ if slug:
140
+ body["slug"] = slug
141
+ if command is not None or cwd is not None or port is not None:
142
+ start: dict[str, Any] = {}
143
+ if command is not None:
144
+ start["command"] = command
145
+ if cwd is not None:
146
+ start["cwd"] = cwd
147
+ if port is not None:
148
+ start["port"] = port
149
+ body["start"] = start
150
+ if network_mode is not None:
151
+ body["network_policy"] = {
152
+ "mode": network_mode,
153
+ "allowed_hosts": list(allowed_hosts or []),
154
+ }
155
+ return self._request(
156
+ "POST",
157
+ "/api/computers",
158
+ json=body or None,
159
+ auth_required=True,
160
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
161
+ )
162
+
163
+ def list_computers(self) -> dict[str, Any]:
164
+ return self._request("GET", "/api/computers", auth_required=True)
165
+
166
+ def get_computer(self, computer_id: str) -> dict[str, Any]:
167
+ return self._request("GET", f"/api/computers/{computer_id}", auth_required=True)
168
+
169
+ def get_computer_network(self, computer_id: str) -> dict[str, Any]:
170
+ return self._request("GET", f"/api/computers/{computer_id}/network", auth_required=True)
171
+
172
+ def list_network_denials(self, computer_id: str) -> dict[str, Any]:
173
+ return self._request(
174
+ "GET",
175
+ f"/api/computers/{computer_id}/network/denials",
176
+ auth_required=True,
177
+ )
178
+
179
+ def set_computer_network(
180
+ self, computer_id: str, mode: str, allowed_hosts: list[str] | None = None
181
+ ) -> dict[str, Any]:
182
+ return self._request(
183
+ "PUT",
184
+ f"/api/computers/{computer_id}/network",
185
+ json={"mode": mode, "allowed_hosts": list(allowed_hosts or [])},
186
+ auth_required=True,
187
+ )
188
+
189
+ def get_default_network(self) -> dict[str, Any]:
190
+ return self._request("GET", "/api/network/default", auth_required=True)
191
+
192
+ def set_default_network(
193
+ self, mode: str, allowed_hosts: list[str] | None = None
194
+ ) -> dict[str, Any]:
195
+ return self._request(
196
+ "PUT",
197
+ "/api/network/default",
198
+ json={"mode": mode, "allowed_hosts": list(allowed_hosts or [])},
199
+ auth_required=True,
200
+ )
201
+
202
+ def set_visibility(self, computer_id: str, visibility: str) -> dict[str, Any]:
203
+ return self._request(
204
+ "PUT",
205
+ f"/api/computers/{computer_id}/visibility",
206
+ json={"visibility": visibility},
207
+ auth_required=True,
208
+ )
209
+
210
+ def get_startup_config(self, computer_id: str) -> dict[str, Any]:
211
+ return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
212
+
213
+ def get_service_config(self, computer_id: str) -> dict[str, Any]:
214
+ return self.get_service(computer_id)
215
+
216
+ def get_service(self, computer_id: str) -> dict[str, Any]:
217
+ return self._request("GET", f"/api/computers/{computer_id}/service", auth_required=True)
218
+
219
+ def run_service(
220
+ self,
221
+ computer_id: str,
222
+ *,
223
+ command: str,
224
+ cwd: str | None = None,
225
+ port: int,
226
+ ) -> dict[str, Any]:
227
+ body: dict[str, Any] = {"command": command, "port": port}
228
+ if cwd is not None:
229
+ body["cwd"] = cwd
230
+ return self._request(
231
+ "POST",
232
+ f"/api/computers/{computer_id}/service/run",
233
+ json=body,
234
+ auth_required=True,
235
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
236
+ )
237
+
238
+ def restart_service(self, computer_id: str) -> dict[str, Any]:
239
+ return self._request(
240
+ "POST",
241
+ f"/api/computers/{computer_id}/service/restart",
242
+ auth_required=True,
243
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
244
+ )
245
+
246
+ def stop_service(self, computer_id: str) -> dict[str, Any]:
247
+ return self._request("POST", f"/api/computers/{computer_id}/service/stop", auth_required=True)
248
+
249
+ def clear_service(self, computer_id: str) -> dict[str, Any]:
250
+ return self._request("DELETE", f"/api/computers/{computer_id}/service", auth_required=True)
251
+
252
+ def stream_service_logs(self, computer_id: str, *, follow: bool = False):
253
+ credential = self._credential()
254
+ path = f"/api/computers/{computer_id}/service/logs"
255
+ if follow:
256
+ path += "?follow=true"
257
+ client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
258
+ try:
259
+ with client.stream("GET", path, headers={"Authorization": f"Bearer {credential}"}) as response:
260
+ if not response.is_success:
261
+ body = response.read()
262
+ payload: dict[str, Any] = {}
263
+ if body:
264
+ try:
265
+ decoded = response.json()
266
+ if isinstance(decoded, dict):
267
+ payload = decoded
268
+ except ValueError:
269
+ pass
270
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
271
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
272
+ for line in response.iter_lines():
273
+ if not line:
274
+ continue
275
+ yield line
276
+ finally:
277
+ client.close()
278
+
279
+ def set_startup_config(
280
+ self,
281
+ computer_id: str,
282
+ *,
283
+ command: str,
284
+ cwd: str | None = None,
285
+ port: int,
286
+ ) -> dict[str, Any]:
287
+ body: dict[str, Any] = {"command": command, "port": port}
288
+ if cwd is not None:
289
+ body["cwd"] = cwd
290
+ return self._request("PUT", f"/api/computers/{computer_id}/startup", json=body, auth_required=True)
291
+
292
+ def clear_startup_config(self, computer_id: str) -> dict[str, Any]:
293
+ return self._request("DELETE", f"/api/computers/{computer_id}/startup", auth_required=True)
294
+
295
+ def clear_service_config(self, computer_id: str) -> dict[str, Any]:
296
+ return self.clear_service(computer_id)
297
+
298
+ def start_computer(self, computer_id: str) -> dict[str, Any]:
299
+ return self._request(
300
+ "POST",
301
+ f"/api/computers/{computer_id}/start",
302
+ auth_required=True,
303
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
304
+ )
305
+
306
+ def delete_computer(self, computer_id: str) -> dict[str, Any]:
307
+ return self._request("DELETE", f"/api/computers/{computer_id}", auth_required=True)
308
+
309
+ def create_terminal_ticket(self, computer_id: str) -> dict[str, Any]:
310
+ return self._request(
311
+ "POST",
312
+ f"/api/computers/{computer_id}/terminal-ticket",
313
+ auth_required=True,
314
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
315
+ )
316
+
317
+ def create_proxy_ticket(self, computer_id: str, port: int) -> dict[str, Any]:
318
+ if port <= 0 or port > 65535:
319
+ raise RuntimeAPIError("port must be between 1 and 65535")
320
+ return self._request(
321
+ "POST",
322
+ f"/api/computers/{computer_id}/proxy-ticket",
323
+ json={"port": port},
324
+ auth_required=True,
325
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
326
+ )
327
+
328
+ def run_command(
329
+ self,
330
+ computer_id: str,
331
+ command: str,
332
+ *,
333
+ uid: int | None = None,
334
+ gid: int | None = None,
335
+ cwd: str | None = None,
336
+ shell: str | None = None,
337
+ ) -> dict[str, Any]:
338
+ body: dict[str, Any] = {"command": command}
339
+ if uid is not None:
340
+ body["uid"] = uid
341
+ if gid is not None:
342
+ body["gid"] = gid
343
+ if cwd is not None:
344
+ body["cwd"] = cwd
345
+ if shell is not None:
346
+ body["shell"] = shell
347
+ return self._request(
348
+ "POST",
349
+ f"/api/computers/{computer_id}/exec",
350
+ json=body,
351
+ auth_required=True,
352
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
353
+ )
354
+
355
+ def stream_command(
356
+ self,
357
+ computer_id: str,
358
+ command: str,
359
+ *,
360
+ stdin: bytes | None = None,
361
+ uid: int | None = None,
362
+ gid: int | None = None,
363
+ cwd: str | None = None,
364
+ shell: str | None = None,
365
+ ):
366
+ body: dict[str, Any] = {"command": command}
367
+ if stdin:
368
+ body["stdin_base64"] = base64.b64encode(stdin).decode()
369
+ if uid is not None:
370
+ body["uid"] = uid
371
+ if gid is not None:
372
+ body["gid"] = gid
373
+ if cwd is not None:
374
+ body["cwd"] = cwd
375
+ if shell is not None:
376
+ body["shell"] = shell
377
+ headers = self._headers(True)
378
+ client = httpx.Client(base_url=self.base_url, timeout=None, transport=self.transport)
379
+ try:
380
+ with client.stream("POST", f"/api/computers/{computer_id}/exec/stream", json=body, headers=headers) as response:
381
+ if not response.is_success:
382
+ body_bytes = response.read()
383
+ payload: dict[str, Any] = {}
384
+ if body_bytes:
385
+ try:
386
+ decoded = json.loads(body_bytes.decode())
387
+ if isinstance(decoded, dict):
388
+ payload = decoded
389
+ except ValueError:
390
+ pass
391
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
392
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
393
+ for line in response.iter_lines():
394
+ if not line:
395
+ continue
396
+ try:
397
+ frame = json.loads(line)
398
+ except ValueError as exc:
399
+ raise RuntimeAPIError("server returned invalid NDJSON", status_code=response.status_code) from exc
400
+ if not isinstance(frame, dict):
401
+ raise RuntimeAPIError("server returned non-object NDJSON", status_code=response.status_code)
402
+ yield frame
403
+ except httpx.HTTPError as exc:
404
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
405
+ finally:
406
+ client.close()
407
+
408
+ def read_file(self, computer_id: str, path: str) -> bytes:
409
+ return self._request_bytes(
410
+ "GET",
411
+ f"/api/computers/{computer_id}/files/read",
412
+ params={"path": path},
413
+ auth_required=True,
414
+ )
415
+
416
+ def write_file(
417
+ self,
418
+ computer_id: str,
419
+ path: str,
420
+ data: bytes,
421
+ *,
422
+ uid: int | None = None,
423
+ gid: int | None = None,
424
+ mode: str | None = None,
425
+ ) -> dict[str, Any]:
426
+ params: dict[str, Any] = {"path": path}
427
+ if uid is not None:
428
+ params["uid"] = uid
429
+ if gid is not None:
430
+ params["gid"] = gid
431
+ if mode is not None:
432
+ params["mode"] = mode
433
+ return self._request(
434
+ "PUT",
435
+ f"/api/computers/{computer_id}/files/write",
436
+ params=params,
437
+ data=data,
438
+ auth_required=True,
439
+ )
440
+
441
+ def list_files(self, computer_id: str, path: str) -> dict[str, Any]:
442
+ return self._request("GET", f"/api/computers/{computer_id}/files/list", params={"path": path}, auth_required=True)
443
+
444
+ def stat_file(self, computer_id: str, path: str) -> dict[str, Any]:
445
+ return self._request("GET", f"/api/computers/{computer_id}/files/stat", params={"path": path}, auth_required=True)
446
+
447
+ def mkdir(self, computer_id: str, path: str, *, recursive: bool = False, mode: str | None = None) -> dict[str, Any]:
448
+ body: dict[str, Any] = {"path": path, "recursive": recursive}
449
+ if mode is not None:
450
+ body["mode"] = mode
451
+ return self._request("POST", f"/api/computers/{computer_id}/files/mkdir", json=body, auth_required=True)
452
+
453
+ def delete_file(self, computer_id: str, path: str, *, recursive: bool = False) -> dict[str, Any]:
454
+ return self._request(
455
+ "DELETE",
456
+ f"/api/computers/{computer_id}/files/delete",
457
+ params={"path": path, "recursive": str(recursive).lower()},
458
+ auth_required=True,
459
+ )
460
+
461
+ def upload_files(self, computer_id: str, path: str, archive: Any) -> dict[str, Any]:
462
+ return self._request(
463
+ "PUT",
464
+ f"/api/computers/{computer_id}/files/upload",
465
+ params={"path": path},
466
+ data=archive,
467
+ auth_required=True,
468
+ )
469
+
470
+ def download_files(self, computer_id: str, path: str) -> bytes:
471
+ return self._request_bytes(
472
+ "GET",
473
+ f"/api/computers/{computer_id}/files/download",
474
+ params={"path": path},
475
+ auth_required=True,
476
+ )
477
+
478
+ def download_files_to(self, computer_id: str, path: str, writer: BinaryIO) -> None:
479
+ self._request_bytes_to(
480
+ "GET",
481
+ f"/api/computers/{computer_id}/files/download",
482
+ writer,
483
+ params={"path": path},
484
+ auth_required=True,
485
+ )
486
+
487
+ def publish_port(self, computer_id: str, port: int, *, command: str | None = None, cwd: str | None = None) -> dict[str, Any]:
488
+ if port <= 0 or port > 65535:
489
+ raise RuntimeAPIError("port must be between 1 and 65535")
490
+ if not command:
491
+ raise RuntimeAPIError("publish requires -- <command...>")
492
+ return self.run_service(computer_id, command=command, cwd=cwd, port=port)
493
+
494
+ def _request(
495
+ self,
496
+ method: str,
497
+ path: str,
498
+ *,
499
+ json: dict[str, Any] | None = None,
500
+ data: Any = None,
501
+ params: dict[str, Any] | None = None,
502
+ auth_required: bool = False,
503
+ timeout: float | None = None,
504
+ success_statuses: set[int] | None = None,
505
+ ) -> dict[str, Any]:
506
+ headers = self._headers(auth_required)
507
+
508
+ try:
509
+ with httpx.Client(
510
+ base_url=self.base_url,
511
+ timeout=self.timeout if timeout is None else timeout,
512
+ transport=self.transport,
513
+ ) as client:
514
+ response = client.request(method, self._path(path, params), json=json, content=data, headers=headers)
515
+ except httpx.HTTPError as exc:
516
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
517
+
518
+ payload = self._decode_response(response)
519
+ if response.is_success and (
520
+ success_statuses is None or response.status_code in success_statuses
521
+ ):
522
+ return payload
523
+
524
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
525
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
526
+
527
+ def _request_bytes(
528
+ self,
529
+ method: str,
530
+ path: str,
531
+ *,
532
+ params: dict[str, Any] | None = None,
533
+ auth_required: bool = False,
534
+ timeout: float | None = None,
535
+ ) -> bytes:
536
+ headers = self._headers(auth_required)
537
+ try:
538
+ with httpx.Client(
539
+ base_url=self.base_url,
540
+ timeout=self.timeout if timeout is None else timeout,
541
+ transport=self.transport,
542
+ ) as client:
543
+ response = client.request(method, self._path(path, params), headers=headers)
544
+ except httpx.HTTPError as exc:
545
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
546
+
547
+ if response.is_success:
548
+ return response.content
549
+
550
+ payload = self._decode_response(response)
551
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
552
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
553
+
554
+ def _request_bytes_to(
555
+ self,
556
+ method: str,
557
+ path: str,
558
+ writer: BinaryIO,
559
+ *,
560
+ params: dict[str, Any] | None = None,
561
+ auth_required: bool = False,
562
+ timeout: float | None = None,
563
+ ) -> None:
564
+ headers = self._headers(auth_required)
565
+ try:
566
+ with httpx.Client(
567
+ base_url=self.base_url,
568
+ timeout=self.timeout if timeout is None else timeout,
569
+ transport=self.transport,
570
+ ) as client:
571
+ with client.stream(method, self._path(path, params), headers=headers) as response:
572
+ if response.is_success:
573
+ for chunk in response.iter_bytes():
574
+ writer.write(chunk)
575
+ return
576
+ response.read()
577
+ payload = self._decode_response(response)
578
+ except httpx.HTTPError as exc:
579
+ raise RuntimeAPIError(f"request failed: {exc}") from exc
580
+
581
+ message = payload.get("error") or payload.get("message") or f"http {response.status_code}"
582
+ raise RuntimeAPIError(str(message), status_code=response.status_code)
583
+
584
+ def _headers(self, auth_required: bool) -> dict[str, str]:
585
+ if not auth_required:
586
+ return {}
587
+ return {"Authorization": f"Bearer {self._credential()}"}
588
+
589
+ def _credential(self) -> str:
590
+ value = self.credential() if callable(self.credential) else self.credential
591
+ if not value:
592
+ raise RuntimeAPIError("missing credential")
593
+ return value
594
+
595
+ @staticmethod
596
+ def _path(path: str, params: dict[str, Any] | None = None) -> str:
597
+ if not params:
598
+ return path
599
+ query = urlencode({key: value for key, value in params.items() if value is not None})
600
+ if not query:
601
+ return path
602
+ separator = "&" if "?" in path else "?"
603
+ return f"{path}{separator}{query}"
604
+
605
+ @staticmethod
606
+ def _decode_response(response: httpx.Response) -> dict[str, Any]:
607
+ if not response.content:
608
+ return {}
609
+ try:
610
+ decoded = response.json()
611
+ except ValueError as exc:
612
+ raise RuntimeAPIError("server returned invalid JSON", status_code=response.status_code) from exc
613
+
614
+ if not isinstance(decoded, dict):
615
+ raise RuntimeAPIError("server returned non-object JSON", status_code=response.status_code)
616
+ return decoded