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/__init__.py +18 -0
- runtime_sdk/account.py +243 -0
- runtime_sdk/arguments.py +10 -0
- runtime_sdk/auth.py +175 -0
- runtime_sdk/cli.py +985 -0
- runtime_sdk/client.py +616 -0
- runtime_sdk/completion.py +348 -0
- runtime_sdk/computers.py +578 -0
- runtime_sdk/config.py +201 -0
- runtime_sdk/console.py +118 -0
- runtime_sdk/files.py +233 -0
- runtime_sdk/github.py +229 -0
- runtime_sdk/goals.py +368 -0
- runtime_sdk/output.py +69 -0
- runtime_sdk/providers.py +422 -0
- runtime_sdk/proxy.py +600 -0
- runtime_sdk/runtime-tui.exe +0 -0
- runtime_sdk/secrets.py +155 -0
- runtime_sdk/services.py +173 -0
- runtime_sdk/ssh.py +197 -0
- runtime_sdk/terminal.py +330 -0
- runtime_sdk-0.4.49.dist-info/METADATA +273 -0
- runtime_sdk-0.4.49.dist-info/RECORD +26 -0
- runtime_sdk-0.4.49.dist-info/WHEEL +5 -0
- runtime_sdk-0.4.49.dist-info/entry_points.txt +2 -0
- runtime_sdk-0.4.49.dist-info/top_level.txt +1 -0
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
|