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.
Files changed (22) hide show
  1. {runcloud_sdk-0.7.4/runcloud_sdk.egg-info → runcloud_sdk-0.8.1}/PKG-INFO +23 -1
  2. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/README.md +21 -0
  3. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/pyproject.toml +2 -2
  4. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/__init__.py +2 -0
  5. runcloud_sdk-0.8.1/runcloud/client.py +549 -0
  6. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/modal.py +18 -14
  7. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1/runcloud_sdk.egg-info}/PKG-INFO +23 -1
  8. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/SOURCES.txt +1 -0
  9. runcloud_sdk-0.8.1/runcloud_sdk.egg-info/requires.txt +1 -0
  10. runcloud_sdk-0.8.1/tests/test_sdk.py +461 -0
  11. runcloud_sdk-0.7.4/runcloud/client.py +0 -272
  12. runcloud_sdk-0.7.4/tests/test_sdk.py +0 -216
  13. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/LICENSE +0 -0
  14. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/__init__.py +0 -0
  15. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/blaxel.py +0 -0
  16. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/daytona.py +0 -0
  17. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/e2b.py +0 -0
  18. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/sprites.py +0 -0
  19. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud/compat/vercel.py +0 -0
  20. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
  21. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.1}/runcloud_sdk.egg-info/top_level.txt +0 -0
  22. {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.7.4
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.7.4"
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"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from .client import (
4
4
  Client,
5
+ ExecProcess,
5
6
  ExecResult,
6
7
  RunCloudError,
7
8
  Sandbox,
@@ -10,6 +11,7 @@ from .client import (
10
11
 
11
12
  __all__ = [
12
13
  "Client",
14
+ "ExecProcess",
13
15
  "ExecResult",
14
16
  "RunCloudError",
15
17
  "Sandbox",
@@ -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
+ )