runcloud-sdk 0.7.4__tar.gz → 0.8.1__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.
- {runcloud_sdk-0.7.4/runcloud_sdk.egg-info → runcloud_sdk-0.8.1}/PKG-INFO +23 -1
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/README.md +21 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/pyproject.toml +2 -2
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/__init__.py +2 -0
- runcloud_sdk-0.8.1/runcloud/client.py +549 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/modal.py +18 -14
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1/runcloud_sdk.egg-info}/PKG-INFO +23 -1
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/SOURCES.txt +1 -0
- runcloud_sdk-0.8.1/runcloud_sdk.egg-info/requires.txt +1 -0
- runcloud_sdk-0.8.1/tests/test_sdk.py +461 -0
- runcloud_sdk-0.7.4/runcloud/client.py +0 -272
- runcloud_sdk-0.7.4/tests/test_sdk.py +0 -216
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/LICENSE +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/__init__.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/blaxel.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/daytona.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/e2b.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/sprites.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/vercel.py +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/top_level.txt +0 -0
- {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runcloud-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
|
|
5
5
|
Author: Newly
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,6 +13,7 @@ Classifier: Operating System :: OS Independent
|
|
|
13
13
|
Requires-Python: >=3.9
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
License-File: LICENSE
|
|
16
|
+
Requires-Dist: websockets<16,>=13
|
|
16
17
|
Dynamic: license-file
|
|
17
18
|
|
|
18
19
|
# runcloud
|
|
@@ -36,6 +37,27 @@ finally:
|
|
|
36
37
|
box.destroy()
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
Commands started through the Modal compatibility adapter stream output while
|
|
41
|
+
they are running:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from runcloud.compat import modal
|
|
45
|
+
|
|
46
|
+
app = modal.App.lookup("my-app")
|
|
47
|
+
box = modal.Sandbox.create(app=app)
|
|
48
|
+
try:
|
|
49
|
+
process = box.exec(
|
|
50
|
+
"bash",
|
|
51
|
+
"-c",
|
|
52
|
+
"for i in {1..5}; do date +%T; sleep 0.5; done",
|
|
53
|
+
)
|
|
54
|
+
for line in process.stdout:
|
|
55
|
+
print(line, end="")
|
|
56
|
+
process.wait()
|
|
57
|
+
finally:
|
|
58
|
+
box.terminate()
|
|
59
|
+
```
|
|
60
|
+
|
|
39
61
|
`Client()` automatically uses the credential saved by `runcloud login`. For
|
|
40
62
|
automation, set `RUN_CLOUD_API_KEY` and optionally `RUN_CLOUD_API_URL`, or pass
|
|
41
63
|
`api_key` and `api_url` directly.
|
|
@@ -19,6 +19,27 @@ finally:
|
|
|
19
19
|
box.destroy()
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Commands started through the Modal compatibility adapter stream output while
|
|
23
|
+
they are running:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from runcloud.compat import modal
|
|
27
|
+
|
|
28
|
+
app = modal.App.lookup("my-app")
|
|
29
|
+
box = modal.Sandbox.create(app=app)
|
|
30
|
+
try:
|
|
31
|
+
process = box.exec(
|
|
32
|
+
"bash",
|
|
33
|
+
"-c",
|
|
34
|
+
"for i in {1..5}; do date +%T; sleep 0.5; done",
|
|
35
|
+
)
|
|
36
|
+
for line in process.stdout:
|
|
37
|
+
print(line, end="")
|
|
38
|
+
process.wait()
|
|
39
|
+
finally:
|
|
40
|
+
box.terminate()
|
|
41
|
+
```
|
|
42
|
+
|
|
22
43
|
`Client()` automatically uses the credential saved by `runcloud login`. For
|
|
23
44
|
automation, set `RUN_CLOUD_API_KEY` and optionally `RUN_CLOUD_API_URL`, or pass
|
|
24
45
|
`api_key` and `api_url` directly.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runcloud-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "Python SDK for run.cloud, including sandbox-provider compatibility adapters"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -12,7 +12,7 @@ classifiers = [
|
|
|
12
12
|
"Programming Language :: Python :: 3",
|
|
13
13
|
"Operating System :: OS Independent",
|
|
14
14
|
]
|
|
15
|
-
dependencies = []
|
|
15
|
+
dependencies = ["websockets>=13,<16"]
|
|
16
16
|
|
|
17
17
|
[project.urls]
|
|
18
18
|
Homepage = "https://run.cloud"
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Dependency-free Python client for the public run.cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Optional, Union
|
|
17
|
+
|
|
18
|
+
from websockets.sync.client import connect as websocket_connect
|
|
19
|
+
|
|
20
|
+
Transport = Callable[[str, str, Any], Any]
|
|
21
|
+
StreamTransport = Callable[[str, dict[str, Any]], Any]
|
|
22
|
+
DEFAULT_API_URL = "https://api.run.cloud"
|
|
23
|
+
USER_AGENT = "runcloud-python/0.8.1"
|
|
24
|
+
FILE_CHUNK_BYTES = 512 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunCloudError(Exception):
|
|
28
|
+
def __init__(self, status: int, detail: str):
|
|
29
|
+
super().__init__(f"run.cloud API {status}: {detail}")
|
|
30
|
+
self.status = status
|
|
31
|
+
self.detail = detail
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnsupportedCompatibilityFeatureError(Exception):
|
|
35
|
+
def __init__(self, provider: str, feature: str, alternative: str = ""):
|
|
36
|
+
message = (
|
|
37
|
+
f"{provider} compatibility does not support {feature} on run.cloud yet"
|
|
38
|
+
)
|
|
39
|
+
if alternative:
|
|
40
|
+
message += f"; {alternative}"
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.provider = provider
|
|
43
|
+
self.feature = feature
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ExecResult:
|
|
48
|
+
exit_code: int
|
|
49
|
+
stdout: str
|
|
50
|
+
stderr: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _StreamReader:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self._condition = threading.Condition()
|
|
56
|
+
self._buffer = ""
|
|
57
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
58
|
+
self._eof = False
|
|
59
|
+
self._error: Optional[Exception] = None
|
|
60
|
+
|
|
61
|
+
def _feed(self, data: bytes) -> None:
|
|
62
|
+
text = self._decoder.decode(data)
|
|
63
|
+
if not text:
|
|
64
|
+
return
|
|
65
|
+
with self._condition:
|
|
66
|
+
self._buffer += text
|
|
67
|
+
self._condition.notify_all()
|
|
68
|
+
|
|
69
|
+
def _finish(self, error: Optional[Exception] = None) -> None:
|
|
70
|
+
with self._condition:
|
|
71
|
+
self._buffer += self._decoder.decode(b"", final=True)
|
|
72
|
+
self._error = error
|
|
73
|
+
self._eof = True
|
|
74
|
+
self._condition.notify_all()
|
|
75
|
+
|
|
76
|
+
def _raise_error_if_empty(self) -> None:
|
|
77
|
+
if not self._buffer and self._error is not None:
|
|
78
|
+
raise self._error
|
|
79
|
+
|
|
80
|
+
def read(self, size: int = -1) -> str:
|
|
81
|
+
if size == 0:
|
|
82
|
+
return ""
|
|
83
|
+
with self._condition:
|
|
84
|
+
if size < 0:
|
|
85
|
+
while not self._eof:
|
|
86
|
+
self._condition.wait()
|
|
87
|
+
self._raise_error_if_empty()
|
|
88
|
+
value, self._buffer = self._buffer, ""
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
while len(self._buffer) < size and not self._eof:
|
|
92
|
+
self._condition.wait()
|
|
93
|
+
self._raise_error_if_empty()
|
|
94
|
+
value, self._buffer = self._buffer[:size], self._buffer[size:]
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
def readline(self, size: int = -1) -> str:
|
|
98
|
+
if size == 0:
|
|
99
|
+
return ""
|
|
100
|
+
with self._condition:
|
|
101
|
+
while True:
|
|
102
|
+
newline = self._buffer.find("\n")
|
|
103
|
+
if newline >= 0:
|
|
104
|
+
end = newline + 1
|
|
105
|
+
break
|
|
106
|
+
if size >= 0 and len(self._buffer) >= size:
|
|
107
|
+
end = size
|
|
108
|
+
break
|
|
109
|
+
if self._eof:
|
|
110
|
+
self._raise_error_if_empty()
|
|
111
|
+
end = len(self._buffer)
|
|
112
|
+
break
|
|
113
|
+
self._condition.wait()
|
|
114
|
+
|
|
115
|
+
if size >= 0:
|
|
116
|
+
end = min(end, size)
|
|
117
|
+
value, self._buffer = self._buffer[:end], self._buffer[end:]
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
def __iter__(self):
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __next__(self) -> str:
|
|
124
|
+
line = self.readline()
|
|
125
|
+
if line == "":
|
|
126
|
+
raise StopIteration
|
|
127
|
+
return line
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ExecProcess:
|
|
131
|
+
def __init__(self, session: Any):
|
|
132
|
+
self.stdout = _StreamReader()
|
|
133
|
+
self.stderr = _StreamReader()
|
|
134
|
+
self._session = session
|
|
135
|
+
self._returncode: Optional[int] = None
|
|
136
|
+
self._error: Optional[Exception] = None
|
|
137
|
+
self._finished = threading.Event()
|
|
138
|
+
self._reader_thread = threading.Thread(
|
|
139
|
+
target=self._read_frames,
|
|
140
|
+
name="runcloud-exec-stream",
|
|
141
|
+
daemon=True,
|
|
142
|
+
)
|
|
143
|
+
self._reader_thread.start()
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def returncode(self) -> Optional[int]:
|
|
147
|
+
return self._returncode
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def exit_code(self) -> int:
|
|
151
|
+
return self.wait()
|
|
152
|
+
|
|
153
|
+
def poll(self) -> Optional[int]:
|
|
154
|
+
if not self._finished.is_set():
|
|
155
|
+
return None
|
|
156
|
+
if self._error is not None:
|
|
157
|
+
raise self._error
|
|
158
|
+
return self._returncode
|
|
159
|
+
|
|
160
|
+
def wait(self, timeout: Optional[float] = None) -> int:
|
|
161
|
+
if not self._finished.wait(timeout):
|
|
162
|
+
raise TimeoutError("run.cloud process did not exit before the wait timeout")
|
|
163
|
+
if self._error is not None:
|
|
164
|
+
raise self._error
|
|
165
|
+
return self._returncode if self._returncode is not None else 0
|
|
166
|
+
|
|
167
|
+
def _read_frames(self) -> None:
|
|
168
|
+
error: Optional[Exception] = None
|
|
169
|
+
try:
|
|
170
|
+
for raw in self._session:
|
|
171
|
+
if isinstance(raw, bytes):
|
|
172
|
+
raw = raw.decode("utf-8")
|
|
173
|
+
frame = json.loads(raw)
|
|
174
|
+
stream = frame.get("stream")
|
|
175
|
+
if stream in {"stdout", "stderr"}:
|
|
176
|
+
encoded = frame.get("data")
|
|
177
|
+
if not isinstance(encoded, str):
|
|
178
|
+
continue
|
|
179
|
+
data = base64.b64decode(encoded, validate=True)
|
|
180
|
+
target = self.stderr if stream == "stderr" else self.stdout
|
|
181
|
+
target._feed(data)
|
|
182
|
+
elif stream == "exit":
|
|
183
|
+
code = frame.get("code", frame.get("exit_code", 0))
|
|
184
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
185
|
+
raise ValueError("exec stream returned an invalid exit code")
|
|
186
|
+
self._returncode = code
|
|
187
|
+
break
|
|
188
|
+
if self._returncode is None:
|
|
189
|
+
raise RuntimeError("exec stream closed before reporting an exit code")
|
|
190
|
+
except Exception as caught:
|
|
191
|
+
error = RunCloudError(0, f"exec stream failed: {caught}")
|
|
192
|
+
self._error = error
|
|
193
|
+
finally:
|
|
194
|
+
try:
|
|
195
|
+
self._session.close()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
self.stdout._finish(error)
|
|
199
|
+
self.stderr._finish(error)
|
|
200
|
+
self._finished.set()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class _SavedCredentials:
|
|
205
|
+
api_key: str
|
|
206
|
+
api_url: str
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _nonempty_string(value: Any) -> Optional[str]:
|
|
210
|
+
if not isinstance(value, str):
|
|
211
|
+
return None
|
|
212
|
+
normalized = value.strip()
|
|
213
|
+
return normalized or None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_saved_credentials() -> Optional[_SavedCredentials]:
|
|
217
|
+
configured_home = _nonempty_string(os.environ.get("RUN_CLOUD_HOME"))
|
|
218
|
+
credentials_home = (
|
|
219
|
+
Path(configured_home).expanduser()
|
|
220
|
+
if configured_home
|
|
221
|
+
else Path.home() / ".run-cloud"
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
payload = json.loads((credentials_home / "credentials").read_text())
|
|
225
|
+
except (OSError, TypeError, ValueError):
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
if not isinstance(payload, dict):
|
|
229
|
+
return None
|
|
230
|
+
api_key = _nonempty_string(payload.get("token"))
|
|
231
|
+
api_url = _nonempty_string(payload.get("apiUrl"))
|
|
232
|
+
expires_at = payload.get("expiresAt")
|
|
233
|
+
if (
|
|
234
|
+
not api_key
|
|
235
|
+
or not api_url
|
|
236
|
+
or isinstance(expires_at, bool)
|
|
237
|
+
or not isinstance(expires_at, (int, float))
|
|
238
|
+
or expires_at < time.time()
|
|
239
|
+
):
|
|
240
|
+
return None
|
|
241
|
+
return _SavedCredentials(api_key=api_key, api_url=api_url)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Client:
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
api_key: Optional[str] = None,
|
|
248
|
+
api_url: Optional[str] = None,
|
|
249
|
+
timeout: float = 60.0,
|
|
250
|
+
transport: Optional[Transport] = None,
|
|
251
|
+
stream_transport: Optional[StreamTransport] = None,
|
|
252
|
+
):
|
|
253
|
+
explicit_api_key = _nonempty_string(api_key)
|
|
254
|
+
environment_api_key = _nonempty_string(
|
|
255
|
+
os.environ.get("RUN_CLOUD_API_KEY")
|
|
256
|
+
) or _nonempty_string(os.environ.get("RUN_CLOUD_API_TOKEN"))
|
|
257
|
+
saved_credentials = (
|
|
258
|
+
None
|
|
259
|
+
if explicit_api_key or environment_api_key
|
|
260
|
+
else _load_saved_credentials()
|
|
261
|
+
)
|
|
262
|
+
self.api_key = (
|
|
263
|
+
explicit_api_key
|
|
264
|
+
or environment_api_key
|
|
265
|
+
or (saved_credentials.api_key if saved_credentials else None)
|
|
266
|
+
)
|
|
267
|
+
if not self.api_key:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"Run Cloud API key required. Run `runcloud login`, set "
|
|
270
|
+
"RUN_CLOUD_API_KEY, or pass Client(api_key=...)."
|
|
271
|
+
)
|
|
272
|
+
explicit_api_url = _nonempty_string(api_url)
|
|
273
|
+
environment_api_url = _nonempty_string(os.environ.get("RUN_CLOUD_API_URL"))
|
|
274
|
+
self.api_url = (
|
|
275
|
+
explicit_api_url
|
|
276
|
+
or environment_api_url
|
|
277
|
+
or (saved_credentials.api_url if saved_credentials else None)
|
|
278
|
+
or DEFAULT_API_URL
|
|
279
|
+
).rstrip("/")
|
|
280
|
+
self.timeout = timeout
|
|
281
|
+
self._transport = transport
|
|
282
|
+
self._stream_transport = stream_transport
|
|
283
|
+
|
|
284
|
+
def request(self, method: str, path: str, body: Any = None) -> Any:
|
|
285
|
+
if self._transport is not None:
|
|
286
|
+
return self._transport(method, path, body)
|
|
287
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
288
|
+
headers = {
|
|
289
|
+
"Authorization": "Bearer " + self.api_key,
|
|
290
|
+
"User-Agent": USER_AGENT,
|
|
291
|
+
}
|
|
292
|
+
if body is not None:
|
|
293
|
+
headers["Content-Type"] = "application/json"
|
|
294
|
+
request = urllib.request.Request(
|
|
295
|
+
self.api_url + path,
|
|
296
|
+
data=data,
|
|
297
|
+
method=method,
|
|
298
|
+
headers=headers,
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
302
|
+
raw = response.read()
|
|
303
|
+
return json.loads(raw) if raw else None
|
|
304
|
+
except urllib.error.HTTPError as error:
|
|
305
|
+
text = error.read().decode(errors="replace")
|
|
306
|
+
try:
|
|
307
|
+
parsed = json.loads(text)
|
|
308
|
+
text = (
|
|
309
|
+
parsed.get("detail")
|
|
310
|
+
or parsed.get("error")
|
|
311
|
+
or parsed.get("message")
|
|
312
|
+
or text
|
|
313
|
+
)
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
raise RunCloudError(error.code, str(text)) from None
|
|
317
|
+
|
|
318
|
+
def open_exec_stream(self, path: str, body: dict[str, Any]) -> ExecProcess:
|
|
319
|
+
if self._stream_transport is not None:
|
|
320
|
+
return ExecProcess(self._stream_transport(path, body))
|
|
321
|
+
|
|
322
|
+
http_url = self.api_url + path
|
|
323
|
+
if http_url.startswith("https://"):
|
|
324
|
+
websocket_url = "wss://" + http_url.removeprefix("https://")
|
|
325
|
+
elif http_url.startswith("http://"):
|
|
326
|
+
websocket_url = "ws://" + http_url.removeprefix("http://")
|
|
327
|
+
else:
|
|
328
|
+
raise ValueError("run.cloud API URL must use http or https")
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
session = websocket_connect(
|
|
332
|
+
websocket_url,
|
|
333
|
+
additional_headers={"Authorization": "Bearer " + self.api_key},
|
|
334
|
+
user_agent_header=USER_AGENT,
|
|
335
|
+
open_timeout=self.timeout,
|
|
336
|
+
close_timeout=5,
|
|
337
|
+
max_size=8 << 20,
|
|
338
|
+
)
|
|
339
|
+
session.send(json.dumps(body))
|
|
340
|
+
session.send(json.dumps({"stream": "stdin_close"}))
|
|
341
|
+
except Exception as error:
|
|
342
|
+
raise RunCloudError(0, f"could not open exec stream: {error}") from None
|
|
343
|
+
return ExecProcess(session)
|
|
344
|
+
|
|
345
|
+
def create(
|
|
346
|
+
self,
|
|
347
|
+
image: str = "runcloud/agent-base",
|
|
348
|
+
cpu: Optional[float] = None,
|
|
349
|
+
memory: Optional[int] = None,
|
|
350
|
+
disk: Optional[int] = None,
|
|
351
|
+
idle_pause_seconds: Optional[int] = None,
|
|
352
|
+
timeout_seconds: Optional[int] = None,
|
|
353
|
+
region: Optional[str] = None,
|
|
354
|
+
name: Optional[str] = None,
|
|
355
|
+
) -> "Sandbox":
|
|
356
|
+
values = {
|
|
357
|
+
"image": image,
|
|
358
|
+
"cpu": cpu,
|
|
359
|
+
"memory": memory,
|
|
360
|
+
"disk": disk,
|
|
361
|
+
"idlePauseSeconds": idle_pause_seconds,
|
|
362
|
+
"timeoutSeconds": timeout_seconds,
|
|
363
|
+
"region": region,
|
|
364
|
+
"name": name,
|
|
365
|
+
}
|
|
366
|
+
body = {key: value for key, value in values.items() if value is not None}
|
|
367
|
+
return Sandbox(self, self.request("POST", "/run-cloud/sandboxes", body))
|
|
368
|
+
|
|
369
|
+
def get(self, sandbox_id: str) -> "Sandbox":
|
|
370
|
+
sid = urllib.parse.quote(sandbox_id, safe="")
|
|
371
|
+
return Sandbox(self, self.request("GET", f"/run-cloud/sandboxes/{sid}"))
|
|
372
|
+
|
|
373
|
+
def list(self, state: Optional[str] = None) -> list["Sandbox"]:
|
|
374
|
+
query = f"?state={urllib.parse.quote(state, safe='')}" if state else ""
|
|
375
|
+
payload = self.request("GET", "/run-cloud/sandboxes" + query) or {}
|
|
376
|
+
return [Sandbox(self, item) for item in payload.get("items", [])]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Sandbox:
|
|
380
|
+
def __init__(self, client: Client, data: dict[str, Any]):
|
|
381
|
+
self._client = client
|
|
382
|
+
self._data = data
|
|
383
|
+
self.id = str(data["id"])
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def state(self) -> str:
|
|
387
|
+
return str(self._data.get("state", ""))
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def name(self) -> str:
|
|
391
|
+
return str(self._data.get("name") or "")
|
|
392
|
+
|
|
393
|
+
def refresh(self) -> "Sandbox":
|
|
394
|
+
self._data = self._client.request(
|
|
395
|
+
"GET", f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}"
|
|
396
|
+
)
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def exec(
|
|
400
|
+
self,
|
|
401
|
+
command: Union[str, list[str]],
|
|
402
|
+
cwd: Optional[str] = None,
|
|
403
|
+
env: Optional[dict[str, str]] = None,
|
|
404
|
+
timeout: Optional[int] = None,
|
|
405
|
+
) -> ExecResult:
|
|
406
|
+
argv = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
|
407
|
+
if not argv:
|
|
408
|
+
raise ValueError("command must not be empty")
|
|
409
|
+
body: dict[str, Any] = {"cmd": argv}
|
|
410
|
+
if cwd:
|
|
411
|
+
body["cwd"] = cwd
|
|
412
|
+
if env:
|
|
413
|
+
body["env"] = env
|
|
414
|
+
if timeout is not None:
|
|
415
|
+
body["timeout_seconds"] = timeout
|
|
416
|
+
payload = self._client.request(
|
|
417
|
+
"POST",
|
|
418
|
+
f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/exec",
|
|
419
|
+
body,
|
|
420
|
+
)
|
|
421
|
+
return ExecResult(
|
|
422
|
+
int(payload.get("exit_code", 0)),
|
|
423
|
+
str(payload.get("stdout", "")),
|
|
424
|
+
str(payload.get("stderr", "")),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def exec_stream(
|
|
428
|
+
self,
|
|
429
|
+
command: Union[str, list[str]],
|
|
430
|
+
cwd: Optional[str] = None,
|
|
431
|
+
env: Optional[dict[str, str]] = None,
|
|
432
|
+
timeout: Optional[int] = None,
|
|
433
|
+
) -> ExecProcess:
|
|
434
|
+
argv = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
|
435
|
+
if not argv:
|
|
436
|
+
raise ValueError("command must not be empty")
|
|
437
|
+
body: dict[str, Any] = {"cmd": argv}
|
|
438
|
+
if cwd:
|
|
439
|
+
body["cwd"] = cwd
|
|
440
|
+
if env:
|
|
441
|
+
body["env"] = env
|
|
442
|
+
if timeout is not None:
|
|
443
|
+
body["timeout_seconds"] = timeout
|
|
444
|
+
sandbox_id = urllib.parse.quote(self.id, safe="")
|
|
445
|
+
return self._client.open_exec_stream(
|
|
446
|
+
f"/run-cloud/sandboxes/{sandbox_id}/exec/stream?mode=command",
|
|
447
|
+
body,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def read_file(self, path: str) -> bytes:
|
|
451
|
+
if not path:
|
|
452
|
+
raise ValueError("path must not be empty")
|
|
453
|
+
sandbox_id = urllib.parse.quote(self.id, safe="")
|
|
454
|
+
chunks: list[bytes] = []
|
|
455
|
+
offset = 0
|
|
456
|
+
while True:
|
|
457
|
+
payload = self._client.request(
|
|
458
|
+
"POST",
|
|
459
|
+
f"/run-cloud/sandboxes/{sandbox_id}/fs",
|
|
460
|
+
{
|
|
461
|
+
"op": "read",
|
|
462
|
+
"path": path,
|
|
463
|
+
"offset": offset,
|
|
464
|
+
"length": FILE_CHUNK_BYTES,
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
if payload.get("error"):
|
|
468
|
+
raise RunCloudError(0, str(payload["error"]))
|
|
469
|
+
chunk = base64.b64decode(payload.get("content") or "")
|
|
470
|
+
chunks.append(chunk)
|
|
471
|
+
if payload.get("eof") is True:
|
|
472
|
+
return b"".join(chunks)
|
|
473
|
+
raw_next_offset = payload.get("next_offset")
|
|
474
|
+
raw_size = payload.get("size")
|
|
475
|
+
if (
|
|
476
|
+
payload.get("eof") is None
|
|
477
|
+
and raw_next_offset is None
|
|
478
|
+
and raw_size is None
|
|
479
|
+
):
|
|
480
|
+
return b"".join(chunks)
|
|
481
|
+
next_offset = int(
|
|
482
|
+
raw_next_offset if raw_next_offset is not None else offset + len(chunk)
|
|
483
|
+
)
|
|
484
|
+
if raw_size is not None and next_offset >= int(raw_size):
|
|
485
|
+
return b"".join(chunks)
|
|
486
|
+
if next_offset <= offset:
|
|
487
|
+
raise RunCloudError(0, "file read did not advance")
|
|
488
|
+
offset = next_offset
|
|
489
|
+
|
|
490
|
+
def write_file(
|
|
491
|
+
self,
|
|
492
|
+
path: str,
|
|
493
|
+
content: bytes,
|
|
494
|
+
mode: Optional[int] = None,
|
|
495
|
+
) -> None:
|
|
496
|
+
if not path:
|
|
497
|
+
raise ValueError("path must not be empty")
|
|
498
|
+
if not isinstance(content, bytes):
|
|
499
|
+
raise TypeError("content must be bytes")
|
|
500
|
+
sandbox_id = urllib.parse.quote(self.id, safe="")
|
|
501
|
+
chunk_count = max(1, (len(content) + FILE_CHUNK_BYTES - 1) // FILE_CHUNK_BYTES)
|
|
502
|
+
for index in range(chunk_count):
|
|
503
|
+
offset = index * FILE_CHUNK_BYTES
|
|
504
|
+
chunk = content[offset : offset + FILE_CHUNK_BYTES]
|
|
505
|
+
body: dict[str, Any] = {
|
|
506
|
+
"op": "write",
|
|
507
|
+
"path": path,
|
|
508
|
+
"offset": offset,
|
|
509
|
+
"content": base64.b64encode(chunk).decode(),
|
|
510
|
+
"truncate": index == 0,
|
|
511
|
+
}
|
|
512
|
+
if mode is not None:
|
|
513
|
+
body["mode"] = mode
|
|
514
|
+
payload = self._client.request(
|
|
515
|
+
"POST",
|
|
516
|
+
f"/run-cloud/sandboxes/{sandbox_id}/fs",
|
|
517
|
+
body,
|
|
518
|
+
)
|
|
519
|
+
if payload.get("error"):
|
|
520
|
+
raise RunCloudError(0, str(payload["error"]))
|
|
521
|
+
|
|
522
|
+
def pause(self) -> "Sandbox":
|
|
523
|
+
self._data = self._client.request(
|
|
524
|
+
"POST",
|
|
525
|
+
f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/pause",
|
|
526
|
+
{},
|
|
527
|
+
)
|
|
528
|
+
return self
|
|
529
|
+
|
|
530
|
+
def resume(self) -> "Sandbox":
|
|
531
|
+
self._data = self._client.request(
|
|
532
|
+
"POST",
|
|
533
|
+
f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/resume",
|
|
534
|
+
{},
|
|
535
|
+
)
|
|
536
|
+
return self
|
|
537
|
+
|
|
538
|
+
def set_timeout(self, seconds: int) -> "Sandbox":
|
|
539
|
+
self._data = self._client.request(
|
|
540
|
+
"POST",
|
|
541
|
+
f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/timeout",
|
|
542
|
+
{"timeoutSeconds": seconds},
|
|
543
|
+
)
|
|
544
|
+
return self
|
|
545
|
+
|
|
546
|
+
def destroy(self) -> None:
|
|
547
|
+
self._client.request(
|
|
548
|
+
"DELETE", f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}"
|
|
549
|
+
)
|