withruntime 0.3.1__tar.gz → 0.5.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 (102) hide show
  1. withruntime-0.5.0/PKG-INFO +135 -0
  2. withruntime-0.5.0/README.md +124 -0
  3. {withruntime-0.3.1 → withruntime-0.5.0}/pyproject.toml +7 -1
  4. withruntime-0.5.0/tests/test_audit.py +70 -0
  5. withruntime-0.5.0/tests/test_capacity_wait.py +133 -0
  6. {withruntime-0.3.1 → withruntime-0.5.0}/tests/test_client.py +60 -0
  7. withruntime-0.5.0/tests/test_daytona.py +363 -0
  8. withruntime-0.5.0/tests/test_deepagents.py +79 -0
  9. withruntime-0.5.0/tests/test_e2b_code_interpreter.py +130 -0
  10. withruntime-0.5.0/tests/test_e2b_sandbox.py +415 -0
  11. withruntime-0.5.0/tests/test_images_v2.py +137 -0
  12. withruntime-0.5.0/tests/test_lifecycle_settings.py +135 -0
  13. withruntime-0.5.0/tests/test_observability.py +134 -0
  14. withruntime-0.5.0/tests/test_openai_agents.py +456 -0
  15. withruntime-0.5.0/tests/test_proxy.py +267 -0
  16. withruntime-0.5.0/tests/test_secrets.py +91 -0
  17. withruntime-0.5.0/tests/test_switching.py +79 -0
  18. withruntime-0.5.0/tests/test_tools.py +115 -0
  19. withruntime-0.5.0/tests/test_vercel.py +282 -0
  20. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/__init__.py +10 -1
  21. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_client.py +160 -19
  22. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/__init__.py +11 -0
  23. withruntime-0.5.0/withruntime/_async_products/audit.py +26 -0
  24. withruntime-0.5.0/withruntime/_async_products/images.py +379 -0
  25. withruntime-0.5.0/withruntime/_async_products/observability.py +128 -0
  26. withruntime-0.5.0/withruntime/_async_products/secrets.py +38 -0
  27. withruntime-0.5.0/withruntime/_async_products/switching.py +36 -0
  28. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_clock.py +15 -0
  29. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_errors.py +21 -0
  30. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_http.py +28 -7
  31. withruntime-0.5.0/withruntime/_proxy.py +177 -0
  32. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_client.py +160 -19
  33. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/__init__.py +11 -0
  34. withruntime-0.5.0/withruntime/_sync_products/audit.py +27 -0
  35. withruntime-0.5.0/withruntime/_sync_products/images.py +380 -0
  36. withruntime-0.5.0/withruntime/_sync_products/observability.py +129 -0
  37. withruntime-0.5.0/withruntime/_sync_products/secrets.py +39 -0
  38. withruntime-0.5.0/withruntime/_sync_products/switching.py +37 -0
  39. withruntime-0.5.0/withruntime/_tunnel.py +330 -0
  40. withruntime-0.5.0/withruntime/_version.py +1 -0
  41. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_ws.py +6 -5
  42. withruntime-0.5.0/withruntime/daytona/__init__.py +74 -0
  43. withruntime-0.5.0/withruntime/daytona/_async_daytona.py +1029 -0
  44. withruntime-0.5.0/withruntime/daytona/_async_io.py +34 -0
  45. withruntime-0.5.0/withruntime/daytona/_core.py +917 -0
  46. withruntime-0.5.0/withruntime/daytona/_sync_daytona.py +1027 -0
  47. withruntime-0.5.0/withruntime/daytona/_sync_io.py +43 -0
  48. withruntime-0.5.0/withruntime/deepagents.py +131 -0
  49. withruntime-0.5.0/withruntime/e2b/__init__.py +57 -0
  50. withruntime-0.5.0/withruntime/e2b/_async_io.py +54 -0
  51. withruntime-0.5.0/withruntime/e2b/_async_sandbox.py +682 -0
  52. withruntime-0.5.0/withruntime/e2b/_core.py +509 -0
  53. withruntime-0.5.0/withruntime/e2b/_sync_io.py +41 -0
  54. withruntime-0.5.0/withruntime/e2b/_sync_sandbox.py +681 -0
  55. withruntime-0.5.0/withruntime/e2b/code_interpreter/__init__.py +14 -0
  56. withruntime-0.5.0/withruntime/e2b/code_interpreter/_async_ci.py +143 -0
  57. withruntime-0.5.0/withruntime/e2b/code_interpreter/_models.py +101 -0
  58. withruntime-0.5.0/withruntime/e2b/code_interpreter/_sync_ci.py +142 -0
  59. withruntime-0.5.0/withruntime/openai_agents.py +844 -0
  60. withruntime-0.5.0/withruntime/tools.py +201 -0
  61. withruntime-0.5.0/withruntime/vercel/__init__.py +12 -0
  62. withruntime-0.5.0/withruntime/vercel/_async_io.py +58 -0
  63. withruntime-0.5.0/withruntime/vercel/_async_sandbox.py +862 -0
  64. withruntime-0.5.0/withruntime/vercel/_core.py +438 -0
  65. withruntime-0.5.0/withruntime/vercel/_sync_io.py +51 -0
  66. withruntime-0.5.0/withruntime/vercel/_sync_sandbox.py +860 -0
  67. withruntime-0.5.0/withruntime/vercel/api.py +32 -0
  68. withruntime-0.5.0/withruntime/vercel/sandbox/__init__.py +56 -0
  69. withruntime-0.5.0/withruntime/vercel/sandbox/sync.py +26 -0
  70. withruntime-0.5.0/withruntime/webhooks.py +56 -0
  71. withruntime-0.5.0/withruntime.egg-info/PKG-INFO +135 -0
  72. withruntime-0.5.0/withruntime.egg-info/SOURCES.txt +93 -0
  73. withruntime-0.5.0/withruntime.egg-info/requires.txt +6 -0
  74. withruntime-0.3.1/PKG-INFO +0 -61
  75. withruntime-0.3.1/README.md +0 -54
  76. withruntime-0.3.1/withruntime/_async_products/images.py +0 -119
  77. withruntime-0.3.1/withruntime/_sync_products/images.py +0 -120
  78. withruntime-0.3.1/withruntime/_version.py +0 -1
  79. withruntime-0.3.1/withruntime.egg-info/PKG-INFO +0 -61
  80. withruntime-0.3.1/withruntime.egg-info/SOURCES.txt +0 -38
  81. {withruntime-0.3.1 → withruntime-0.5.0}/setup.cfg +0 -0
  82. {withruntime-0.3.1 → withruntime-0.5.0}/tests/test_images_volumes_interpreter.py +0 -0
  83. {withruntime-0.3.1 → withruntime-0.5.0}/tests/test_limits.py +0 -0
  84. {withruntime-0.3.1 → withruntime-0.5.0}/tests/test_previews_network_desktop.py +0 -0
  85. {withruntime-0.3.1 → withruntime-0.5.0}/tests/test_referrals.py +0 -0
  86. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/desktop.py +0 -0
  87. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/interpreter.py +0 -0
  88. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/limits.py +0 -0
  89. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/network.py +0 -0
  90. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/previews.py +0 -0
  91. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/referrals.py +0 -0
  92. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_async_products/volumes.py +0 -0
  93. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_connection.py +0 -0
  94. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/desktop.py +0 -0
  95. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/interpreter.py +0 -0
  96. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/limits.py +0 -0
  97. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/network.py +0 -0
  98. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/previews.py +0 -0
  99. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/referrals.py +0 -0
  100. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime/_sync_products/volumes.py +0 -0
  101. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime.egg-info/dependency_links.txt +0 -0
  102. {withruntime-0.3.1 → withruntime-0.5.0}/withruntime.egg-info/top_level.txt +0 -0
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: withruntime
3
+ Version: 0.5.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
+ 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"
11
+
12
+ # Runtime Cloud Python SDK
13
+
14
+ One client for every Runtime Cloud product, sync and async. Python 3.10 or
15
+ later, standard library only.
16
+
17
+ ```bash no-run
18
+ pip install withruntime
19
+ ```
20
+
21
+ The client uses `RUNTIME_API_KEY` when it is set, and otherwise the connection
22
+ this machine saved when `npx withruntime login` connected it (one browser
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.
28
+
29
+ ```python
30
+ from withruntime import Sandbox
31
+
32
+ with Sandbox.create() as sbx:
33
+ result = sbx.exec("python3 -c 'print(6 * 7)'")
34
+ print(result.exit_code, result.stdout)
35
+ ```
36
+
37
+ `Sandbox.create()` needs no arguments and returns once the sandbox is running;
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:
41
+
42
+ ```python
43
+ import asyncio
44
+ from withruntime import AsyncRuntime
45
+
46
+
47
+ async def main():
48
+ async with AsyncRuntime() as runtime:
49
+ async with await runtime.sandboxes.create() as sbx:
50
+ print((await sbx.exec("uname -a")).stdout)
51
+
52
+
53
+ asyncio.run(main())
54
+ ```
55
+
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,
58
+ list, glob, stat, move, remove, upload and download directories), `pause`,
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`,
63
+ `volumes`, `snapshots`, `limits`, `feedback` and `support`;
64
+ `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
65
+ what its agent may still spend today. Every write carries an
66
+ idempotency key, so retries never do anything twice; errors are typed and carry
67
+ `code`, `hint` and `request_id`.
68
+
69
+ ## Behind a proxy
70
+
71
+ The client reads `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` (upper or lower
72
+ case; lower wins when both are set), sync and async. Calls to the API and
73
+ terminal WebSockets go through `HTTPS_PROXY` as a CONNECT tunnel, with any
74
+ `user:password@` in its address sent to the proxy. `HTTP_PROXY` is used only
75
+ for `http://` addresses, never for the HTTPS API. `NO_PROXY` lists hosts to
76
+ reach directly, split by commas or spaces: a name covers its subdomains, a
77
+ leading dot is allowed, `host:port` limits it to one port, and `*` means every
78
+ host. A proxy address without a scheme is `http://`; only `http://` proxies are
79
+ supported, and anything else fails at once with `invalid_proxy`. When a call
80
+ cannot get through, the error names the proxy it tried (without the password),
81
+ for example `No answer from Runtime at https://api.withruntime.com through the
82
+ proxy http://proxy.internal:3128 (HTTPS_PROXY).`
83
+
84
+ ## Code written for E2B
85
+
86
+ `withruntime.e2b` runs code written for E2B's Python SDK on Runtime. Change
87
+ the import and set `RUNTIME_API_KEY`:
88
+
89
+ ```python no-run
90
+ from withruntime.e2b import Sandbox, AsyncSandbox # was: from e2b import ...
91
+ from withruntime.e2b.code_interpreter import Sandbox # was: from e2b_code_interpreter import Sandbox
92
+ ```
93
+
94
+ Sandboxes get E2B's defaults: 2 vCPU, 512 MiB and a 300-second timeout.
95
+ Timeouts are in seconds, as in E2B's Python SDK. `runtime_create={...}` passes
96
+ Runtime's own create fields, for example `{"funding": "trial"}`. What Runtime
97
+ does not do the way E2B does raises `NotSupportedException` before anything
98
+ happens, naming what to use instead. Importing `withruntime` alone does not
99
+ load it. The sync `Sandbox` is generated from the async one by
100
+ `scripts/generate_e2b_sync.py`.
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
+
131
+ Docs: https://withruntime.com/docs/python.
132
+
133
+ The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
134
+ 0.3.0. Both names still work: `withruntime-cloud` installs this package and
135
+ `import runtime_cloud` gives you the same classes.
@@ -0,0 +1,124 @@
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 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.
17
+
18
+ ```python
19
+ from withruntime import Sandbox
20
+
21
+ with Sandbox.create() as sbx:
22
+ result = sbx.exec("python3 -c 'print(6 * 7)'")
23
+ print(result.exit_code, result.stdout)
24
+ ```
25
+
26
+ `Sandbox.create()` needs no arguments and returns once the sandbox is running;
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:
30
+
31
+ ```python
32
+ import asyncio
33
+ from withruntime import AsyncRuntime
34
+
35
+
36
+ async def main():
37
+ async with AsyncRuntime() as runtime:
38
+ async with await runtime.sandboxes.create() as sbx:
39
+ print((await sbx.exec("uname -a")).stdout)
40
+
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
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,
47
+ list, glob, stat, move, remove, upload and download directories), `pause`,
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`,
52
+ `volumes`, `snapshots`, `limits`, `feedback` and `support`;
53
+ `runtime.limits.get()` (0.3.1 and later) says whether the key is read-only and
54
+ what its agent may still spend today. Every write carries an
55
+ idempotency key, so retries never do anything twice; errors are typed and carry
56
+ `code`, `hint` and `request_id`.
57
+
58
+ ## Behind a proxy
59
+
60
+ The client reads `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` (upper or lower
61
+ case; lower wins when both are set), sync and async. Calls to the API and
62
+ terminal WebSockets go through `HTTPS_PROXY` as a CONNECT tunnel, with any
63
+ `user:password@` in its address sent to the proxy. `HTTP_PROXY` is used only
64
+ for `http://` addresses, never for the HTTPS API. `NO_PROXY` lists hosts to
65
+ reach directly, split by commas or spaces: a name covers its subdomains, a
66
+ leading dot is allowed, `host:port` limits it to one port, and `*` means every
67
+ host. A proxy address without a scheme is `http://`; only `http://` proxies are
68
+ supported, and anything else fails at once with `invalid_proxy`. When a call
69
+ cannot get through, the error names the proxy it tried (without the password),
70
+ for example `No answer from Runtime at https://api.withruntime.com through the
71
+ proxy http://proxy.internal:3128 (HTTPS_PROXY).`
72
+
73
+ ## Code written for E2B
74
+
75
+ `withruntime.e2b` runs code written for E2B's Python SDK on Runtime. Change
76
+ the import and set `RUNTIME_API_KEY`:
77
+
78
+ ```python no-run
79
+ from withruntime.e2b import Sandbox, AsyncSandbox # was: from e2b import ...
80
+ from withruntime.e2b.code_interpreter import Sandbox # was: from e2b_code_interpreter import Sandbox
81
+ ```
82
+
83
+ Sandboxes get E2B's defaults: 2 vCPU, 512 MiB and a 300-second timeout.
84
+ Timeouts are in seconds, as in E2B's Python SDK. `runtime_create={...}` passes
85
+ Runtime's own create fields, for example `{"funding": "trial"}`. What Runtime
86
+ does not do the way E2B does raises `NotSupportedException` before anything
87
+ happens, naming what to use instead. Importing `withruntime` alone does not
88
+ load it. The sync `Sandbox` is generated from the async one by
89
+ `scripts/generate_e2b_sync.py`.
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
+
120
+ Docs: https://withruntime.com/docs/python.
121
+
122
+ The package was called `withruntime-cloud`, imported as `runtime_cloud`, until
123
+ 0.3.0. Both names still work: `withruntime-cloud` installs this package and
124
+ `import runtime_cloud` gives you the same classes.
@@ -4,12 +4,18 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.3.1"
7
+ version = "0.5.0"
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()
@@ -40,6 +40,15 @@ class Sync(unittest.TestCase):
40
40
  piped = sbx.exec(["cat"], stdin="in", env={"TOKEN": "secret"})
41
41
  self.assertIn("<in>", piped.stdout)
42
42
 
43
+ def test_fork_takes_funding_as_create_does(self):
44
+ # 23 September 2026: fork(funding="trial") was refused as "body.funding is
45
+ # not a known field". Forks are switched off on the fixture, so the real
46
+ # router's answer is the deliberate fork_unavailable, not a 400.
47
+ sbx = client().sandboxes.get(ID)
48
+ with self.assertRaises(ServiceUnavailableError) as caught:
49
+ sbx.fork(funding="trial")
50
+ self.assertEqual(caught.exception.code, "fork_unavailable")
51
+
43
52
  def test_check_raises_with_the_output(self):
44
53
  sbx = client().sandboxes.get(ID)
45
54
  with self.assertRaises(CommandError) as caught:
@@ -340,5 +349,56 @@ class Routes(unittest.TestCase):
340
349
  self.assertTrue(all(r.split(" ")[1].startswith("/v1/") for r in routes))
341
350
 
342
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
+
343
403
  if __name__ == "__main__":
344
404
  unittest.main()