runcloud-sdk 0.7.4__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.
Files changed (20) hide show
  1. {runcloud_sdk-0.7.4/runcloud_sdk.egg-info → runcloud_sdk-0.8.0}/PKG-INFO +23 -1
  2. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/README.md +21 -0
  3. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/pyproject.toml +2 -2
  4. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/__init__.py +2 -0
  5. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/client.py +217 -3
  6. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/modal.py +18 -14
  7. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0/runcloud_sdk.egg-info}/PKG-INFO +23 -1
  8. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/SOURCES.txt +1 -0
  9. runcloud_sdk-0.8.0/runcloud_sdk.egg-info/requires.txt +1 -0
  10. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/tests/test_sdk.py +159 -2
  11. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/LICENSE +0 -0
  12. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/__init__.py +0 -0
  13. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/blaxel.py +0 -0
  14. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/daytona.py +0 -0
  15. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/e2b.py +0 -0
  16. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/sprites.py +0 -0
  17. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud/compat/vercel.py +0 -0
  18. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/dependency_links.txt +0 -0
  19. {runcloud_sdk-0.7.4 → runcloud_sdk-0.8.0}/runcloud_sdk.egg-info/top_level.txt +0 -0
  20. {runcloud_sdk-0.7.4 → 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.7.4
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.7.4"
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"
@@ -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",
@@ -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.7.4"
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 = f"{provider} compatibility does not support {feature} on run.cloud yet"
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,6 +278,7 @@ 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:
@@ -145,11 +304,43 @@ class Client:
145
304
  text = error.read().decode(errors="replace")
146
305
  try:
147
306
  parsed = json.loads(text)
148
- text = parsed.get("detail") or parsed.get("error") or parsed.get("message") or text
307
+ text = (
308
+ parsed.get("detail")
309
+ or parsed.get("error")
310
+ or parsed.get("message")
311
+ or text
312
+ )
149
313
  except Exception:
150
314
  pass
151
315
  raise RunCloudError(error.code, str(text)) from None
152
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
+
153
344
  def create(
154
345
  self,
155
346
  image: str = "runcloud/agent-base",
@@ -232,6 +423,29 @@ class Sandbox:
232
423
  str(payload.get("stderr", "")),
233
424
  )
234
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
+
235
449
  def read_file(self, path: str) -> bytes:
236
450
  payload = self._client.request(
237
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, result):
46
- self._result = result
47
- self.stdout = _Reader(result.stdout)
48
- self.stderr = _Reader(result.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
- def wait(self):
52
- return self.returncode
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
- class _Reader(io.StringIO):
59
- pass
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(self.native.exec(cmd, cwd=workdir, env=env, timeout=timeout))
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.7.4
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.
@@ -13,5 +13,6 @@ runcloud/compat/vercel.py
13
13
  runcloud_sdk.egg-info/PKG-INFO
14
14
  runcloud_sdk.egg-info/SOURCES.txt
15
15
  runcloud_sdk.egg-info/dependency_links.txt
16
+ runcloud_sdk.egg-info/requires.txt
16
17
  runcloud_sdk.egg-info/top_level.txt
17
18
  tests/test_sdk.py
@@ -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(api_key="rc_live_test", transport=self.recorder)
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,41 @@ 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.7.4")
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
+ )
108
265
 
109
266
  def test_content_type_is_only_sent_with_a_json_body(self):
110
267
  with patch(
File without changes
File without changes