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.
Files changed (20) hide show
  1. {runcloud_sdk-0.8.0/runcloud_sdk.egg-info → runcloud_sdk-0.8.2}/PKG-INFO +1 -1
  2. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/pyproject.toml +1 -1
  3. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/client.py +83 -9
  4. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2/runcloud_sdk.egg-info}/PKG-INFO +1 -1
  5. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/tests/test_sdk.py +114 -3
  6. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/LICENSE +0 -0
  7. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/README.md +0 -0
  8. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/__init__.py +0 -0
  9. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/__init__.py +0 -0
  10. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/blaxel.py +0 -0
  11. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/daytona.py +0 -0
  12. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/e2b.py +0 -0
  13. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/modal.py +0 -0
  14. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/sprites.py +0 -0
  15. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud/compat/vercel.py +0 -0
  16. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/SOURCES.txt +0 -0
  17. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
  18. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/requires.txt +0 -0
  19. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/runcloud_sdk.egg-info/top_level.txt +0 -0
  20. {runcloud_sdk-0.8.0 → runcloud_sdk-0.8.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runcloud-sdk
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
5
  Author: Newly
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runcloud-sdk"
3
- version = "0.8.0"
3
+ version = "0.8.2"
4
4
  description = "Python SDK for run.cloud, including sandbox-provider compatibility adapters"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -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.0"
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
- payload = self._client.request(
451
- "POST",
452
- f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/fs",
453
- {"op": "read", "path": path},
454
- )
455
- if payload.get("error"):
456
- raise RunCloudError(0, str(payload["error"]))
457
- return base64.b64decode(payload.get("content") or "")
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runcloud-sdk
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
5
  Author: Newly
6
6
  License-Expression: Apache-2.0
@@ -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.0")
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.0",
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