runcloud-sdk 0.7.3__tar.gz → 0.8.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.
- {runcloud_sdk-0.7.3/runcloud_sdk.egg-info → runcloud_sdk-0.8.0}/PKG-INFO +23 -1
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/README.md +21 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/pyproject.toml +2 -2
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/__init__.py +2 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/client.py +224 -8
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/modal.py +18 -14
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0/runcloud_sdk.egg-info}/PKG-INFO +23 -1
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/SOURCES.txt +1 -0
- runcloud_sdk-0.8.0/runcloud_sdk.egg-info/requires.txt +1 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/tests/test_sdk.py +172 -2
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/LICENSE +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/__init__.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/blaxel.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/daytona.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/e2b.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/sprites.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud/compat/vercel.py +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/top_level.txt +0 -0
- {runcloud_sdk-0.7.3 → runcloud_sdk-0.8.0}/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.0
|
|
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.0"
|
|
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"
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import codecs
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
9
|
+
import threading
|
|
8
10
|
import time
|
|
9
11
|
import urllib.error
|
|
10
12
|
import urllib.parse
|
|
@@ -13,9 +15,12 @@ from dataclasses import dataclass
|
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from typing import Any, Callable, Optional, Union
|
|
15
17
|
|
|
18
|
+
from websockets.sync.client import connect as websocket_connect
|
|
19
|
+
|
|
16
20
|
Transport = Callable[[str, str, Any], Any]
|
|
21
|
+
StreamTransport = Callable[[str, dict[str, Any]], Any]
|
|
17
22
|
DEFAULT_API_URL = "https://api.run.cloud"
|
|
18
|
-
USER_AGENT = "runcloud-python/0.
|
|
23
|
+
USER_AGENT = "runcloud-python/0.8.0"
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
class RunCloudError(Exception):
|
|
@@ -27,7 +32,9 @@ class RunCloudError(Exception):
|
|
|
27
32
|
|
|
28
33
|
class UnsupportedCompatibilityFeatureError(Exception):
|
|
29
34
|
def __init__(self, provider: str, feature: str, alternative: str = ""):
|
|
30
|
-
message =
|
|
35
|
+
message = (
|
|
36
|
+
f"{provider} compatibility does not support {feature} on run.cloud yet"
|
|
37
|
+
)
|
|
31
38
|
if alternative:
|
|
32
39
|
message += f"; {alternative}"
|
|
33
40
|
super().__init__(message)
|
|
@@ -42,6 +49,156 @@ class ExecResult:
|
|
|
42
49
|
stderr: str
|
|
43
50
|
|
|
44
51
|
|
|
52
|
+
class _StreamReader:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self._condition = threading.Condition()
|
|
55
|
+
self._buffer = ""
|
|
56
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
57
|
+
self._eof = False
|
|
58
|
+
self._error: Optional[Exception] = None
|
|
59
|
+
|
|
60
|
+
def _feed(self, data: bytes) -> None:
|
|
61
|
+
text = self._decoder.decode(data)
|
|
62
|
+
if not text:
|
|
63
|
+
return
|
|
64
|
+
with self._condition:
|
|
65
|
+
self._buffer += text
|
|
66
|
+
self._condition.notify_all()
|
|
67
|
+
|
|
68
|
+
def _finish(self, error: Optional[Exception] = None) -> None:
|
|
69
|
+
with self._condition:
|
|
70
|
+
self._buffer += self._decoder.decode(b"", final=True)
|
|
71
|
+
self._error = error
|
|
72
|
+
self._eof = True
|
|
73
|
+
self._condition.notify_all()
|
|
74
|
+
|
|
75
|
+
def _raise_error_if_empty(self) -> None:
|
|
76
|
+
if not self._buffer and self._error is not None:
|
|
77
|
+
raise self._error
|
|
78
|
+
|
|
79
|
+
def read(self, size: int = -1) -> str:
|
|
80
|
+
if size == 0:
|
|
81
|
+
return ""
|
|
82
|
+
with self._condition:
|
|
83
|
+
if size < 0:
|
|
84
|
+
while not self._eof:
|
|
85
|
+
self._condition.wait()
|
|
86
|
+
self._raise_error_if_empty()
|
|
87
|
+
value, self._buffer = self._buffer, ""
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
while len(self._buffer) < size and not self._eof:
|
|
91
|
+
self._condition.wait()
|
|
92
|
+
self._raise_error_if_empty()
|
|
93
|
+
value, self._buffer = self._buffer[:size], self._buffer[size:]
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
def readline(self, size: int = -1) -> str:
|
|
97
|
+
if size == 0:
|
|
98
|
+
return ""
|
|
99
|
+
with self._condition:
|
|
100
|
+
while True:
|
|
101
|
+
newline = self._buffer.find("\n")
|
|
102
|
+
if newline >= 0:
|
|
103
|
+
end = newline + 1
|
|
104
|
+
break
|
|
105
|
+
if size >= 0 and len(self._buffer) >= size:
|
|
106
|
+
end = size
|
|
107
|
+
break
|
|
108
|
+
if self._eof:
|
|
109
|
+
self._raise_error_if_empty()
|
|
110
|
+
end = len(self._buffer)
|
|
111
|
+
break
|
|
112
|
+
self._condition.wait()
|
|
113
|
+
|
|
114
|
+
if size >= 0:
|
|
115
|
+
end = min(end, size)
|
|
116
|
+
value, self._buffer = self._buffer[:end], self._buffer[end:]
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
def __iter__(self):
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __next__(self) -> str:
|
|
123
|
+
line = self.readline()
|
|
124
|
+
if line == "":
|
|
125
|
+
raise StopIteration
|
|
126
|
+
return line
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ExecProcess:
|
|
130
|
+
def __init__(self, session: Any):
|
|
131
|
+
self.stdout = _StreamReader()
|
|
132
|
+
self.stderr = _StreamReader()
|
|
133
|
+
self._session = session
|
|
134
|
+
self._returncode: Optional[int] = None
|
|
135
|
+
self._error: Optional[Exception] = None
|
|
136
|
+
self._finished = threading.Event()
|
|
137
|
+
self._reader_thread = threading.Thread(
|
|
138
|
+
target=self._read_frames,
|
|
139
|
+
name="runcloud-exec-stream",
|
|
140
|
+
daemon=True,
|
|
141
|
+
)
|
|
142
|
+
self._reader_thread.start()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def returncode(self) -> Optional[int]:
|
|
146
|
+
return self._returncode
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def exit_code(self) -> int:
|
|
150
|
+
return self.wait()
|
|
151
|
+
|
|
152
|
+
def poll(self) -> Optional[int]:
|
|
153
|
+
if not self._finished.is_set():
|
|
154
|
+
return None
|
|
155
|
+
if self._error is not None:
|
|
156
|
+
raise self._error
|
|
157
|
+
return self._returncode
|
|
158
|
+
|
|
159
|
+
def wait(self, timeout: Optional[float] = None) -> int:
|
|
160
|
+
if not self._finished.wait(timeout):
|
|
161
|
+
raise TimeoutError("run.cloud process did not exit before the wait timeout")
|
|
162
|
+
if self._error is not None:
|
|
163
|
+
raise self._error
|
|
164
|
+
return self._returncode if self._returncode is not None else 0
|
|
165
|
+
|
|
166
|
+
def _read_frames(self) -> None:
|
|
167
|
+
error: Optional[Exception] = None
|
|
168
|
+
try:
|
|
169
|
+
for raw in self._session:
|
|
170
|
+
if isinstance(raw, bytes):
|
|
171
|
+
raw = raw.decode("utf-8")
|
|
172
|
+
frame = json.loads(raw)
|
|
173
|
+
stream = frame.get("stream")
|
|
174
|
+
if stream in {"stdout", "stderr"}:
|
|
175
|
+
encoded = frame.get("data")
|
|
176
|
+
if not isinstance(encoded, str):
|
|
177
|
+
continue
|
|
178
|
+
data = base64.b64decode(encoded, validate=True)
|
|
179
|
+
target = self.stderr if stream == "stderr" else self.stdout
|
|
180
|
+
target._feed(data)
|
|
181
|
+
elif stream == "exit":
|
|
182
|
+
code = frame.get("code", frame.get("exit_code", 0))
|
|
183
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
184
|
+
raise ValueError("exec stream returned an invalid exit code")
|
|
185
|
+
self._returncode = code
|
|
186
|
+
break
|
|
187
|
+
if self._returncode is None:
|
|
188
|
+
raise RuntimeError("exec stream closed before reporting an exit code")
|
|
189
|
+
except Exception as caught:
|
|
190
|
+
error = RunCloudError(0, f"exec stream failed: {caught}")
|
|
191
|
+
self._error = error
|
|
192
|
+
finally:
|
|
193
|
+
try:
|
|
194
|
+
self._session.close()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
self.stdout._finish(error)
|
|
198
|
+
self.stderr._finish(error)
|
|
199
|
+
self._finished.set()
|
|
200
|
+
|
|
201
|
+
|
|
45
202
|
@dataclass(frozen=True)
|
|
46
203
|
class _SavedCredentials:
|
|
47
204
|
api_key: str
|
|
@@ -90,6 +247,7 @@ class Client:
|
|
|
90
247
|
api_url: Optional[str] = None,
|
|
91
248
|
timeout: float = 60.0,
|
|
92
249
|
transport: Optional[Transport] = None,
|
|
250
|
+
stream_transport: Optional[StreamTransport] = None,
|
|
93
251
|
):
|
|
94
252
|
explicit_api_key = _nonempty_string(api_key)
|
|
95
253
|
environment_api_key = _nonempty_string(
|
|
@@ -120,20 +278,23 @@ class Client:
|
|
|
120
278
|
).rstrip("/")
|
|
121
279
|
self.timeout = timeout
|
|
122
280
|
self._transport = transport
|
|
281
|
+
self._stream_transport = stream_transport
|
|
123
282
|
|
|
124
283
|
def request(self, method: str, path: str, body: Any = None) -> Any:
|
|
125
284
|
if self._transport is not None:
|
|
126
285
|
return self._transport(method, path, body)
|
|
127
286
|
data = json.dumps(body).encode() if body is not None else None
|
|
287
|
+
headers = {
|
|
288
|
+
"Authorization": "Bearer " + self.api_key,
|
|
289
|
+
"User-Agent": USER_AGENT,
|
|
290
|
+
}
|
|
291
|
+
if body is not None:
|
|
292
|
+
headers["Content-Type"] = "application/json"
|
|
128
293
|
request = urllib.request.Request(
|
|
129
294
|
self.api_url + path,
|
|
130
295
|
data=data,
|
|
131
296
|
method=method,
|
|
132
|
-
headers=
|
|
133
|
-
"Authorization": "Bearer " + self.api_key,
|
|
134
|
-
"Content-Type": "application/json",
|
|
135
|
-
"User-Agent": USER_AGENT,
|
|
136
|
-
},
|
|
297
|
+
headers=headers,
|
|
137
298
|
)
|
|
138
299
|
try:
|
|
139
300
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
@@ -143,11 +304,43 @@ class Client:
|
|
|
143
304
|
text = error.read().decode(errors="replace")
|
|
144
305
|
try:
|
|
145
306
|
parsed = json.loads(text)
|
|
146
|
-
text =
|
|
307
|
+
text = (
|
|
308
|
+
parsed.get("detail")
|
|
309
|
+
or parsed.get("error")
|
|
310
|
+
or parsed.get("message")
|
|
311
|
+
or text
|
|
312
|
+
)
|
|
147
313
|
except Exception:
|
|
148
314
|
pass
|
|
149
315
|
raise RunCloudError(error.code, str(text)) from None
|
|
150
316
|
|
|
317
|
+
def open_exec_stream(self, path: str, body: dict[str, Any]) -> ExecProcess:
|
|
318
|
+
if self._stream_transport is not None:
|
|
319
|
+
return ExecProcess(self._stream_transport(path, body))
|
|
320
|
+
|
|
321
|
+
http_url = self.api_url + path
|
|
322
|
+
if http_url.startswith("https://"):
|
|
323
|
+
websocket_url = "wss://" + http_url.removeprefix("https://")
|
|
324
|
+
elif http_url.startswith("http://"):
|
|
325
|
+
websocket_url = "ws://" + http_url.removeprefix("http://")
|
|
326
|
+
else:
|
|
327
|
+
raise ValueError("run.cloud API URL must use http or https")
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
session = websocket_connect(
|
|
331
|
+
websocket_url,
|
|
332
|
+
additional_headers={"Authorization": "Bearer " + self.api_key},
|
|
333
|
+
user_agent_header=USER_AGENT,
|
|
334
|
+
open_timeout=self.timeout,
|
|
335
|
+
close_timeout=5,
|
|
336
|
+
max_size=8 << 20,
|
|
337
|
+
)
|
|
338
|
+
session.send(json.dumps(body))
|
|
339
|
+
session.send(json.dumps({"stream": "stdin_close"}))
|
|
340
|
+
except Exception as error:
|
|
341
|
+
raise RunCloudError(0, f"could not open exec stream: {error}") from None
|
|
342
|
+
return ExecProcess(session)
|
|
343
|
+
|
|
151
344
|
def create(
|
|
152
345
|
self,
|
|
153
346
|
image: str = "runcloud/agent-base",
|
|
@@ -230,6 +423,29 @@ class Sandbox:
|
|
|
230
423
|
str(payload.get("stderr", "")),
|
|
231
424
|
)
|
|
232
425
|
|
|
426
|
+
def exec_stream(
|
|
427
|
+
self,
|
|
428
|
+
command: Union[str, list[str]],
|
|
429
|
+
cwd: Optional[str] = None,
|
|
430
|
+
env: Optional[dict[str, str]] = None,
|
|
431
|
+
timeout: Optional[int] = None,
|
|
432
|
+
) -> ExecProcess:
|
|
433
|
+
argv = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
|
434
|
+
if not argv:
|
|
435
|
+
raise ValueError("command must not be empty")
|
|
436
|
+
body: dict[str, Any] = {"cmd": argv}
|
|
437
|
+
if cwd:
|
|
438
|
+
body["cwd"] = cwd
|
|
439
|
+
if env:
|
|
440
|
+
body["env"] = env
|
|
441
|
+
if timeout is not None:
|
|
442
|
+
body["timeout_seconds"] = timeout
|
|
443
|
+
sandbox_id = urllib.parse.quote(self.id, safe="")
|
|
444
|
+
return self._client.open_exec_stream(
|
|
445
|
+
f"/run-cloud/sandboxes/{sandbox_id}/exec/stream?mode=command",
|
|
446
|
+
body,
|
|
447
|
+
)
|
|
448
|
+
|
|
233
449
|
def read_file(self, path: str) -> bytes:
|
|
234
450
|
payload = self._client.request(
|
|
235
451
|
"POST",
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import io
|
|
6
5
|
from typing import Optional
|
|
7
6
|
|
|
8
7
|
from runcloud.client import (
|
|
9
8
|
Client,
|
|
9
|
+
ExecProcess,
|
|
10
10
|
UnsupportedCompatibilityFeatureError,
|
|
11
11
|
)
|
|
12
12
|
from runcloud.client import (
|
|
@@ -42,21 +42,20 @@ class Image:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class Process:
|
|
45
|
-
def __init__(self,
|
|
46
|
-
self.
|
|
47
|
-
self.stdout =
|
|
48
|
-
self.stderr =
|
|
49
|
-
self.returncode = result.exit_code
|
|
45
|
+
def __init__(self, native: ExecProcess):
|
|
46
|
+
self._native = native
|
|
47
|
+
self.stdout = native.stdout
|
|
48
|
+
self.stderr = native.stderr
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def poll(self):
|
|
55
|
-
return self.returncode
|
|
50
|
+
@property
|
|
51
|
+
def returncode(self):
|
|
52
|
+
return self._native.returncode
|
|
56
53
|
|
|
54
|
+
def wait(self, timeout=None):
|
|
55
|
+
return self._native.wait(timeout)
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
def poll(self):
|
|
58
|
+
return self._native.poll()
|
|
60
59
|
|
|
61
60
|
|
|
62
61
|
class App:
|
|
@@ -145,11 +144,16 @@ class Sandbox:
|
|
|
145
144
|
|
|
146
145
|
def exec(self, *args, timeout=None, workdir=None, env=None, **_ignored):
|
|
147
146
|
cmd = list(args) if len(args) > 1 else args[0]
|
|
148
|
-
return Process(
|
|
147
|
+
return Process(
|
|
148
|
+
self.native.exec_stream(cmd, cwd=workdir, env=env, timeout=timeout)
|
|
149
|
+
)
|
|
149
150
|
|
|
150
151
|
def terminate(self, wait=True):
|
|
151
152
|
self.native.destroy()
|
|
152
153
|
|
|
154
|
+
def detach(self):
|
|
155
|
+
return None
|
|
156
|
+
|
|
153
157
|
def poll(self):
|
|
154
158
|
self.native.refresh()
|
|
155
159
|
return None if self.native.state in {"running", "starting", "pending"} else 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runcloud-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets<16,>=13
|
|
@@ -3,6 +3,7 @@ import base64
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import tempfile
|
|
6
|
+
import threading
|
|
6
7
|
import time
|
|
7
8
|
import unittest
|
|
8
9
|
from pathlib import Path
|
|
@@ -22,6 +23,7 @@ SANDBOX = {"id": "sb_python", "name": "compat", "state": "running"}
|
|
|
22
23
|
class Recorder:
|
|
23
24
|
def __init__(self):
|
|
24
25
|
self.calls = []
|
|
26
|
+
self.stream_calls = []
|
|
25
27
|
|
|
26
28
|
def __call__(self, method, path, body):
|
|
27
29
|
self.calls.append((method, path, body))
|
|
@@ -33,6 +35,20 @@ class Recorder:
|
|
|
33
35
|
return {"items": [SANDBOX]}
|
|
34
36
|
return SANDBOX
|
|
35
37
|
|
|
38
|
+
def stream(self, path, body):
|
|
39
|
+
self.stream_calls.append((path, body))
|
|
40
|
+
|
|
41
|
+
def frames():
|
|
42
|
+
yield json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"stream": "stdout",
|
|
45
|
+
"data": base64.b64encode(b"ok\n").decode(),
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
yield json.dumps({"stream": "exit", "code": 0})
|
|
49
|
+
|
|
50
|
+
return frames()
|
|
51
|
+
|
|
36
52
|
|
|
37
53
|
class Response:
|
|
38
54
|
def __enter__(self):
|
|
@@ -45,10 +61,35 @@ class Response:
|
|
|
45
61
|
return b"{}"
|
|
46
62
|
|
|
47
63
|
|
|
64
|
+
class WebSocketSession:
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.sent = []
|
|
67
|
+
self.closed = False
|
|
68
|
+
|
|
69
|
+
def send(self, value):
|
|
70
|
+
self.sent.append(value)
|
|
71
|
+
|
|
72
|
+
def close(self):
|
|
73
|
+
self.closed = True
|
|
74
|
+
|
|
75
|
+
def __iter__(self):
|
|
76
|
+
yield json.dumps(
|
|
77
|
+
{
|
|
78
|
+
"stream": "stdout",
|
|
79
|
+
"data": base64.b64encode(b"live\n").decode(),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
yield json.dumps({"stream": "exit", "code": 0})
|
|
83
|
+
|
|
84
|
+
|
|
48
85
|
class SDKTests(unittest.TestCase):
|
|
49
86
|
def setUp(self):
|
|
50
87
|
self.recorder = Recorder()
|
|
51
|
-
self.client = Client(
|
|
88
|
+
self.client = Client(
|
|
89
|
+
api_key="rc_live_test",
|
|
90
|
+
transport=self.recorder,
|
|
91
|
+
stream_transport=self.recorder.stream,
|
|
92
|
+
)
|
|
52
93
|
|
|
53
94
|
def test_native_routes(self):
|
|
54
95
|
box = self.client.create(cpu=1.5, memory=2048)
|
|
@@ -78,6 +119,21 @@ class SDKTests(unittest.TestCase):
|
|
|
78
119
|
self.assertEqual(read_process.wait(), 0)
|
|
79
120
|
iter_process = modal_sandbox.exec("echo ok")
|
|
80
121
|
self.assertEqual(list(iter_process.stdout), ["ok\n"])
|
|
122
|
+
self.assertEqual(iter_process.wait(), 0)
|
|
123
|
+
self.assertIsNone(modal_sandbox.detach())
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
self.recorder.stream_calls,
|
|
126
|
+
[
|
|
127
|
+
(
|
|
128
|
+
"/run-cloud/sandboxes/sb_python/exec/stream?mode=command",
|
|
129
|
+
{"cmd": ["/bin/sh", "-c", "echo ok"]},
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
"/run-cloud/sandboxes/sb_python/exec/stream?mode=command",
|
|
133
|
+
{"cmd": ["/bin/sh", "-c", "echo ok"]},
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
)
|
|
81
137
|
self.assertEqual(
|
|
82
138
|
E2BSandbox.create(client=self.client).commands.run("echo ok").exit_code, 0
|
|
83
139
|
)
|
|
@@ -92,6 +148,73 @@ class SDKTests(unittest.TestCase):
|
|
|
92
148
|
asyncio.run(async_blaxel.delete())
|
|
93
149
|
self.assertTrue(SpritesClient(client=self.client).create_sprite("compat"))
|
|
94
150
|
|
|
151
|
+
def test_modal_process_delivers_output_before_exit(self):
|
|
152
|
+
first_frame_sent = threading.Event()
|
|
153
|
+
release_exit = threading.Event()
|
|
154
|
+
|
|
155
|
+
def stream_transport(path, body):
|
|
156
|
+
self.assertEqual(
|
|
157
|
+
path,
|
|
158
|
+
"/run-cloud/sandboxes/sb_python/exec/stream?mode=command",
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(
|
|
161
|
+
body,
|
|
162
|
+
{
|
|
163
|
+
"cmd": ["bash", "-c", "run"],
|
|
164
|
+
"cwd": "/workspace",
|
|
165
|
+
"env": {"CI": "true"},
|
|
166
|
+
"timeout_seconds": 30,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def frames():
|
|
171
|
+
yield json.dumps(
|
|
172
|
+
{
|
|
173
|
+
"stream": "stdout",
|
|
174
|
+
"data": base64.b64encode(b"first\n").decode(),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
first_frame_sent.set()
|
|
178
|
+
release_exit.wait(2)
|
|
179
|
+
yield json.dumps(
|
|
180
|
+
{
|
|
181
|
+
"stream": "stderr",
|
|
182
|
+
"data": base64.b64encode(b"warning\n").decode(),
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
yield json.dumps(
|
|
186
|
+
{
|
|
187
|
+
"stream": "stdout",
|
|
188
|
+
"data": base64.b64encode(b"second\n").decode(),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
yield json.dumps({"stream": "exit", "code": 7})
|
|
192
|
+
|
|
193
|
+
return frames()
|
|
194
|
+
|
|
195
|
+
client = Client(
|
|
196
|
+
api_key="rc_live_test",
|
|
197
|
+
transport=self.recorder,
|
|
198
|
+
stream_transport=stream_transport,
|
|
199
|
+
)
|
|
200
|
+
process = modal.Sandbox(client.get("sb_python")).exec(
|
|
201
|
+
"bash",
|
|
202
|
+
"-c",
|
|
203
|
+
"run",
|
|
204
|
+
workdir="/workspace",
|
|
205
|
+
env={"CI": "true"},
|
|
206
|
+
timeout=30,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self.assertTrue(first_frame_sent.wait(1))
|
|
210
|
+
self.assertIsNone(process.poll())
|
|
211
|
+
self.assertEqual(process.stdout.readline(), "first\n")
|
|
212
|
+
release_exit.set()
|
|
213
|
+
self.assertEqual(list(process.stdout), ["second\n"])
|
|
214
|
+
self.assertEqual(process.stderr.read(), "warning\n")
|
|
215
|
+
self.assertEqual(process.wait(), 7)
|
|
216
|
+
self.assertEqual(process.poll(), 7)
|
|
217
|
+
|
|
95
218
|
def test_unsupported_features_fail_loudly(self):
|
|
96
219
|
with self.assertRaises(UnsupportedCompatibilityFeatureError):
|
|
97
220
|
modal.Image.debian_slim().pip_install("requests")
|
|
@@ -104,7 +227,54 @@ class SDKTests(unittest.TestCase):
|
|
|
104
227
|
Client(api_key="rc_live_test").request("GET", "/run-cloud/account")
|
|
105
228
|
|
|
106
229
|
request = urlopen.call_args.args[0]
|
|
107
|
-
self.assertEqual(request.get_header("User-agent"), "runcloud-python/0.
|
|
230
|
+
self.assertEqual(request.get_header("User-agent"), "runcloud-python/0.8.0")
|
|
231
|
+
|
|
232
|
+
def test_exec_stream_opens_authenticated_websocket_and_sends_command(self):
|
|
233
|
+
session = WebSocketSession()
|
|
234
|
+
with patch(
|
|
235
|
+
"runcloud.client.websocket_connect",
|
|
236
|
+
return_value=session,
|
|
237
|
+
) as connect:
|
|
238
|
+
client = Client(
|
|
239
|
+
api_key="rc_live_test",
|
|
240
|
+
api_url="https://api.example.test",
|
|
241
|
+
)
|
|
242
|
+
process = client.open_exec_stream(
|
|
243
|
+
"/run-cloud/sandboxes/sb_1/exec/stream?mode=command",
|
|
244
|
+
{"cmd": ["echo", "live"]},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self.assertEqual(process.stdout.read(), "live\n")
|
|
248
|
+
self.assertEqual(process.wait(), 0)
|
|
249
|
+
self.assertTrue(session.closed)
|
|
250
|
+
self.assertEqual(
|
|
251
|
+
session.sent,
|
|
252
|
+
[
|
|
253
|
+
'{"cmd": ["echo", "live"]}',
|
|
254
|
+
'{"stream": "stdin_close"}',
|
|
255
|
+
],
|
|
256
|
+
)
|
|
257
|
+
connect.assert_called_once_with(
|
|
258
|
+
"wss://api.example.test/run-cloud/sandboxes/sb_1/exec/stream?mode=command",
|
|
259
|
+
additional_headers={"Authorization": "Bearer rc_live_test"},
|
|
260
|
+
user_agent_header="runcloud-python/0.8.0",
|
|
261
|
+
open_timeout=60.0,
|
|
262
|
+
close_timeout=5,
|
|
263
|
+
max_size=8 << 20,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def test_content_type_is_only_sent_with_a_json_body(self):
|
|
267
|
+
with patch(
|
|
268
|
+
"runcloud.client.urllib.request.urlopen",
|
|
269
|
+
return_value=Response(),
|
|
270
|
+
) as urlopen:
|
|
271
|
+
client = Client(api_key="rc_live_test")
|
|
272
|
+
client.request("DELETE", "/run-cloud/sandboxes/sbx_test")
|
|
273
|
+
client.request("POST", "/run-cloud/sandboxes", {})
|
|
274
|
+
|
|
275
|
+
delete_request, post_request = (call.args[0] for call in urlopen.call_args_list)
|
|
276
|
+
self.assertIsNone(delete_request.get_header("Content-type"))
|
|
277
|
+
self.assertEqual(post_request.get_header("Content-type"), "application/json")
|
|
108
278
|
|
|
109
279
|
|
|
110
280
|
class CredentialTests(unittest.TestCase):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|