withruntime 0.2.0__tar.gz → 0.3.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 (44) hide show
  1. withruntime-0.3.0/PKG-INFO +61 -0
  2. withruntime-0.3.0/README.md +54 -0
  3. withruntime-0.3.0/pyproject.toml +15 -0
  4. withruntime-0.3.0/tests/test_client.py +344 -0
  5. withruntime-0.3.0/tests/test_images_volumes_interpreter.py +149 -0
  6. withruntime-0.3.0/tests/test_previews_network_desktop.py +108 -0
  7. withruntime-0.3.0/tests/test_referrals.py +58 -0
  8. withruntime-0.3.0/withruntime/__init__.py +50 -0
  9. withruntime-0.3.0/withruntime/_async_client.py +826 -0
  10. withruntime-0.3.0/withruntime/_async_products/__init__.py +34 -0
  11. withruntime-0.3.0/withruntime/_async_products/desktop.py +101 -0
  12. withruntime-0.3.0/withruntime/_async_products/images.py +119 -0
  13. withruntime-0.3.0/withruntime/_async_products/interpreter.py +97 -0
  14. withruntime-0.3.0/withruntime/_async_products/network.py +41 -0
  15. withruntime-0.3.0/withruntime/_async_products/previews.py +47 -0
  16. withruntime-0.3.0/withruntime/_async_products/referrals.py +19 -0
  17. withruntime-0.3.0/withruntime/_async_products/volumes.py +43 -0
  18. withruntime-0.3.0/withruntime/_clock.py +76 -0
  19. withruntime-0.3.0/withruntime/_connection.py +53 -0
  20. withruntime-0.3.0/withruntime/_errors.py +88 -0
  21. withruntime-0.3.0/withruntime/_http.py +295 -0
  22. withruntime-0.3.0/withruntime/_sync_client.py +825 -0
  23. withruntime-0.3.0/withruntime/_sync_products/__init__.py +20 -0
  24. withruntime-0.3.0/withruntime/_sync_products/desktop.py +102 -0
  25. withruntime-0.3.0/withruntime/_sync_products/images.py +120 -0
  26. withruntime-0.3.0/withruntime/_sync_products/interpreter.py +98 -0
  27. withruntime-0.3.0/withruntime/_sync_products/network.py +42 -0
  28. withruntime-0.3.0/withruntime/_sync_products/previews.py +48 -0
  29. withruntime-0.3.0/withruntime/_sync_products/referrals.py +20 -0
  30. withruntime-0.3.0/withruntime/_sync_products/volumes.py +44 -0
  31. withruntime-0.3.0/withruntime/_version.py +1 -0
  32. withruntime-0.3.0/withruntime/_ws.py +161 -0
  33. withruntime-0.3.0/withruntime.egg-info/PKG-INFO +61 -0
  34. withruntime-0.3.0/withruntime.egg-info/SOURCES.txt +35 -0
  35. withruntime-0.2.0/PKG-INFO +0 -24
  36. withruntime-0.2.0/README.md +0 -13
  37. withruntime-0.2.0/pyproject.toml +0 -19
  38. withruntime-0.2.0/withruntime/__init__.py +0 -8
  39. withruntime-0.2.0/withruntime.egg-info/PKG-INFO +0 -24
  40. withruntime-0.2.0/withruntime.egg-info/SOURCES.txt +0 -8
  41. withruntime-0.2.0/withruntime.egg-info/requires.txt +0 -1
  42. {withruntime-0.2.0 → withruntime-0.3.0}/setup.cfg +0 -0
  43. {withruntime-0.2.0 → withruntime-0.3.0}/withruntime.egg-info/dependency_links.txt +0 -0
  44. {withruntime-0.2.0 → withruntime-0.3.0}/withruntime.egg-info/top_level.txt +0 -0
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: withruntime
3
+ Version: 0.3.0
4
+ Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Runtime Cloud Python SDK
9
+
10
+ One client for every Runtime Cloud product, sync and async. Python 3.10 or
11
+ later, standard library only.
12
+
13
+ ```bash no-run
14
+ pip install withruntime
15
+ ```
16
+
17
+ The client uses `RUNTIME_API_KEY` when it is set, and otherwise the connection
18
+ this machine saved when `npx withruntime login` connected it (one browser
19
+ approval, no key to copy). On a server, set `RUNTIME_API_KEY` from your secret
20
+ manager (create a key at https://withruntime.com/account/keys). Never put a key
21
+ in source code, a URL or a command-line argument.
22
+
23
+ ```python
24
+ from withruntime import Sandbox
25
+
26
+ with Sandbox.create() as sbx:
27
+ result = sbx.exec("python3 -c 'print(6 * 7)'")
28
+ print(result.exit_code, result.stdout)
29
+ ```
30
+
31
+ `Sandbox.create()` needs no arguments and returns once the sandbox is running;
32
+ leaving the `with` block stops it. `AsyncRuntime` is the same client for
33
+ asyncio, method for method:
34
+
35
+ ```python
36
+ import asyncio
37
+ from withruntime import AsyncRuntime
38
+
39
+
40
+ async def main():
41
+ async with AsyncRuntime() as runtime:
42
+ async with await runtime.sandboxes.create() as sbx:
43
+ print((await sbx.exec("uname -a")).stdout)
44
+
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `files` (read, write,
50
+ list, glob, stat, move, remove, upload and download directories), `pause`,
51
+ `wake`, `extend`, `fork` and `snapshot`, and the `interpreter`, `network`,
52
+ `previews` and `desktop` products. The client has `sandboxes`, `images`,
53
+ `volumes`, `snapshots`, `feedback` and `support`. Every write carries an
54
+ idempotency key, so retries never do anything twice; errors are typed and carry
55
+ `code`, `hint` and `request_id`.
56
+
57
+ Docs: https://withruntime.com/docs/python.
58
+
59
+ The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
60
+ 0.3.0. Both names still work: `withruntime-cloud` installs this package and
61
+ `import runtime_cloud` gives you the same classes.
@@ -0,0 +1,54 @@
1
+ # Runtime Cloud Python SDK
2
+
3
+ One client for every Runtime Cloud product, sync and async. Python 3.10 or
4
+ later, standard library only.
5
+
6
+ ```bash no-run
7
+ pip install withruntime
8
+ ```
9
+
10
+ The client uses `RUNTIME_API_KEY` when it is set, and otherwise the connection
11
+ this machine saved when `npx withruntime login` connected it (one browser
12
+ approval, no key to copy). On a server, set `RUNTIME_API_KEY` from your secret
13
+ manager (create a key at https://withruntime.com/account/keys). Never put a key
14
+ in source code, a URL or a command-line argument.
15
+
16
+ ```python
17
+ from withruntime import Sandbox
18
+
19
+ with Sandbox.create() as sbx:
20
+ result = sbx.exec("python3 -c 'print(6 * 7)'")
21
+ print(result.exit_code, result.stdout)
22
+ ```
23
+
24
+ `Sandbox.create()` needs no arguments and returns once the sandbox is running;
25
+ leaving the `with` block stops it. `AsyncRuntime` is the same client for
26
+ asyncio, method for method:
27
+
28
+ ```python
29
+ import asyncio
30
+ from withruntime import AsyncRuntime
31
+
32
+
33
+ async def main():
34
+ async with AsyncRuntime() as runtime:
35
+ async with await runtime.sandboxes.create() as sbx:
36
+ print((await sbx.exec("uname -a")).stdout)
37
+
38
+
39
+ asyncio.run(main())
40
+ ```
41
+
42
+ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `files` (read, write,
43
+ list, glob, stat, move, remove, upload and download directories), `pause`,
44
+ `wake`, `extend`, `fork` and `snapshot`, and the `interpreter`, `network`,
45
+ `previews` and `desktop` products. The client has `sandboxes`, `images`,
46
+ `volumes`, `snapshots`, `feedback` and `support`. Every write carries an
47
+ idempotency key, so retries never do anything twice; errors are typed and carry
48
+ `code`, `hint` and `request_id`.
49
+
50
+ Docs: https://withruntime.com/docs/python.
51
+
52
+ The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
53
+ 0.3.0. Both names still work: `withruntime-cloud` installs this package and
54
+ `import runtime_cloud` gives you the same classes.
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "withruntime"
7
+ version = "0.3.0"
8
+ description = "Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["."]
15
+ include = ["withruntime*"]
@@ -0,0 +1,344 @@
1
+ """The Python SDK, sync and async, against the real API router.
2
+
3
+ scripts/python-fixture.ts (started by `bun scripts/test-python.ts`) serves the
4
+ server's own routes over a fake guest; RUNTIME_FIXTURE_URL names it. Without
5
+ it, these tests are skipped, never faked.
6
+ """
7
+ import asyncio
8
+ import inspect
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ import withruntime
19
+ from withruntime import (AsyncRuntime, CommandError, InvalidRequestError, Runtime, RuntimeError, Sandbox,
20
+ ServiceUnavailableError)
21
+
22
+ URL = os.environ.get("RUNTIME_FIXTURE_URL")
23
+ ID = "11111111-2222-4333-8444-555555555555"
24
+
25
+
26
+ def client():
27
+ return Runtime(api_key="rk_test", base_url=URL, max_retries=3)
28
+
29
+
30
+ @unittest.skipUnless(URL, "run through `bun scripts/test-python.ts`, which starts the real router")
31
+ class Sync(unittest.TestCase):
32
+ def test_hello_world(self):
33
+ with client() as runtime:
34
+ with runtime.sandboxes.create() as sbx:
35
+ self.assertEqual(sbx.state, "running")
36
+ self.assertEqual(sbx.info["vcpu"], 2)
37
+ result = sbx.exec("python3 -c 'print(6*7)'")
38
+ self.assertEqual(result.exit_code, 0)
39
+ self.assertEqual(result.stdout, "ran python3 -c 'print(6*7)'\n")
40
+ piped = sbx.exec(["cat"], stdin="in", env={"TOKEN": "secret"})
41
+ self.assertIn("<in>", piped.stdout)
42
+
43
+ def test_check_raises_with_the_output(self):
44
+ sbx = client().sandboxes.get(ID)
45
+ with self.assertRaises(CommandError) as caught:
46
+ sbx.exec("exit 3", check=True)
47
+ self.assertEqual((caught.exception.exit_code, caught.exception.stderr), (3, "boom\n"))
48
+
49
+ def test_files_small_large_and_listing(self):
50
+ sbx = client().sandboxes.get(ID)
51
+ sbx.files.write("/workspace/a.txt", "hello")
52
+ self.assertEqual(sbx.files.read_text("/workspace/a.txt"), "hello")
53
+ big = bytes(7 for _ in range(3 * 1_048_576 + 17))
54
+ sbx.files.write("/workspace/big.bin", big)
55
+ self.assertEqual(sbx.files.read("/workspace/big.bin"), big)
56
+ self.assertIn("/workspace/a.txt", [e["path"] for e in sbx.files.list()])
57
+ self.assertTrue(sbx.files.exists("/workspace/a.txt"))
58
+ self.assertFalse(sbx.files.exists("/workspace/none"))
59
+ with self.assertRaises(RuntimeError) as caught:
60
+ sbx.files.read("/workspace/none")
61
+ self.assertEqual(caught.exception.code, "file_not_found")
62
+ self.assertTrue(caught.exception.request_id)
63
+ self.assertTrue(caught.exception.hint)
64
+
65
+ def test_processes_and_streams(self):
66
+ sbx = client().sandboxes.get(ID)
67
+ proc = sbx.spawn("python3 server.py", stdin="pipe")
68
+ proc.write("line\n")
69
+ result = proc.wait()
70
+ self.assertEqual((result.exit_code, result.stdout), (0, "hello\n"))
71
+ self.assertEqual([e["type"] for e in sbx.exec_stream("echo hi")], ["start", "stdout", "exit"])
72
+ seen = []
73
+ self.assertEqual(sbx.exec("long job", on_stdout=seen.append).exit_code, 0)
74
+ self.assertEqual(seen, ["hello\n"])
75
+
76
+ def test_errors_are_typed_and_keys_never_leak(self):
77
+ with self.assertRaises(InvalidRequestError) as caught:
78
+ client().sandboxes.create(vcpus=2)
79
+ self.assertIn("issues", caught.exception.details)
80
+ self.assertNotIn("rk_test", str(caught.exception))
81
+ self.assertTrue(ServiceUnavailableError("x", status=503).retryable)
82
+ with self.assertRaises(RuntimeError):
83
+ Runtime(api_key="not a key")
84
+
85
+ def test_pages_iterate(self):
86
+ page = client().sandboxes.list()
87
+ self.assertEqual([s.id for s in page], [ID])
88
+ self.assertFalse(page.has_more)
89
+
90
+ def test_directories_round_trip_through_tar(self):
91
+ # The fake guest cannot run tar; this proves the archive the SDK builds is safe to unpack.
92
+ import io, tarfile
93
+ buffer = io.BytesIO()
94
+ with tempfile.TemporaryDirectory() as root:
95
+ Path(root, "src").mkdir()
96
+ Path(root, "src", "a.py").write_text("print(1)\n")
97
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
98
+ archive.add(root, arcname=".")
99
+ with tarfile.open(fileobj=io.BytesIO(buffer.getvalue()), mode="r:gz") as archive:
100
+ self.assertIn("./src/a.py", archive.getnames())
101
+
102
+ def test_terminal_over_websocket(self):
103
+ sbx = client().sandboxes.get(ID)
104
+ terminal = sbx.terminal(cols=100, rows=30)
105
+ self.assertEqual(terminal.process_id, "t1")
106
+ terminal.write("echo hi\r")
107
+ screen = b""
108
+ while b"echo hi" not in screen:
109
+ chunk = terminal.recv()
110
+ self.assertIsNotNone(chunk)
111
+ screen += chunk
112
+ terminal.write("exit\r")
113
+ while terminal.recv() is not None:
114
+ pass
115
+ terminal.close()
116
+
117
+ def test_static_helper(self):
118
+ sbx = Sandbox.create(client=client(), wait=False)
119
+ self.assertEqual(sbx.id, ID)
120
+
121
+
122
+ @unittest.skipUnless(URL, "run through `bun scripts/test-python.ts`, which starts the real router")
123
+ class Async(unittest.TestCase):
124
+ def test_the_same_flows_async(self):
125
+ async def main():
126
+ async with AsyncRuntime(api_key="rk_test", base_url=URL) as runtime:
127
+ async with await runtime.sandboxes.create() as sbx:
128
+ self.assertEqual((await sbx.exec("echo hi")).exit_code, 0)
129
+ await sbx.files.write("/workspace/b.txt", "async")
130
+ self.assertEqual(await sbx.files.read_text("/workspace/b.txt"), "async")
131
+ big = b"\x01" * (2 * 1_048_576 + 3)
132
+ await sbx.files.write("/workspace/big2.bin", big)
133
+ self.assertEqual(len(await sbx.files.list()) >= 2, True)
134
+ events = [e["type"] async for e in sbx.exec_stream("echo hi")]
135
+ self.assertEqual(events, ["start", "stdout", "exit"])
136
+ proc = await sbx.spawn("python3 server.py")
137
+ self.assertEqual((await proc.wait()).stdout, "hello\n")
138
+ page = await runtime.sandboxes.list()
139
+ self.assertEqual([s.id async for s in page], [ID])
140
+ terminal = await sbx.terminal()
141
+ await terminal.write("echo hi\r")
142
+ self.assertIsNotNone(await terminal.recv())
143
+ await terminal.close()
144
+ asyncio.run(main())
145
+
146
+ def test_many_calls_share_connections(self):
147
+ async def main():
148
+ async with AsyncRuntime(api_key="rk_test", base_url=URL) as runtime:
149
+ sbx = await runtime.sandboxes.get(ID)
150
+ results = await asyncio.gather(*(sbx.exec(f"echo {i}") for i in range(20)))
151
+ self.assertTrue(all(r.exit_code == 0 for r in results))
152
+ asyncio.run(main())
153
+
154
+
155
+ class Connections(unittest.TestCase):
156
+ def test_a_hundred_calls_at_once_hold_at_most_max_connections(self):
157
+ import asyncio
158
+ from withruntime._async_client import _Transport
159
+
160
+ peak = {"now": 0, "most": 0}
161
+
162
+ class Answer:
163
+ status = 200
164
+ headers: dict = {}
165
+
166
+ async def read(self):
167
+ return b"{}"
168
+
169
+ class Wire:
170
+ async def send(self, *_):
171
+ peak["now"] += 1
172
+ peak["most"] = max(peak["most"], peak["now"])
173
+ await asyncio.sleep(0.005)
174
+ peak["now"] -= 1
175
+ return Answer()
176
+
177
+ transport = _Transport("rk", "https://api.example.test", 10, 0)
178
+ transport._http = Wire()
179
+
180
+ async def main():
181
+ await asyncio.gather(*(transport.json("GET", "/v1/me") for _ in range(100)))
182
+ asyncio.run(main())
183
+ self.assertEqual(peak["most"], 32)
184
+
185
+
186
+ class DeliberateUnavailable(unittest.TestCase):
187
+ def test_a_deliberate_503_fails_at_once_and_a_passing_one_is_retried(self):
188
+ import asyncio
189
+ import json as _json
190
+ from withruntime._async_client import _Transport
191
+
192
+ for code, calls in (("fork_unavailable", 1), ("host_unavailable", 3)):
193
+ seen = {"n": 0}
194
+
195
+ class Answer:
196
+ status = 503
197
+ headers: dict = {}
198
+
199
+ async def read(self):
200
+ return _json.dumps({"error": {"code": code, "status": 503, "message": "m",
201
+ "retryAfterMs": 1}}).encode()
202
+
203
+ class Wire:
204
+ async def send(self, *_):
205
+ seen["n"] += 1
206
+ return Answer()
207
+
208
+ transport = _Transport("rk", "https://api.example.test", 10, 2)
209
+ transport._http = Wire()
210
+ with self.assertRaises(withruntime.RuntimeError) as caught:
211
+ asyncio.run(transport.json("GET", "/v1/me"))
212
+ self.assertEqual(caught.exception.code, code)
213
+ self.assertEqual(seen["n"], calls)
214
+
215
+
216
+ class Retries(unittest.TestCase):
217
+ def test_resets_and_5xx_are_retried_with_the_same_key(self):
218
+ import asyncio
219
+ import time as _time
220
+ from withruntime._async_client import _Transport
221
+
222
+ keys, at = [], []
223
+
224
+ class Answer:
225
+ def __init__(self, status, body, headers=None):
226
+ self.status, self._body, self.headers = status, body, headers or {}
227
+
228
+ async def read(self):
229
+ return self._body
230
+
231
+ class Wire:
232
+ async def send(self, method, target, headers, data, timeout):
233
+ keys.append(headers.get("Idempotency-Key"))
234
+ at.append(_time.perf_counter())
235
+ n = len(keys)
236
+ if n == 1:
237
+ raise ConnectionResetError("reset")
238
+ if n == 2:
239
+ return Answer(502, b"bad gateway")
240
+ if n == 3:
241
+ return Answer(503, b'{"error":{"code":"host_unavailable","message":"m"}}', {"retry-after": "1"})
242
+ if n == 4:
243
+ return Answer(504, b"timeout")
244
+ return Answer(200, b'{"ok":true}')
245
+
246
+ transport = _Transport("rk", "https://api.example.test", 10, 4)
247
+ transport._http = Wire()
248
+ self.assertEqual(asyncio.run(transport.json("POST", "/v1/sandboxes", body={})), {"ok": True})
249
+ self.assertEqual(len(keys), 5)
250
+ self.assertEqual(len(set(keys)), 1)
251
+ self.assertIsNotNone(keys[0])
252
+ self.assertGreaterEqual(at[3] - at[2], 0.88)
253
+
254
+
255
+ class SavedConnection(unittest.TestCase):
256
+ def test_the_key_runtime_login_saved_is_used_when_none_is_given(self):
257
+ import hashlib
258
+ import tempfile
259
+ from withruntime._async_client import _Transport
260
+
261
+ key = "rtcloud_" + "1" * 8 + "-2222-4333-8444-" + "5" * 12 + "_" + "a" * 43
262
+ with tempfile.TemporaryDirectory() as root:
263
+ old = {name: os.environ.get(name) for name in ("XDG_CONFIG_HOME", "RUNTIME_AUTH_URL")}
264
+ os.environ["XDG_CONFIG_HOME"] = root
265
+ os.environ.pop("RUNTIME_AUTH_URL", None)
266
+ try:
267
+ transport = _Transport("", "https://api.withruntime.com", 10, 0)
268
+ with self.assertRaises(withruntime.RuntimeError) as caught:
269
+ transport._headers("application/json", None, None, None)
270
+ self.assertEqual(caught.exception.code, "missing_api_key")
271
+ self.assertIn("npx -y withruntime login", caught.exception.hint)
272
+ folder = os.path.join(root, "runtime-cloud")
273
+ os.mkdir(folder, 0o700)
274
+ name = hashlib.sha256(b"https://withruntime.com\nhttps://api.withruntime.com").hexdigest()
275
+ path = os.path.join(folder, name + ".json")
276
+ with open(os.open(path, os.O_WRONLY | os.O_CREAT, 0o600), "w") as file:
277
+ json.dump({"version": 1, "apiOrigin": "https://api.withruntime.com",
278
+ "authOrigin": "https://withruntime.com", "key": key}, file)
279
+ transport = _Transport("", "https://api.withruntime.com", 10, 0)
280
+ headers = transport._headers("application/json", None, None, None)
281
+ self.assertEqual(headers["Authorization"], "Bearer " + key)
282
+ # A file others can read is not trusted.
283
+ os.chmod(path, 0o644)
284
+ with self.assertRaises(withruntime.RuntimeError):
285
+ _Transport("", "https://api.withruntime.com", 10, 0)._headers("application/json", None, None, None)
286
+ finally:
287
+ for name, value in old.items():
288
+ if value is None:
289
+ os.environ.pop(name, None)
290
+ else:
291
+ os.environ[name] = value
292
+
293
+
294
+ class Parity(unittest.TestCase):
295
+ def test_sync_client_is_generated_from_the_async_one(self):
296
+ script = Path(__file__).resolve().parent.parent / "scripts" / "generate_sync.py"
297
+ check = subprocess.run([sys.executable, str(script), "--check"])
298
+ self.assertEqual(check.returncode, 0, "run python3 scripts/generate_sync.py")
299
+
300
+ def test_a_product_with_its_own_docstring_and_future_import_generates(self):
301
+ import importlib.util
302
+ script = Path(__file__).resolve().parent.parent / "scripts" / "generate_sync.py"
303
+ spec = importlib.util.spec_from_file_location("generate_sync", script)
304
+ module = importlib.util.module_from_spec(spec)
305
+ spec.loader.exec_module(module)
306
+ source = '"""Mine."""\nfrom __future__ import annotations\n\nfrom .._async_client import AsyncSandbox\n'
307
+ out = module.transform(source, "_async_products/x.py")
308
+ compile(out, "x.py", "exec")
309
+ self.assertNotIn("Mine", out)
310
+ self.assertIn("from .._sync_client import Sandbox", out)
311
+
312
+ def test_every_async_method_has_a_sync_twin(self):
313
+ for name in ("Runtime", "Sandbox", "Sandboxes", "Snapshots", "Files", "Process", "Terminal", "Page", "Feedback", "Support"):
314
+ sync_cls = getattr(withruntime._sync_client, name)
315
+ async_cls = getattr(withruntime._async_client, "Async" + name)
316
+ sync_methods = {m for m, _ in inspect.getmembers(sync_cls, inspect.isfunction) if not m.startswith("_")}
317
+ async_methods = {m for m, _ in inspect.getmembers(async_cls, callable) if not m.startswith("_")}
318
+ self.assertEqual(sync_methods, {m for m in async_methods if m in sync_methods | async_methods} & sync_methods | sync_methods)
319
+ self.assertEqual(sorted(m for m in async_methods if not m.startswith("_")), sorted(sync_methods), name)
320
+
321
+ def test_import_is_cheap(self):
322
+ code = "import time; t=time.perf_counter(); import withruntime; print((time.perf_counter()-t)*1000)"
323
+ root = Path(__file__).resolve().parent.parent
324
+ subprocess.run([sys.executable, "-c", "import withruntime"], env={**os.environ, "PYTHONPATH": str(root)})
325
+ ms = float(subprocess.run([sys.executable, "-c", code], env={**os.environ, "PYTHONPATH": str(root)},
326
+ capture_output=True, text=True).stdout)
327
+ self.assertLess(ms, 60)
328
+
329
+
330
+ @unittest.skipUnless(URL, "needs the fixture")
331
+ class Routes(unittest.TestCase):
332
+ def test_zz_only_registered_routes_were_called(self):
333
+ with urllib.request.urlopen(URL + "/__routes") as response:
334
+ seen = json.load(response)
335
+ routes = seen["routes"]
336
+ # Every JSON answer matched the schema its route publishes in OpenAPI.
337
+ self.assertEqual(seen["mismatches"], [])
338
+ self.assertGreater(len(routes), 10)
339
+ # The fixture records a route only when the server's registry matched it.
340
+ self.assertTrue(all(r.split(" ")[1].startswith("/v1/") for r in routes))
341
+
342
+
343
+ if __name__ == "__main__":
344
+ unittest.main()
@@ -0,0 +1,149 @@
1
+ """Images, volumes and the interpreter against a stub API: the paths and
2
+ bodies the SDK sends, the build helper's polling and log lines, and the
3
+ interpreter's NDJSON stream turned into callbacks and one execution. The
4
+ routes themselves are tested in packages/cloud (images-api, interpreter)."""
5
+ import asyncio
6
+ import json
7
+ import threading
8
+ import unittest
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from urllib.parse import urlsplit
11
+
12
+ from withruntime import AsyncRuntime, AsyncSandbox, Runtime, RuntimeError
13
+ from withruntime._sync_client import Sandbox
14
+
15
+ SANDBOX = "8a1f9c2e-0d1b-4c3a-9e8f-7a6b5c4d3e2f"
16
+ EXECUTION = {"id": "e1", "contextId": "python", "status": "ok", "stdout": "hi\n", "results": [], "error": None}
17
+
18
+
19
+ class Stub(BaseHTTPRequestHandler):
20
+ seen: list = []
21
+ polls = 0
22
+ fail = False
23
+
24
+ def log_message(self, *args):
25
+ pass
26
+
27
+ def _reply(self, status, value, kind="application/json"):
28
+ data = value if isinstance(value, bytes) else json.dumps(value).encode()
29
+ self.send_response(status)
30
+ self.send_header("content-type", kind)
31
+ self.send_header("content-length", str(len(data)))
32
+ self.end_headers()
33
+ self.wfile.write(data)
34
+
35
+ def _route(self):
36
+ url = urlsplit(self.path)
37
+ length = int(self.headers.get("content-length") or 0)
38
+ body = json.loads(self.rfile.read(length)) if length else None
39
+ Stub.seen.append((self.command, url.path, url.query, body, self.headers.get("prefer")))
40
+ path, method = url.path, self.command
41
+ if method == "POST" and path == "/v1/images":
42
+ return self._reply(201, {"id": "img-1", "state": "queued"})
43
+ if path == "/v1/images/img-1/logs":
44
+ first = url.query == "after=0"
45
+ return self._reply(200, {"lines": [{"seq": 1, "text": "pulling"}] if first else [], "nextAfter": 1,
46
+ "done": False})
47
+ if path == "/v1/images/img-1":
48
+ Stub.polls += 1
49
+ state = "building" if Stub.polls < 2 else ("failed" if Stub.fail else "ready")
50
+ return self._reply(200, {"id": "img-1", "state": state, "error": "exit 1" if Stub.fail else None})
51
+ if path == "/v1/images":
52
+ return self._reply(200, {"data": [{"id": "img-1"}], "nextCursor": None})
53
+ if path == "/v1/volumes" and method == "POST":
54
+ return self._reply(201, {"id": "vol-1", "state": "ready", "sizeMiB": body["sizeMiB"]})
55
+ if path == "/v1/volumes/vol-1:delete":
56
+ return self._reply(200, {"id": "vol-1", "state": "deleting"})
57
+ base = f"/v1/sandboxes/{SANDBOX}/interpreter"
58
+ if path == base + ":run":
59
+ if not body.get("stream"):
60
+ return self._reply(200, EXECUTION)
61
+ lines = [{"k": "start", "n": 1}, {"k": "stdout", "text": "hi\n"},
62
+ {"k": "result", "main": True, "data": {"text/plain": "2"}, "refs": {}},
63
+ {"k": "end"}, {"k": "execution", "execution": EXECUTION}]
64
+ return self._reply(200, "".join(json.dumps(line) + "\n" for line in lines).encode(),
65
+ "application/x-ndjson")
66
+ if path == base + "/contexts" and method == "POST":
67
+ return self._reply(201, {"id": body["id"], "language": body["language"]})
68
+ if path == base + "/contexts/py2:interrupt":
69
+ return self._reply(200, {"interrupted": True})
70
+ if path == base + "/contexts/python/results/r1.png":
71
+ return self._reply(200, b"\x89PNG", "image/png")
72
+ return self._reply(404, {"error": {"code": "not_found", "message": path}})
73
+
74
+ do_GET = do_POST = do_DELETE = _route
75
+
76
+
77
+ class ImagesVolumesInterpreter(unittest.TestCase):
78
+ @classmethod
79
+ def setUpClass(cls):
80
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
81
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
82
+ cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
83
+
84
+ @classmethod
85
+ def tearDownClass(cls):
86
+ cls.server.shutdown()
87
+
88
+ def setUp(self):
89
+ Stub.seen, Stub.polls, Stub.fail = [], 0, False
90
+
91
+ def client(self):
92
+ return Runtime(api_key="rk_test", base_url=self.url, max_retries=0)
93
+
94
+ def test_build_polls_to_ready_and_passes_log_lines(self):
95
+ lines = []
96
+ image = self.client().images.build(recipe={"pip": ["pandas"], "files": {"a.txt": b"\x00"}},
97
+ build={"max_image_mib": 2048}, build_args={"V": "1"},
98
+ on_log=lambda line: lines.append(line["text"]), poll_seconds=0)
99
+ self.assertEqual(image["state"], "ready")
100
+ self.assertEqual(lines, ["pulling"])
101
+ method, path, _, body, _ = Stub.seen[0]
102
+ self.assertEqual((method, path), ("POST", "/v1/images"))
103
+ self.assertEqual(body, {"recipe": {"pip": ["pandas"], "files": [
104
+ {"path": "a.txt", "content": "AA==", "encoding": "base64"}]},
105
+ "build": {"maxImageMiB": 2048}, "buildArgs": {"V": "1"}})
106
+
107
+ def test_a_failed_build_raises_with_its_error(self):
108
+ Stub.fail = True
109
+ with self.assertRaises(RuntimeError) as caught:
110
+ self.client().images.build(image="alpine:3.20", poll_seconds=0)
111
+ self.assertEqual(caught.exception.code, "image_build_failed")
112
+ self.assertIn("exit 1", str(caught.exception))
113
+
114
+ def test_images_list_and_volumes(self):
115
+ runtime = self.client()
116
+ self.assertEqual([i["id"] for i in runtime.images.list(state="ready")], ["img-1"])
117
+ volume = runtime.volumes.create(1024, name="data")
118
+ self.assertEqual(volume["sizeMiB"], 1024)
119
+ self.assertEqual(Stub.seen[-1][3:], ({"sizeMiB": 1024, "name": "data"}, "wait=10"))
120
+ self.assertEqual(runtime.volumes.delete("vol-1")["state"], "deleting")
121
+
122
+ def test_interpreter_run_plain_and_streamed(self):
123
+ sbx = Sandbox(self.client().sandboxes._t, {"id": SANDBOX})
124
+ self.assertEqual(sbx.interpreter.run("print('hi')", timeout_ms=5000)["stdout"], "hi\n")
125
+ self.assertEqual(Stub.seen[-1][3], {"code": "print('hi')", "timeoutMs": 5000})
126
+ out, results = [], []
127
+ execution = sbx.interpreter.run("1+1", on_stdout=out.append, on_result=results.append)
128
+ self.assertEqual((execution["status"], out), ("ok", ["hi\n"]))
129
+ self.assertEqual(results, [{"main": True, "data": {"text/plain": "2"}, "refs": {}}])
130
+ self.assertTrue(Stub.seen[-1][3]["stream"])
131
+ self.assertEqual(sbx.interpreter.contexts.create(id="py2", language="python")["id"], "py2")
132
+ self.assertTrue(sbx.interpreter.contexts.interrupt("py2"))
133
+ ref = {"path": "/workspace/.runtime/interpreter/python/out/r1.png"}
134
+ self.assertEqual(sbx.interpreter.result(ref), b"\x89PNG")
135
+ with self.assertRaises(ValueError):
136
+ sbx.interpreter.result({"path": "/etc/passwd"})
137
+
138
+ def test_async_twin(self):
139
+ async def main():
140
+ async with AsyncRuntime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
141
+ image = await runtime.images.build(image="alpine:3.20", poll_seconds=0)
142
+ sbx = AsyncSandbox(runtime.sandboxes._t, {"id": SANDBOX})
143
+ execution = await sbx.interpreter.run("1+1", on_stdout=lambda text: None)
144
+ return image["state"], execution["status"]
145
+ self.assertEqual(asyncio.run(main()), ("ready", "ok"))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()