withruntime 0.2.0__tar.gz → 0.3.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.
- withruntime-0.3.1/PKG-INFO +61 -0
- withruntime-0.3.1/README.md +54 -0
- withruntime-0.3.1/pyproject.toml +15 -0
- withruntime-0.3.1/tests/test_client.py +344 -0
- withruntime-0.3.1/tests/test_images_volumes_interpreter.py +149 -0
- withruntime-0.3.1/tests/test_limits.py +61 -0
- withruntime-0.3.1/tests/test_previews_network_desktop.py +108 -0
- withruntime-0.3.1/tests/test_referrals.py +58 -0
- withruntime-0.3.1/withruntime/__init__.py +50 -0
- withruntime-0.3.1/withruntime/_async_client.py +826 -0
- withruntime-0.3.1/withruntime/_async_products/__init__.py +36 -0
- withruntime-0.3.1/withruntime/_async_products/desktop.py +101 -0
- withruntime-0.3.1/withruntime/_async_products/images.py +119 -0
- withruntime-0.3.1/withruntime/_async_products/interpreter.py +97 -0
- withruntime-0.3.1/withruntime/_async_products/limits.py +20 -0
- withruntime-0.3.1/withruntime/_async_products/network.py +41 -0
- withruntime-0.3.1/withruntime/_async_products/previews.py +47 -0
- withruntime-0.3.1/withruntime/_async_products/referrals.py +19 -0
- withruntime-0.3.1/withruntime/_async_products/volumes.py +43 -0
- withruntime-0.3.1/withruntime/_clock.py +76 -0
- withruntime-0.3.1/withruntime/_connection.py +53 -0
- withruntime-0.3.1/withruntime/_errors.py +88 -0
- withruntime-0.3.1/withruntime/_http.py +295 -0
- withruntime-0.3.1/withruntime/_sync_client.py +825 -0
- withruntime-0.3.1/withruntime/_sync_products/__init__.py +22 -0
- withruntime-0.3.1/withruntime/_sync_products/desktop.py +102 -0
- withruntime-0.3.1/withruntime/_sync_products/images.py +120 -0
- withruntime-0.3.1/withruntime/_sync_products/interpreter.py +98 -0
- withruntime-0.3.1/withruntime/_sync_products/limits.py +21 -0
- withruntime-0.3.1/withruntime/_sync_products/network.py +42 -0
- withruntime-0.3.1/withruntime/_sync_products/previews.py +48 -0
- withruntime-0.3.1/withruntime/_sync_products/referrals.py +20 -0
- withruntime-0.3.1/withruntime/_sync_products/volumes.py +44 -0
- withruntime-0.3.1/withruntime/_version.py +1 -0
- withruntime-0.3.1/withruntime/_ws.py +161 -0
- withruntime-0.3.1/withruntime.egg-info/PKG-INFO +61 -0
- withruntime-0.3.1/withruntime.egg-info/SOURCES.txt +38 -0
- withruntime-0.2.0/PKG-INFO +0 -24
- withruntime-0.2.0/README.md +0 -13
- withruntime-0.2.0/pyproject.toml +0 -19
- withruntime-0.2.0/withruntime/__init__.py +0 -8
- withruntime-0.2.0/withruntime.egg-info/PKG-INFO +0 -24
- withruntime-0.2.0/withruntime.egg-info/SOURCES.txt +0 -8
- withruntime-0.2.0/withruntime.egg-info/requires.txt +0 -1
- {withruntime-0.2.0 → withruntime-0.3.1}/setup.cfg +0 -0
- {withruntime-0.2.0 → withruntime-0.3.1}/withruntime.egg-info/dependency_links.txt +0 -0
- {withruntime-0.2.0 → withruntime-0.3.1}/withruntime.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: withruntime
|
|
3
|
+
Version: 0.3.1
|
|
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.1"
|
|
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()
|