runcloud-sdk 0.8.0__tar.gz → 0.8.2__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.8.0/runcloud_sdk.egg-info → runcloud_sdk-0.8.2}/PKG-INFO +1 -1
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/pyproject.toml +1 -1
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/client.py +83 -9
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2/runcloud_sdk.egg-info}/PKG-INFO +1 -1
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/tests/test_sdk.py +114 -3
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/LICENSE +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/README.md +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/__init__.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/__init__.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/blaxel.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/daytona.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/e2b.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/modal.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/sprites.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/vercel.py +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/SOURCES.txt +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/requires.txt +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/top_level.txt +0 -0
- {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/setup.cfg +0 -0
|
@@ -20,7 +20,8 @@ from websockets.sync.client import connect as websocket_connect
|
|
|
20
20
|
Transport = Callable[[str, str, Any], Any]
|
|
21
21
|
StreamTransport = Callable[[str, dict[str, Any]], Any]
|
|
22
22
|
DEFAULT_API_URL = "https://api.run.cloud"
|
|
23
|
-
USER_AGENT = "runcloud-python/0.8.
|
|
23
|
+
USER_AGENT = "runcloud-python/0.8.2"
|
|
24
|
+
FILE_CHUNK_BYTES = 512 * 1024
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class RunCloudError(Exception):
|
|
@@ -447,14 +448,87 @@ class Sandbox:
|
|
|
447
448
|
)
|
|
448
449
|
|
|
449
450
|
def read_file(self, path: str) -> bytes:
|
|
450
|
-
|
|
451
|
-
"
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
+
expected_next_offset = offset + len(chunk)
|
|
522
|
+
next_offset = payload.get("next_offset")
|
|
523
|
+
if chunk and (
|
|
524
|
+
isinstance(next_offset, bool)
|
|
525
|
+
or not isinstance(next_offset, int)
|
|
526
|
+
or next_offset != expected_next_offset
|
|
527
|
+
):
|
|
528
|
+
raise RunCloudError(
|
|
529
|
+
0,
|
|
530
|
+
"file write chunk was not acknowledged; the file may be incomplete",
|
|
531
|
+
)
|
|
458
532
|
|
|
459
533
|
def pause(self) -> "Sandbox":
|
|
460
534
|
self._data = self._client.request(
|
|
@@ -9,7 +9,7 @@ import unittest
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from unittest.mock import patch
|
|
11
11
|
|
|
12
|
-
from runcloud import Client, UnsupportedCompatibilityFeatureError
|
|
12
|
+
from runcloud import Client, RunCloudError, UnsupportedCompatibilityFeatureError
|
|
13
13
|
from runcloud.compat import modal
|
|
14
14
|
from runcloud.compat.blaxel import SandboxInstance, SyncSandboxInstance
|
|
15
15
|
from runcloud.compat.daytona import Daytona
|
|
@@ -30,6 +30,9 @@ class Recorder:
|
|
|
30
30
|
if path.endswith("/exec"):
|
|
31
31
|
return {"exit_code": 0, "stdout": "ok\n", "stderr": ""}
|
|
32
32
|
if path.endswith("/fs"):
|
|
33
|
+
if body["op"] == "write":
|
|
34
|
+
chunk = base64.b64decode(body["content"])
|
|
35
|
+
return {"next_offset": body["offset"] + len(chunk)}
|
|
33
36
|
return {"content": base64.b64encode(b"artifact").decode()}
|
|
34
37
|
if path == "/run-cloud/sandboxes" and method == "GET":
|
|
35
38
|
return {"items": [SANDBOX]}
|
|
@@ -111,6 +114,114 @@ class SDKTests(unittest.TestCase):
|
|
|
111
114
|
self.recorder.calls,
|
|
112
115
|
)
|
|
113
116
|
|
|
117
|
+
def test_exec_contract_defaults_legacy_true_and_preserves_false(self):
|
|
118
|
+
for frame, expected in [
|
|
119
|
+
({"stream": "exit"}, 0),
|
|
120
|
+
({"stream": "exit", "code": 1}, 1),
|
|
121
|
+
]:
|
|
122
|
+
with self.subTest(frame=frame):
|
|
123
|
+
def stream_transport(_path, _body, exit_frame=frame):
|
|
124
|
+
return iter([json.dumps(exit_frame)])
|
|
125
|
+
|
|
126
|
+
client = Client(
|
|
127
|
+
api_key="rc_live_test",
|
|
128
|
+
transport=self.recorder,
|
|
129
|
+
stream_transport=stream_transport,
|
|
130
|
+
)
|
|
131
|
+
process = client.get("sb_python").exec_stream(
|
|
132
|
+
"true" if expected == 0 else "false"
|
|
133
|
+
)
|
|
134
|
+
self.assertEqual(process.wait(), expected)
|
|
135
|
+
|
|
136
|
+
def test_read_file_downloads_all_chunks(self):
|
|
137
|
+
def transport(method, path, body):
|
|
138
|
+
if method == "GET":
|
|
139
|
+
return SANDBOX
|
|
140
|
+
self.assertEqual(method, "POST")
|
|
141
|
+
self.assertEqual(path, "/run-cloud/sandboxes/sb_python/fs")
|
|
142
|
+
if body["offset"] == 0:
|
|
143
|
+
return {
|
|
144
|
+
"content": base64.b64encode(b"first").decode(),
|
|
145
|
+
"next_offset": 5,
|
|
146
|
+
"size": 11,
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
"content": base64.b64encode(b"second").decode(),
|
|
150
|
+
"eof": True,
|
|
151
|
+
"next_offset": 11,
|
|
152
|
+
"size": 11,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
box = Client(api_key="rc_live_test", transport=transport).get("sb_python")
|
|
156
|
+
self.assertEqual(box.read_file("/tmp/chunked"), b"firstsecond")
|
|
157
|
+
|
|
158
|
+
def test_write_file_uploads_binary_content_in_chunks(self):
|
|
159
|
+
box = self.client.create()
|
|
160
|
+
self.recorder.calls.clear()
|
|
161
|
+
content = b"a" * (512 * 1024) + b"tail"
|
|
162
|
+
|
|
163
|
+
box.write_file("/tmp/input.bin", content, mode=0o600)
|
|
164
|
+
|
|
165
|
+
writes = [body for method, path, body in self.recorder.calls if path.endswith("/fs")]
|
|
166
|
+
self.assertEqual(len(writes), 2)
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
writes[0],
|
|
169
|
+
{
|
|
170
|
+
"op": "write",
|
|
171
|
+
"path": "/tmp/input.bin",
|
|
172
|
+
"offset": 0,
|
|
173
|
+
"content": base64.b64encode(content[: 512 * 1024]).decode(),
|
|
174
|
+
"truncate": True,
|
|
175
|
+
"mode": 0o600,
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
self.assertEqual(writes[1]["offset"], 512 * 1024)
|
|
179
|
+
self.assertEqual(base64.b64decode(writes[1]["content"]), b"tail")
|
|
180
|
+
self.assertFalse(writes[1]["truncate"])
|
|
181
|
+
|
|
182
|
+
def test_write_file_truncates_when_writing_an_empty_file(self):
|
|
183
|
+
box = self.client.create()
|
|
184
|
+
self.recorder.calls.clear()
|
|
185
|
+
|
|
186
|
+
box.write_file("/tmp/empty", b"")
|
|
187
|
+
|
|
188
|
+
self.assertEqual(
|
|
189
|
+
self.recorder.calls,
|
|
190
|
+
[
|
|
191
|
+
(
|
|
192
|
+
"POST",
|
|
193
|
+
"/run-cloud/sandboxes/sb_python/fs",
|
|
194
|
+
{
|
|
195
|
+
"op": "write",
|
|
196
|
+
"path": "/tmp/empty",
|
|
197
|
+
"offset": 0,
|
|
198
|
+
"content": "",
|
|
199
|
+
"truncate": True,
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def test_write_file_rejects_an_unacknowledged_continuation_chunk(self):
|
|
206
|
+
write_count = 0
|
|
207
|
+
|
|
208
|
+
def transport(method, path, body):
|
|
209
|
+
nonlocal write_count
|
|
210
|
+
if method == "GET":
|
|
211
|
+
return SANDBOX
|
|
212
|
+
self.assertEqual(path, "/run-cloud/sandboxes/sb_python/fs")
|
|
213
|
+
write_count += 1
|
|
214
|
+
if write_count == 1:
|
|
215
|
+
return {"next_offset": 512 * 1024}
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
box = Client(api_key="rc_live_test", transport=transport).get("sb_python")
|
|
219
|
+
|
|
220
|
+
with self.assertRaisesRegex(RunCloudError, "file write chunk was not acknowledged"):
|
|
221
|
+
box.write_file("/tmp/truncated.bin", b"a" * (512 * 1024) + b"tail")
|
|
222
|
+
|
|
223
|
+
self.assertEqual(write_count, 2)
|
|
224
|
+
|
|
114
225
|
def test_compatibility_adapters_share_the_public_client(self):
|
|
115
226
|
app = modal.App.lookup("compat", client=self.client)
|
|
116
227
|
modal_sandbox = modal.Sandbox.create(app=app)
|
|
@@ -227,7 +338,7 @@ class SDKTests(unittest.TestCase):
|
|
|
227
338
|
Client(api_key="rc_live_test").request("GET", "/run-cloud/account")
|
|
228
339
|
|
|
229
340
|
request = urlopen.call_args.args[0]
|
|
230
|
-
self.assertEqual(request.get_header("User-agent"), "runcloud-python/0.8.
|
|
341
|
+
self.assertEqual(request.get_header("User-agent"), "runcloud-python/0.8.2")
|
|
231
342
|
|
|
232
343
|
def test_exec_stream_opens_authenticated_websocket_and_sends_command(self):
|
|
233
344
|
session = WebSocketSession()
|
|
@@ -257,7 +368,7 @@ class SDKTests(unittest.TestCase):
|
|
|
257
368
|
connect.assert_called_once_with(
|
|
258
369
|
"wss://api.example.test/run-cloud/sandboxes/sb_1/exec/stream?mode=command",
|
|
259
370
|
additional_headers={"Authorization": "Bearer rc_live_test"},
|
|
260
|
-
user_agent_header="runcloud-python/0.8.
|
|
371
|
+
user_agent_header="runcloud-python/0.8.2",
|
|
261
372
|
open_timeout=60.0,
|
|
262
373
|
close_timeout=5,
|
|
263
374
|
max_size=8 << 20,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|