withruntime 0.4.0__tar.gz → 0.5.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 (98) hide show
  1. {withruntime-0.4.0/withruntime.egg-info → withruntime-0.5.1}/PKG-INFO +48 -9
  2. withruntime-0.4.0/PKG-INFO → withruntime-0.5.1/README.md +43 -15
  3. {withruntime-0.4.0 → withruntime-0.5.1}/pyproject.toml +7 -1
  4. withruntime-0.5.1/tests/test_audit.py +70 -0
  5. withruntime-0.5.1/tests/test_capacity_wait.py +133 -0
  6. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_client.py +51 -0
  7. withruntime-0.5.1/tests/test_daytona.py +373 -0
  8. withruntime-0.5.1/tests/test_deepagents.py +79 -0
  9. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_e2b_sandbox.py +13 -4
  10. withruntime-0.5.1/tests/test_images_v2.py +137 -0
  11. withruntime-0.5.1/tests/test_lifecycle_settings.py +135 -0
  12. withruntime-0.5.1/tests/test_observability.py +134 -0
  13. withruntime-0.5.1/tests/test_openai_agents.py +456 -0
  14. withruntime-0.5.1/tests/test_secrets.py +91 -0
  15. withruntime-0.5.1/tests/test_switching.py +79 -0
  16. withruntime-0.5.1/tests/test_tools.py +115 -0
  17. withruntime-0.5.1/tests/test_vercel.py +282 -0
  18. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/__init__.py +10 -1
  19. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_client.py +149 -13
  20. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/__init__.py +11 -0
  21. withruntime-0.5.1/withruntime/_async_products/audit.py +26 -0
  22. withruntime-0.5.1/withruntime/_async_products/images.py +379 -0
  23. withruntime-0.5.1/withruntime/_async_products/observability.py +128 -0
  24. withruntime-0.5.1/withruntime/_async_products/secrets.py +38 -0
  25. withruntime-0.5.1/withruntime/_async_products/switching.py +36 -0
  26. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_clock.py +15 -0
  27. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_errors.py +21 -0
  28. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_client.py +149 -13
  29. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/__init__.py +11 -0
  30. withruntime-0.5.1/withruntime/_sync_products/audit.py +27 -0
  31. withruntime-0.5.1/withruntime/_sync_products/images.py +380 -0
  32. withruntime-0.5.1/withruntime/_sync_products/observability.py +129 -0
  33. withruntime-0.5.1/withruntime/_sync_products/secrets.py +39 -0
  34. withruntime-0.5.1/withruntime/_sync_products/switching.py +37 -0
  35. withruntime-0.5.1/withruntime/_tunnel.py +330 -0
  36. withruntime-0.5.1/withruntime/_version.py +1 -0
  37. withruntime-0.5.1/withruntime/daytona/__init__.py +74 -0
  38. withruntime-0.5.1/withruntime/daytona/_async_daytona.py +1031 -0
  39. withruntime-0.5.1/withruntime/daytona/_async_io.py +34 -0
  40. withruntime-0.5.1/withruntime/daytona/_core.py +924 -0
  41. withruntime-0.5.1/withruntime/daytona/_sync_daytona.py +1029 -0
  42. withruntime-0.5.1/withruntime/daytona/_sync_io.py +43 -0
  43. withruntime-0.5.1/withruntime/deepagents.py +131 -0
  44. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/_async_sandbox.py +45 -4
  45. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/_core.py +8 -4
  46. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/_sync_sandbox.py +45 -4
  47. withruntime-0.5.1/withruntime/openai_agents.py +844 -0
  48. withruntime-0.5.1/withruntime/tools.py +201 -0
  49. withruntime-0.5.1/withruntime/vercel/__init__.py +12 -0
  50. withruntime-0.5.1/withruntime/vercel/_async_io.py +58 -0
  51. withruntime-0.5.1/withruntime/vercel/_async_sandbox.py +862 -0
  52. withruntime-0.5.1/withruntime/vercel/_core.py +438 -0
  53. withruntime-0.5.1/withruntime/vercel/_sync_io.py +51 -0
  54. withruntime-0.5.1/withruntime/vercel/_sync_sandbox.py +860 -0
  55. withruntime-0.5.1/withruntime/vercel/api.py +32 -0
  56. withruntime-0.5.1/withruntime/vercel/sandbox/__init__.py +56 -0
  57. withruntime-0.5.1/withruntime/vercel/sandbox/sync.py +26 -0
  58. withruntime-0.5.1/withruntime/webhooks.py +56 -0
  59. withruntime-0.4.0/README.md → withruntime-0.5.1/withruntime.egg-info/PKG-INFO +54 -8
  60. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime.egg-info/SOURCES.txt +42 -1
  61. withruntime-0.5.1/withruntime.egg-info/requires.txt +6 -0
  62. withruntime-0.4.0/withruntime/_async_products/images.py +0 -119
  63. withruntime-0.4.0/withruntime/_sync_products/images.py +0 -120
  64. withruntime-0.4.0/withruntime/_version.py +0 -1
  65. {withruntime-0.4.0 → withruntime-0.5.1}/setup.cfg +0 -0
  66. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_e2b_code_interpreter.py +0 -0
  67. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_images_volumes_interpreter.py +0 -0
  68. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_limits.py +0 -0
  69. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_previews_network_desktop.py +0 -0
  70. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_proxy.py +0 -0
  71. {withruntime-0.4.0 → withruntime-0.5.1}/tests/test_referrals.py +0 -0
  72. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/desktop.py +0 -0
  73. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/interpreter.py +0 -0
  74. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/limits.py +0 -0
  75. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/network.py +0 -0
  76. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/previews.py +0 -0
  77. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/referrals.py +0 -0
  78. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_async_products/volumes.py +0 -0
  79. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_connection.py +0 -0
  80. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_http.py +0 -0
  81. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_proxy.py +0 -0
  82. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/desktop.py +0 -0
  83. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/interpreter.py +0 -0
  84. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/limits.py +0 -0
  85. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/network.py +0 -0
  86. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/previews.py +0 -0
  87. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/referrals.py +0 -0
  88. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_sync_products/volumes.py +0 -0
  89. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/_ws.py +0 -0
  90. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/__init__.py +0 -0
  91. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/_async_io.py +0 -0
  92. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/_sync_io.py +0 -0
  93. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/code_interpreter/__init__.py +0 -0
  94. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
  95. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/code_interpreter/_models.py +0 -0
  96. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
  97. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime.egg-info/dependency_links.txt +0 -0
  98. {withruntime-0.4.0 → withruntime-0.5.1}/withruntime.egg-info/top_level.txt +0 -0
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
+ Provides-Extra: openai-agents
8
+ Requires-Dist: openai-agents>=0.22.3; extra == "openai-agents"
9
+ Provides-Extra: deepagents
10
+ Requires-Dist: deepagents>=0.7; extra == "deepagents"
7
11
 
8
12
  # Runtime Cloud Python SDK
9
13
 
@@ -16,9 +20,11 @@ pip install withruntime
16
20
 
17
21
  The client uses `RUNTIME_API_KEY` when it is set, and otherwise the connection
18
22
  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.
23
+ approval, no key to copy). On a server or a CI runner, set `RUNTIME_API_KEY`
24
+ from your secret manager: `npx withruntime keys create` prints a new key once
25
+ after an owner, admin or developer of the account approves it in the browser, or create one at
26
+ https://withruntime.com/account/keys. Never put a key in source code, a URL or a
27
+ command-line argument.
22
28
 
23
29
  ```python
24
30
  from withruntime import Sandbox
@@ -29,8 +35,9 @@ with Sandbox.create() as sbx:
29
35
  ```
30
36
 
31
37
  `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:
38
+ leaving the `with` block stops it. With no arguments you get the free trial
39
+ while it lasts: 50 free hours, no card, up to eight sandboxes running at once.
40
+ `AsyncRuntime` is the same client for asyncio, method for method:
34
41
 
35
42
  ```python
36
43
  import asyncio
@@ -46,10 +53,13 @@ async def main():
46
53
  asyncio.run(main())
47
54
  ```
48
55
 
49
- A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `files` (read, write,
56
+ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `forward_port` (any
57
+ TCP port in the sandbox on a local port), `files` (read, write,
50
58
  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`,
59
+ `wake`, `extend`, `update`, `keep_alive`, `fork` and `snapshot`, and the
60
+ `interpreter`, `network`, `previews` and `desktop` products. A paused sandbox
61
+ also wakes by itself on the next call, and `Sandbox.get_or_create(name)` returns
62
+ the sandbox with that name or creates it. The client has `sandboxes`, `images`,
53
63
  `volumes`, `snapshots`, `limits`, `feedback` and `support`;
54
64
  `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
55
65
  what its agent may still spend today. Every write carries an
@@ -89,6 +99,35 @@ happens, naming what to use instead. Importing `withruntime` alone does not
89
99
  load it. The sync `Sandbox` is generated from the async one by
90
100
  `scripts/generate_e2b_sync.py`.
91
101
 
102
+ ## Code written for Daytona or Vercel Sandbox
103
+
104
+ `withruntime.daytona` and `withruntime.vercel` do the same for Daytona's
105
+ Python SDK and Vercel Sandbox's. Change the import:
106
+
107
+ ```python no-run
108
+ from withruntime.daytona import Daytona, AsyncDaytona # was: from daytona import ...
109
+ from withruntime.vercel import sandbox # was: from vercel import sandbox
110
+ from withruntime.vercel.sandbox import sync as sandbox # was: from vercel.sandbox import sync as sandbox
111
+ ```
112
+
113
+ Sandboxes get the rival's defaults: Daytona's 1 vCPU, 1 GiB and 3 GiB disk,
114
+ pausing after 15 minutes without calls; Vercel's 2 vCPUs with 2048 MiB each, 5
115
+ minutes, persistent. `runtime_create={...}` passes Runtime's own create
116
+ fields. A Daytona or Vercel key is never sent anywhere. What Runtime does not do
117
+ the same way raises `NotSupportedError` before anything happens, naming what to
118
+ use instead. The sync modules are generated from the async ones by
119
+ `scripts/generate_dropin_sync.py`. `DAYTONA.md` and `VERCEL.md` in the
120
+ JavaScript package list every mapping and gap.
121
+
122
+ ## Agent frameworks
123
+
124
+ `withruntime.openai_agents` is a sandbox client for the OpenAI Agents SDK's
125
+ `SandboxAgent` (`pip install "withruntime[openai-agents]"`), and
126
+ `withruntime.deepagents` is a Deep Agents sandbox backend. `withruntime.tools`
127
+ gives LangChain, CrewAI, LlamaIndex, Pydantic AI, Google ADK and any framework
128
+ that takes typed functions four sandbox tools. See
129
+ https://withruntime.com/docs/frameworks.
130
+
92
131
  Docs: https://withruntime.com/docs/python.
93
132
 
94
133
  The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
@@ -1,10 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: withruntime
3
- Version: 0.4.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
1
  # Runtime Cloud Python SDK
9
2
 
10
3
  One client for every Runtime Cloud product, sync and async. Python 3.10 or
@@ -16,9 +9,11 @@ pip install withruntime
16
9
 
17
10
  The client uses `RUNTIME_API_KEY` when it is set, and otherwise the connection
18
11
  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.
12
+ approval, no key to copy). On a server or a CI runner, set `RUNTIME_API_KEY`
13
+ from your secret manager: `npx withruntime keys create` prints a new key once
14
+ after an owner, admin or developer of the account approves it in the browser, or create one at
15
+ https://withruntime.com/account/keys. Never put a key in source code, a URL or a
16
+ command-line argument.
22
17
 
23
18
  ```python
24
19
  from withruntime import Sandbox
@@ -29,8 +24,9 @@ with Sandbox.create() as sbx:
29
24
  ```
30
25
 
31
26
  `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:
27
+ leaving the `with` block stops it. With no arguments you get the free trial
28
+ while it lasts: 50 free hours, no card, up to eight sandboxes running at once.
29
+ `AsyncRuntime` is the same client for asyncio, method for method:
34
30
 
35
31
  ```python
36
32
  import asyncio
@@ -46,10 +42,13 @@ async def main():
46
42
  asyncio.run(main())
47
43
  ```
48
44
 
49
- A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `files` (read, write,
45
+ A sandbox has `exec`, `exec_stream`, `spawn`, `terminal`, `forward_port` (any
46
+ TCP port in the sandbox on a local port), `files` (read, write,
50
47
  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`,
48
+ `wake`, `extend`, `update`, `keep_alive`, `fork` and `snapshot`, and the
49
+ `interpreter`, `network`, `previews` and `desktop` products. A paused sandbox
50
+ also wakes by itself on the next call, and `Sandbox.get_or_create(name)` returns
51
+ the sandbox with that name or creates it. The client has `sandboxes`, `images`,
53
52
  `volumes`, `snapshots`, `limits`, `feedback` and `support`;
54
53
  `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
55
54
  what its agent may still spend today. Every write carries an
@@ -89,6 +88,35 @@ happens, naming what to use instead. Importing `withruntime` alone does not
89
88
  load it. The sync `Sandbox` is generated from the async one by
90
89
  `scripts/generate_e2b_sync.py`.
91
90
 
91
+ ## Code written for Daytona or Vercel Sandbox
92
+
93
+ `withruntime.daytona` and `withruntime.vercel` do the same for Daytona's
94
+ Python SDK and Vercel Sandbox's. Change the import:
95
+
96
+ ```python no-run
97
+ from withruntime.daytona import Daytona, AsyncDaytona # was: from daytona import ...
98
+ from withruntime.vercel import sandbox # was: from vercel import sandbox
99
+ from withruntime.vercel.sandbox import sync as sandbox # was: from vercel.sandbox import sync as sandbox
100
+ ```
101
+
102
+ Sandboxes get the rival's defaults: Daytona's 1 vCPU, 1 GiB and 3 GiB disk,
103
+ pausing after 15 minutes without calls; Vercel's 2 vCPUs with 2048 MiB each, 5
104
+ minutes, persistent. `runtime_create={...}` passes Runtime's own create
105
+ fields. A Daytona or Vercel key is never sent anywhere. What Runtime does not do
106
+ the same way raises `NotSupportedError` before anything happens, naming what to
107
+ use instead. The sync modules are generated from the async ones by
108
+ `scripts/generate_dropin_sync.py`. `DAYTONA.md` and `VERCEL.md` in the
109
+ JavaScript package list every mapping and gap.
110
+
111
+ ## Agent frameworks
112
+
113
+ `withruntime.openai_agents` is a sandbox client for the OpenAI Agents SDK's
114
+ `SandboxAgent` (`pip install "withruntime[openai-agents]"`), and
115
+ `withruntime.deepagents` is a Deep Agents sandbox backend. `withruntime.tools`
116
+ gives LangChain, CrewAI, LlamaIndex, Pydantic AI, Google ADK and any framework
117
+ that takes typed functions four sandbox tools. See
118
+ https://withruntime.com/docs/frameworks.
119
+
92
120
  Docs: https://withruntime.com/docs/python.
93
121
 
94
122
  The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
@@ -4,12 +4,18 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.4.0"
7
+ version = "0.5.1"
8
8
  description = "Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = []
12
12
 
13
+ [project.optional-dependencies]
14
+ # withruntime.openai_agents: a sandbox client for the OpenAI Agents SDK's SandboxAgent.
15
+ openai-agents = ["openai-agents>=0.22.3"]
16
+ # withruntime.deepagents: a LangChain Deep Agents sandbox backend.
17
+ deepagents = ["deepagents>=0.7"]
18
+
13
19
  [tool.setuptools.packages.find]
14
20
  where = ["."]
15
21
  include = ["withruntime*"]
@@ -0,0 +1,70 @@
1
+ """runtime.audit.list() against a stub API, sync and async. The route and who
2
+ may read it are tested in packages/db (teams) and packages/cloud."""
3
+ import asyncio
4
+ import json
5
+ import threading
6
+ import unittest
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+
9
+ from withruntime import AsyncRuntime, Runtime
10
+
11
+ PAGE = {
12
+ "events": [
13
+ {
14
+ "seq": "7",
15
+ "action": "key.created",
16
+ "actor": {"kind": "person", "id": "p", "name": "Marc", "person": None},
17
+ "ip": "203.0.113.7",
18
+ }
19
+ ],
20
+ "next": None,
21
+ }
22
+
23
+
24
+ class Stub(BaseHTTPRequestHandler):
25
+ seen: list = []
26
+
27
+ def log_message(self, *args):
28
+ pass
29
+
30
+ def do_GET(self):
31
+ Stub.seen.append((self.command, self.path))
32
+ data = json.dumps(PAGE).encode()
33
+ self.send_response(200)
34
+ self.send_header("content-type", "application/json")
35
+ self.send_header("content-length", str(len(data)))
36
+ self.end_headers()
37
+ self.wfile.write(data)
38
+
39
+
40
+ class AuditTest(unittest.TestCase):
41
+ @classmethod
42
+ def setUpClass(cls):
43
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
44
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
45
+ cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
46
+
47
+ @classmethod
48
+ def tearDownClass(cls):
49
+ cls.server.shutdown()
50
+
51
+ def setUp(self):
52
+ Stub.seen = []
53
+
54
+ def test_sync_list_with_filters(self):
55
+ with Runtime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
56
+ page = runtime.audit.list(action="member.", limit=10)
57
+ self.assertEqual(page["events"][0]["action"], "key.created")
58
+ self.assertEqual(Stub.seen, [("GET", "/v1/audit?action=member.&limit=10")])
59
+
60
+ def test_async_list(self):
61
+ async def go():
62
+ async with AsyncRuntime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
63
+ return await runtime.audit.list()
64
+
65
+ self.assertIsNone(asyncio.run(go())["next"])
66
+ self.assertEqual(Stub.seen, [("GET", "/v1/audit")])
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()
@@ -0,0 +1,133 @@
1
+ """A create that finds every trial slot, the account's quota or the region full
2
+ waits for room and sends the same call again, instead of failing a CI job that
3
+ burst past its limit (judge panel, 23 September 2026). Sync and async share
4
+ the transport's code, so both are driven here over a fake wire."""
5
+ import asyncio
6
+ import json
7
+ import time
8
+ import unittest
9
+
10
+ from withruntime import AsyncRuntime, ConflictError, Runtime
11
+
12
+ RUNNING = json.dumps({"id": "sbx", "kind": "sandbox", "state": "running", "status": "active"}).encode()
13
+
14
+
15
+ def refusal(code, status=409, details=None):
16
+ error = {"code": code, "status": status, "message": "full", "retryAfterMs": 5}
17
+ if details is not None:
18
+ error["details"] = details
19
+ return status, json.dumps({"error": error}).encode()
20
+
21
+
22
+ class Wire:
23
+ """Answers each call with the next refusal, then with a running sandbox."""
24
+
25
+ def __init__(self, refusals, is_async):
26
+ self.refusals, self.is_async = list(refusals), is_async
27
+ self.keys, self.bodies, self.targets = [], [], []
28
+
29
+ def _answer(self, method, target, headers, data):
30
+ self.keys.append(headers.get("Idempotency-Key"))
31
+ self.bodies.append(data)
32
+ self.targets.append(target)
33
+ status, body = self.refusals.pop(0) if self.refusals else (200, RUNNING)
34
+ is_async = self.is_async
35
+
36
+ class Answer:
37
+ def __init__(self):
38
+ self.status, self.headers = status, {}
39
+
40
+ if is_async:
41
+ async def read(self):
42
+ return body
43
+ else:
44
+ def read(self):
45
+ return body
46
+ return Answer()
47
+
48
+ def send(self, method, target, headers, data, timeout):
49
+ if self.is_async:
50
+ async def later():
51
+ return self._answer(method, target, headers, data)
52
+ return later()
53
+ return self._answer(method, target, headers, data)
54
+
55
+
56
+ def create(refusals, client_options=None, **create_options):
57
+ """Runs one create, sync and async, and returns each run's wire and outcome."""
58
+ runs = []
59
+ for is_async in (False, True):
60
+ wire = Wire(refusals, is_async)
61
+ options = {"api_key": "rk", "base_url": "https://api.example.test", **(client_options or {})}
62
+ runtime = AsyncRuntime(**options) if is_async else Runtime(**options)
63
+ runtime._t._http = wire
64
+ started = time.monotonic()
65
+ try:
66
+ if is_async:
67
+ outcome = asyncio.run(runtime.sandboxes.create(**create_options))
68
+ else:
69
+ outcome = runtime.sandboxes.create(**create_options)
70
+ except Exception as error: # noqa: BLE001 - the outcome under test
71
+ outcome = error
72
+ runs.append((wire, outcome, time.monotonic() - started))
73
+ return runs
74
+
75
+
76
+ class CapacityWait(unittest.TestCase):
77
+ def test_a_full_trial_is_waited_out_with_the_same_key_and_input(self):
78
+ busy = refusal("trial_busy", details={"sandboxIds": ["a"], "concurrent": 8})
79
+ for wire, outcome, _ in create([busy, busy], funding="trial", labels={"ci": "1"}):
80
+ self.assertEqual(outcome.id, "sbx")
81
+ self.assertEqual(len(wire.keys), 3)
82
+ self.assertEqual(len(set(wire.keys)), 1)
83
+ self.assertIsNotNone(wire.keys[0])
84
+ self.assertEqual(len(set(wire.bodies)), 1)
85
+ self.assertEqual(json.loads(wire.bodies[0]), {"funding": "trial", "labels": {"ci": "1"}})
86
+
87
+ def test_the_paid_limit_a_domain_limit_and_a_full_region_are_waited_out_too(self):
88
+ for code in ("quota_exceeded", "trial_domain_limit", "no_capacity"):
89
+ for wire, outcome, _ in create([refusal(code)]):
90
+ self.assertEqual(outcome.id, "sbx", code)
91
+ self.assertEqual(len(wire.keys), 2)
92
+ # trial_capacity is a 503: waited for as room, not spent from max_retries.
93
+ for wire, outcome, _ in create([refusal("trial_capacity", 503)] * 6, {"max_retries": 1}):
94
+ self.assertEqual(outcome.id, "sbx")
95
+ self.assertEqual(len(wire.keys), 7)
96
+
97
+ def test_when_the_wait_runs_out_the_original_refusal_is_raised(self):
98
+ for wire, outcome, took in create([refusal("trial_busy")] * 1000, {"wait_for_capacity": 0.2}):
99
+ self.assertIsInstance(outcome, ConflictError)
100
+ self.assertEqual(outcome.code, "trial_busy")
101
+ self.assertGreater(len(wire.keys), 2)
102
+ self.assertGreaterEqual(took, 0.19)
103
+ self.assertLess(took, 1.5)
104
+
105
+ def test_zero_fails_at_once_on_the_client_or_on_one_call(self):
106
+ for wire, outcome, _ in create([refusal("trial_busy")], {"wait_for_capacity": 0}):
107
+ self.assertEqual(getattr(outcome, "code", None), "trial_busy")
108
+ self.assertEqual(len(wire.keys), 1)
109
+ for wire, outcome, _ in create([refusal("quota_exceeded")], wait_for_capacity=0):
110
+ self.assertEqual(getattr(outcome, "code", None), "quota_exceeded")
111
+ self.assertEqual(len(wire.keys), 1)
112
+
113
+ def test_a_request_that_can_never_fit_is_not_waited_for(self):
114
+ never = refusal("trial_busy", details={"field": "count", "concurrent": 8})
115
+ for wire, outcome, _ in create([never]):
116
+ self.assertEqual(getattr(outcome, "code", None), "trial_busy")
117
+ self.assertEqual(len(wire.keys), 1)
118
+
119
+ def test_only_a_create_waits(self):
120
+ # A fork's failure names copies that started, and its key replays that
121
+ # failure, so a fork is never retried by waiting.
122
+ wire = Wire([refusal("trial_busy")], False)
123
+ runtime = Runtime(api_key="rk", base_url="https://api.example.test")
124
+ runtime._t._http = wire
125
+ from withruntime._sync_client import Sandbox
126
+ sandbox = Sandbox(runtime._t, {"id": "sbx", "state": "running"})
127
+ with self.assertRaises(ConflictError):
128
+ sandbox.fork()
129
+ self.assertEqual(len(wire.keys), 1)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()
@@ -349,5 +349,56 @@ class Routes(unittest.TestCase):
349
349
  self.assertTrue(all(r.split(" ")[1].startswith("/v1/") for r in routes))
350
350
 
351
351
 
352
+ class Retryable(unittest.TestCase):
353
+ def test_refusals_that_clear_by_themselves_are_retryable(self):
354
+ from withruntime import ConflictError
355
+ self.assertTrue(ConflictError("x", code="trial_busy", status=409, details={"sandboxIds": []}).retryable)
356
+ self.assertTrue(ConflictError("x", code="no_capacity", status=409).retryable)
357
+ self.assertFalse(ConflictError("x", code="trial_busy", status=409, details={"field": "count"}).retryable)
358
+ self.assertFalse(ConflictError("x", code="sandbox_stopped", status=409).retryable)
359
+ self.assertFalse(ServiceUnavailableError("x", code="fork_unavailable", status=503).retryable)
360
+
361
+
362
+ class StreamedTruncation(unittest.TestCase):
363
+ """A streamed result says whether output was lost: False unless the server
364
+ sent a "truncated" event, which it does when a reader falls behind the
365
+ process's output buffer."""
366
+
367
+ START = {"type": "start", "processId": "p1"}
368
+ OUT = {"type": "stdout", "data": "hello\n", "offset": 0}
369
+ DROP = {"type": "truncated", "droppedBytes": 10, "resumeAt": 10}
370
+ EXIT = {"type": "exit", "exitCode": 0, "state": "exited", "timedOut": False}
371
+
372
+ def flags(self, result):
373
+ return (result.stdout_truncated, result.stderr_truncated)
374
+
375
+ def test_sync(self):
376
+ for events, expected in (([self.START, self.OUT, self.EXIT], (False, False)),
377
+ ([self.START, self.DROP, self.OUT, self.EXIT], (True, True))):
378
+ sbx = withruntime._Sandbox(None, {"id": ID})
379
+ sbx.exec_stream = lambda *_, _events=events, **__: iter(_events)
380
+ self.assertEqual(self.flags(sbx.exec("long job", on_stdout=lambda _: None)), expected)
381
+ proc = withruntime.Process(None, ID, {"id": "p1"})
382
+ proc.output = lambda *_, _events=events[1:], **__: iter(_events)
383
+ self.assertEqual(self.flags(proc.wait()), expected)
384
+
385
+ def test_async(self):
386
+ async def replay(events):
387
+ for event in events:
388
+ yield event
389
+
390
+ async def main():
391
+ for events, expected in (([self.START, self.OUT, self.EXIT], (False, False)),
392
+ ([self.START, self.DROP, self.OUT, self.EXIT], (True, True))):
393
+ sbx = withruntime.AsyncSandbox(None, {"id": ID})
394
+ sbx.exec_stream = lambda *_, _events=events, **__: replay(_events)
395
+ self.assertEqual(self.flags(await sbx.exec("long job", on_stdout=lambda _: None)), expected)
396
+ proc = withruntime.AsyncProcess(None, ID, {"id": "p1"})
397
+ proc.output = lambda *_, _events=events[1:], **__: replay(_events)
398
+ self.assertEqual(self.flags(await proc.wait()), expected)
399
+
400
+ asyncio.run(main())
401
+
402
+
352
403
  if __name__ == "__main__":
353
404
  unittest.main()