withruntime 0.6.1__tar.gz → 0.7.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 (135) hide show
  1. {withruntime-0.6.1/withruntime.egg-info → withruntime-0.7.0}/PKG-INFO +2 -2
  2. {withruntime-0.6.1 → withruntime-0.7.0}/README.md +1 -1
  3. {withruntime-0.6.1 → withruntime-0.7.0}/pyproject.toml +1 -1
  4. withruntime-0.7.0/tests/test_account_errors.py +46 -0
  5. withruntime-0.7.0/tests/test_billing.py +72 -0
  6. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_capacity_wait.py +9 -0
  7. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_client.py +3 -1
  8. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_e2b_sandbox.py +19 -4
  9. withruntime-0.7.0/tests/test_eval_sandbox.py +43 -0
  10. withruntime-0.7.0/tests/test_exec_stream.py +131 -0
  11. withruntime-0.7.0/tests/test_files_download.py +137 -0
  12. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_harbor.py +3 -3
  13. withruntime-0.7.0/tests/test_late_answer.py +69 -0
  14. withruntime-0.7.0/tests/test_secrets.py +193 -0
  15. withruntime-0.7.0/tests/test_sso.py +29 -0
  16. withruntime-0.7.0/tests/test_wake_awake.py +34 -0
  17. withruntime-0.7.0/tests/test_wallet.py +55 -0
  18. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/__init__.py +4 -3
  19. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_client.py +335 -57
  20. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/__init__.py +6 -1
  21. withruntime-0.7.0/withruntime/_async_products/billing.py +31 -0
  22. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/images.py +2 -2
  23. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/network_products.py +46 -0
  24. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/secrets.py +23 -3
  25. withruntime-0.7.0/withruntime/_async_products/sso.py +20 -0
  26. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_clock.py +10 -0
  27. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_errors.py +8 -1
  28. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_eval_sandbox.py +18 -3
  29. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_http.py +29 -5
  30. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_proxy.py +2 -4
  31. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_client.py +335 -57
  32. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/__init__.py +6 -1
  33. withruntime-0.7.0/withruntime/_sync_products/billing.py +32 -0
  34. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/images.py +2 -2
  35. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/network_products.py +46 -0
  36. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/secrets.py +23 -3
  37. withruntime-0.7.0/withruntime/_sync_products/sso.py +21 -0
  38. withruntime-0.7.0/withruntime/_version.py +1 -0
  39. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/_async_daytona.py +3 -6
  40. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/_sync_daytona.py +3 -6
  41. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/_async_io.py +14 -0
  42. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/_async_sandbox.py +24 -9
  43. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/_core.py +4 -0
  44. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/_sync_io.py +5 -0
  45. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/_sync_sandbox.py +24 -9
  46. withruntime-0.7.0/withruntime/wallet.py +62 -0
  47. {withruntime-0.6.1 → withruntime-0.7.0/withruntime.egg-info}/PKG-INFO +2 -2
  48. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime.egg-info/SOURCES.txt +14 -0
  49. withruntime-0.6.1/tests/test_secrets.py +0 -91
  50. withruntime-0.6.1/withruntime/_version.py +0 -1
  51. {withruntime-0.6.1 → withruntime-0.7.0}/LICENSE +0 -0
  52. {withruntime-0.6.1 → withruntime-0.7.0}/setup.cfg +0 -0
  53. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_async_stream_cleanup.py +0 -0
  54. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_audit.py +0 -0
  55. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_daytona.py +0 -0
  56. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_deepagents.py +0 -0
  57. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_e2b_code_interpreter.py +0 -0
  58. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_files_mode.py +0 -0
  59. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_identity_token.py +0 -0
  60. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_images_v2.py +0 -0
  61. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_images_volumes_interpreter.py +0 -0
  62. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_inspect_ai.py +0 -0
  63. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_lifecycle_settings.py +0 -0
  64. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_limits.py +0 -0
  65. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_mounts.py +0 -0
  66. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_network_funding.py +0 -0
  67. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_network_products.py +0 -0
  68. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_observability.py +0 -0
  69. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_openai_agents.py +0 -0
  70. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_previews_network_desktop.py +0 -0
  71. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_proxy.py +0 -0
  72. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_referrals.py +0 -0
  73. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_snapshot_after_fork.py +0 -0
  74. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_switching.py +0 -0
  75. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_tools.py +0 -0
  76. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_vercel.py +0 -0
  77. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_volume_backups.py +0 -0
  78. {withruntime-0.6.1 → withruntime-0.7.0}/tests/test_watch.py +0 -0
  79. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/audit.py +0 -0
  80. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/desktop.py +0 -0
  81. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/interpreter.py +0 -0
  82. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/limits.py +0 -0
  83. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/mcp.py +0 -0
  84. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/mounts.py +0 -0
  85. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/network.py +0 -0
  86. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/observability.py +0 -0
  87. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/previews.py +0 -0
  88. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/referrals.py +0 -0
  89. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/switching.py +0 -0
  90. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/volumes.py +0 -0
  91. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_async_products/watch.py +0 -0
  92. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_connection.py +0 -0
  93. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/audit.py +0 -0
  94. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/desktop.py +0 -0
  95. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/interpreter.py +0 -0
  96. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/limits.py +0 -0
  97. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/mcp.py +0 -0
  98. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/mounts.py +0 -0
  99. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/network.py +0 -0
  100. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/observability.py +0 -0
  101. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/previews.py +0 -0
  102. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/referrals.py +0 -0
  103. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/switching.py +0 -0
  104. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/volumes.py +0 -0
  105. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_sync_products/watch.py +0 -0
  106. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_tunnel.py +0 -0
  107. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/_ws.py +0 -0
  108. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/__init__.py +0 -0
  109. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/_async_io.py +0 -0
  110. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/_core.py +0 -0
  111. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/daytona/_sync_io.py +0 -0
  112. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/deepagents.py +0 -0
  113. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/__init__.py +0 -0
  114. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/__init__.py +0 -0
  115. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
  116. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_models.py +0 -0
  117. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
  118. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/harbor.py +0 -0
  119. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/inspect_ai.py +0 -0
  120. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/openai_agents.py +0 -0
  121. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/tools.py +0 -0
  122. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/__init__.py +0 -0
  123. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/_async_io.py +0 -0
  124. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/_async_sandbox.py +0 -0
  125. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/_core.py +0 -0
  126. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/_sync_io.py +0 -0
  127. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/_sync_sandbox.py +0 -0
  128. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/api.py +0 -0
  129. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/sandbox/__init__.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/vercel/sandbox/sync.py +0 -0
  131. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime/webhooks.py +0 -0
  132. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime.egg-info/dependency_links.txt +0 -0
  133. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime.egg-info/entry_points.txt +0 -0
  134. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime.egg-info/requires.txt +0 -0
  135. {withruntime-0.6.1 → withruntime-0.7.0}/withruntime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -43,7 +43,7 @@ with Sandbox.create() as sbx:
43
43
 
44
44
  `Sandbox.create()` needs no arguments and returns once the sandbox is running;
45
45
  leaving the `with` block stops it. With no arguments you get the free trial
46
- while it lasts: 50 free hours, no card, up to eight sandboxes running at once.
46
+ while it lasts: 100 free hours, no card, up to eight sandboxes running at once.
47
47
  The current default image includes NumPy, pandas and matplotlib; see the
48
48
  [sandbox environment](https://withruntime.com/docs/sandbox-environment).
49
49
  `AsyncRuntime` is the same client for asyncio, method for method:
@@ -25,7 +25,7 @@ with Sandbox.create() as sbx:
25
25
 
26
26
  `Sandbox.create()` needs no arguments and returns once the sandbox is running;
27
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.
28
+ while it lasts: 100 free hours, no card, up to eight sandboxes running at once.
29
29
  The current default image includes NumPy, pandas and matplotlib; see the
30
30
  [sandbox environment](https://withruntime.com/docs/sandbox-environment).
31
31
  `AsyncRuntime` is the same client for asyncio, method for method:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.6.1"
7
+ version = "0.7.0"
8
8
  description = "Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -0,0 +1,46 @@
1
+ """A blocked account gets its own error type (ARCHITECTURE.md section 11,
2
+ "A blocked account is told why")."""
3
+
4
+ import unittest
5
+ from typing import Any
6
+
7
+ import withruntime
8
+ from withruntime._errors import error_for
9
+
10
+
11
+ class AccountErrorsTest(unittest.TestCase):
12
+ def test_account_blocked_is_its_own_type(self) -> None:
13
+ error = error_for(402, {"error": {"code": "account_blocked",
14
+ "message": "This account cannot spend: a payment on it is disputed."}}, None)
15
+ self.assertIsInstance(error, withruntime.AccountBlockedError)
16
+ self.assertIsInstance(error, withruntime.RuntimeError)
17
+ self.assertEqual(error.code, "account_blocked")
18
+ self.assertEqual(error.status, 402)
19
+ self.assertFalse(error.retryable)
20
+
21
+ def test_empty_balance_stays_plain(self) -> None:
22
+ error = error_for(402, {"error": {"code": "insufficient_funds", "message": "Add credit."}}, None)
23
+ self.assertNotIsInstance(error, withruntime.AccountBlockedError)
24
+
25
+
26
+ class AccountCloseTest(unittest.TestCase):
27
+ """runtime.account.close (ARCHITECTURE.md section 3.1)."""
28
+
29
+ def test_close_posts_the_typed_name_once(self) -> None:
30
+ from withruntime._sync_client import Account
31
+
32
+ calls: list[tuple[Any, ...]] = []
33
+
34
+ class Transport:
35
+ def json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
36
+ calls.append((method, path, kwargs))
37
+ return {"orgId": "o1", "name": "Acme", "closedAt": "2026-09-25T20:00:00Z", "released": True}
38
+
39
+ closed = Account(Transport()).close(confirm="Acme") # type: ignore[arg-type]
40
+ self.assertTrue(closed["released"])
41
+ self.assertEqual(calls, [("POST", "/v1/account:close", {"body": {"confirm": "Acme"}, "retry": False})])
42
+ self.assertTrue(hasattr(withruntime.Runtime(api_key="rk_x"), "account"))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
@@ -0,0 +1,72 @@
1
+ """runtime.billing.topup() and topup_status() against a stub API, sync and
2
+ async. The route is tested in packages/cloud (billing-api)."""
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
+ TOPUP = {
12
+ "purchaseId": "3f0c2a4e-1b7d-4c5a-9e2f-0a1b2c3d4e5f",
13
+ "status": "open",
14
+ "amountUsd": "20.00",
15
+ "amount": "20.000000",
16
+ "payBy": "2026-09-25T13:00:00.000Z",
17
+ "networks": [{"network": "base", "address": "0xbase", "tokens": [{"currency": "usdc", "contract": "0xusdc"}]}],
18
+ }
19
+
20
+
21
+ class Stub(BaseHTTPRequestHandler):
22
+ seen: list = []
23
+
24
+ def log_message(self, *args):
25
+ pass
26
+
27
+ def answer(self):
28
+ length = int(self.headers.get("content-length") or 0)
29
+ body = json.loads(self.rfile.read(length)) if length else None
30
+ Stub.seen.append((self.command, self.path, body, self.headers.get("idempotency-key")))
31
+ data = json.dumps(TOPUP).encode()
32
+ self.send_response(200)
33
+ self.send_header("content-type", "application/json")
34
+ self.send_header("content-length", str(len(data)))
35
+ self.end_headers()
36
+ self.wfile.write(data)
37
+
38
+ do_GET = answer
39
+ do_POST = answer
40
+
41
+
42
+ class BillingTest(unittest.TestCase):
43
+ @classmethod
44
+ def setUpClass(cls):
45
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
46
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
47
+ cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
48
+
49
+ @classmethod
50
+ def tearDownClass(cls):
51
+ cls.server.shutdown()
52
+
53
+ def setUp(self):
54
+ Stub.seen = []
55
+
56
+ def test_sync_topup_and_status(self):
57
+ with Runtime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
58
+ self.assertEqual(runtime.billing.topup(20, idempotency_key="k1")["amount"], "20.000000")
59
+ self.assertEqual(runtime.billing.topup_status(TOPUP["purchaseId"])["status"], "open")
60
+ self.assertEqual(Stub.seen[0], ("POST", "/v1/billing/topups", {"usd": 20}, "k1"))
61
+ self.assertEqual(Stub.seen[1][:2], ("GET", f"/v1/billing/topups/{TOPUP['purchaseId']}"))
62
+
63
+ def test_async_topup(self):
64
+ async def go():
65
+ async with AsyncRuntime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
66
+ return await runtime.billing.topup(20)
67
+
68
+ self.assertEqual(asyncio.run(go())["networks"][0]["address"], "0xbase")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()
@@ -84,6 +84,15 @@ class CapacityWait(unittest.TestCase):
84
84
  self.assertEqual(len(set(wire.bodies)), 1)
85
85
  self.assertEqual(json.loads(wire.bodies[0]), {"funding": "trial", "labels": {"ci": "1"}})
86
86
 
87
+ def test_a_create_says_why_it_waits_before_each_wait(self):
88
+ heard = []
89
+ busy = refusal("trial_busy")
90
+ for _, outcome, _ in create([busy, busy], on_capacity_wait=lambda error, seconds: heard.append(
91
+ (error.code, seconds))):
92
+ self.assertEqual(outcome.id, "sbx")
93
+ # Sync, then async: two waits each, of retryAfterMs.
94
+ self.assertEqual(heard, [("trial_busy", 0.005)] * 4)
95
+
87
96
  def test_the_paid_limit_a_domain_limit_and_a_full_region_are_waited_out_too(self):
88
97
  for code in ("quota_exceeded", "trial_domain_limit", "no_capacity"):
89
98
  for wire, outcome, _ in create([refusal(code)]):
@@ -83,8 +83,10 @@ class Sync(unittest.TestCase):
83
83
  self.assertEqual(seen, ["hello\n"])
84
84
 
85
85
  def test_errors_are_typed_and_keys_never_leak(self):
86
+ # A misspelled field is refused before sending (test_exec_stream.CreateFields);
87
+ # a value out of range is the API's to refuse.
86
88
  with self.assertRaises(InvalidRequestError) as caught:
87
- client().sandboxes.create(vcpus=2)
89
+ client().sandboxes.create(vcpu=0)
88
90
  self.assertIn("issues", caught.exception.details)
89
91
  self.assertNotIn("rk_test", str(caught.exception))
90
92
  self.assertTrue(ServiceUnavailableError("x", status=503).retryable)
@@ -340,11 +340,15 @@ class ForksPortsAndGaps(Base):
340
340
  self.assertEqual(caught.exception.code, "fork_unavailable")
341
341
 
342
342
  def test_ports(self):
343
+ # 25 September 2026: get_host raised, so E2B code did not run unchanged.
343
344
  sbx = self.create()
344
- with self.assertRaises(NotSupportedException) as caught:
345
- sbx.get_host(3000)
346
- self.assertIn("get_public_host", str(caught.exception))
347
- self.assertEqual(sbx.get_public_host(3000), f"3000-{sbx.sandbox_id}.runtimehost.com")
345
+ host = f"3000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com"
346
+ self.assertEqual(sbx.get_host(3000), host)
347
+ self.assertEqual(sbx.get_host(3000), host)
348
+ self.assertEqual(sbx.get_public_host(3000), host)
349
+ self.assertEqual(len(self.world.called("previews.create")), 1)
350
+ with self.assertRaises(e2b.InvalidArgumentException):
351
+ sbx.get_host(0)
348
352
 
349
353
  def test_gaps(self):
350
354
  sbx = self.create()
@@ -356,6 +360,17 @@ class ForksPortsAndGaps(Base):
356
360
 
357
361
 
358
362
  class Async(unittest.TestCase):
363
+ def test_get_host_answers_at_once_and_the_share_lands_beside_it(self):
364
+ world = World()
365
+
366
+ async def scenario():
367
+ async with await AsyncSandbox.create(client=world.async_client()) as sbx:
368
+ host = sbx.get_host(8000) # no await, as in E2B
369
+ self.assertEqual(host, f"8000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com")
370
+ self.assertEqual(await sbx.get_public_host(8000), host)
371
+ self.assertEqual(len(world.called("previews.create")), 1)
372
+ asyncio.run(scenario())
373
+
359
374
  def test_async_sandbox_end_to_end(self):
360
375
  world = World()
361
376
 
@@ -0,0 +1,43 @@
1
+ """Exit codes in withruntime._eval_sandbox, which Harbor and Inspect share, as the
2
+ live API reports them (read on 25 September 2026): a command past its limit ends
3
+ with -9 and timedOut, and a command a signal ended with the negative signal
4
+ number. Harbor and Inspect expect what a shell reports. Needs neither."""
5
+ import asyncio
6
+ import unittest
7
+
8
+ from withruntime._eval_sandbox import _Shell, shell_exit_code
9
+
10
+
11
+ class _Sandbox:
12
+ id = "sbx_exit"
13
+
14
+ def __init__(self, exit_event):
15
+ self.exit_event = exit_event
16
+
17
+ async def exec_stream(self, argv, *, env=None, stdin=None, timeout_ms=None):
18
+ yield {"type": "start", "processId": "p1"}
19
+ yield self.exit_event
20
+
21
+
22
+ def _run(exit_event):
23
+ shell = _Shell(_Sandbox(exit_event), "/tmp")
24
+ return asyncio.run(shell._stream(["sh", "-c", "x"], None, None, 1000, None, None))
25
+
26
+
27
+ class ShellExitCodeTest(unittest.TestCase):
28
+ def test_a_timed_out_command_is_124(self):
29
+ result = _run({"type": "exit", "exitCode": -9, "state": "timed_out", "timedOut": True})
30
+ self.assertEqual((result.exit_code, result.timed_out), (124, True))
31
+
32
+ def test_a_command_a_signal_ended_is_128_plus_the_signal(self):
33
+ self.assertEqual(_run({"type": "exit", "exitCode": -15, "timedOut": False}).exit_code, 143)
34
+ self.assertEqual(_run({"type": "exit", "exitCode": -9, "timedOut": False}).exit_code, 137)
35
+
36
+ def test_ordinary_codes_are_kept(self):
37
+ self.assertEqual(_run({"type": "exit", "exitCode": 3, "timedOut": False}).exit_code, 3)
38
+ self.assertEqual(shell_exit_code(0, False), 0)
39
+ self.assertEqual(shell_exit_code(None, False), -1)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()
@@ -0,0 +1,131 @@
1
+ """A streamed exec that loses output or its connection (user lane, 25 September
2
+ 2026): lost output never passes as whole, and a connection cut after the command
3
+ started does not end the command's output. Sync and async share the code, so
4
+ both are driven here over a fake wire."""
5
+ import asyncio
6
+ import json
7
+ import unittest
8
+
9
+ from withruntime import AsyncRuntime, ConnectionError, Runtime
10
+
11
+ ID = "8a1f9c2e-0d1b-4c3a-9e8f-7a6b5c4d3e2f"
12
+ INFO = json.dumps({"id": ID, "kind": "sandbox", "state": "running", "status": "active"}).encode()
13
+
14
+
15
+ class Wire:
16
+ """Answers each path with scripted NDJSON lines, then an end or a failure."""
17
+
18
+ def __init__(self, routes, is_async):
19
+ self.routes, self.is_async, self.targets = routes, is_async, []
20
+
21
+ def send(self, method, target, headers, data, timeout):
22
+ self.targets.append(f"{method} {target}")
23
+ lines, fail = next(((lines, fail) for suffix, (lines, fail) in self.routes.items()
24
+ if target.split("?")[0].endswith(suffix)), ([], False))
25
+ body = [json.dumps(line) for line in lines]
26
+ is_async = self.is_async
27
+
28
+ class Answer:
29
+ status, headers = 200, {}
30
+
31
+ if is_async:
32
+ async def read(self):
33
+ return INFO
34
+
35
+ async def lines(self):
36
+ for line in body:
37
+ yield line
38
+ if fail:
39
+ raise OSError("connection reset")
40
+
41
+ async def close(self):
42
+ return None
43
+ else:
44
+ def read(self):
45
+ return INFO
46
+
47
+ def lines(self):
48
+ yield from body
49
+ if fail:
50
+ raise OSError("connection reset")
51
+
52
+ def close(self):
53
+ return None
54
+ answer = Answer()
55
+ if self.is_async:
56
+ async def later():
57
+ return answer
58
+ return later()
59
+ return answer
60
+
61
+
62
+ def run_exec(routes):
63
+ """One streamed exec, sync and async: each run's wire and outcome."""
64
+ runs = []
65
+ for is_async in (False, True):
66
+ wire = Wire(routes, is_async)
67
+ runtime = (AsyncRuntime if is_async else Runtime)(api_key="rk", base_url="https://api.example.test")
68
+ runtime._t._http = wire
69
+
70
+ def call():
71
+ if is_async:
72
+ async def go():
73
+ sbx = await runtime.sandboxes.get(ID)
74
+ return await sbx.exec("x", on_stdout=lambda _: None)
75
+ return asyncio.run(go())
76
+ return runtime.sandboxes.get(ID).exec("x", on_stdout=lambda _: None)
77
+ try:
78
+ outcome = call()
79
+ except Exception as error: # noqa: BLE001 - the outcome under test
80
+ outcome = error
81
+ runs.append((wire, outcome))
82
+ return runs
83
+
84
+
85
+ EXIT = {"type": "exit", "exitCode": 0, "state": "exited", "timedOut": False}
86
+
87
+
88
+ class ExecStream(unittest.TestCase):
89
+ def test_output_the_stream_skipped_is_reported_lost_even_unnamed(self):
90
+ routes = {":exec": ([{"type": "start", "processId": "p1"}, {"type": "stdout", "data": "a\n", "offset": 0},
91
+ {"type": "stdout", "data": "b\n", "offset": 10}, EXIT], False)}
92
+ for _, result in run_exec(routes):
93
+ self.assertEqual(result.exit_code, 0)
94
+ self.assertEqual(result.stdout, "a\nb\n")
95
+ self.assertTrue(result.stdout_truncated)
96
+
97
+ def test_a_cut_after_the_start_is_followed_from_where_it_stopped(self):
98
+ routes = {":exec": ([{"type": "start", "processId": "p1"}, {"type": "stdout", "data": "a\n", "offset": 0}], True),
99
+ "/processes/p1/output": ([{"type": "stdout", "data": "b\n", "offset": 2}, EXIT], False)}
100
+ for wire, result in run_exec(routes):
101
+ self.assertEqual(result.stdout, "a\nb\n")
102
+ self.assertFalse(result.stdout_truncated)
103
+ self.assertIn(f"GET /v1/sandboxes/{ID}/processes/p1/output?cursor=2&follow=true", wire.targets)
104
+
105
+ def test_a_stream_that_keeps_closing_is_given_up_naming_the_process(self):
106
+ routes = {":exec": ([{"type": "start", "processId": "p1"}], False), "/processes/p1/output": ([], False)}
107
+ for wire, error in run_exec(routes):
108
+ self.assertIsInstance(error, ConnectionError)
109
+ self.assertEqual(error.details, {"sandboxId": ID, "processId": "p1"})
110
+ self.assertEqual(sum("/output" in t for t in wire.targets), 4)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ unittest.main()
115
+
116
+
117
+ class CreateFields(unittest.TestCase):
118
+ """A misspelled create field is refused before anything is sent, naming the
119
+ one meant (user lane: memory_mb= reached the API as "memoryMb is not a
120
+ known field")."""
121
+
122
+ def test_a_misspelled_field_names_the_one_meant(self):
123
+ runtime = Runtime(api_key="rk", base_url="https://api.example.test")
124
+ runtime._t._http = None # nothing may be sent
125
+ with self.assertRaises(TypeError) as caught:
126
+ runtime.sandboxes.create(memory_mb=8192)
127
+ self.assertIn("Did you mean 'memory_mib'?", str(caught.exception))
128
+
129
+ def test_snake_and_camel_names_both_pass(self):
130
+ from withruntime._sync_client import _check_create_fields
131
+ _check_create_fields({"memory_mib": 1, "memoryMiB": 1, "snapshot_id": "s", "get_or_create": True})
@@ -0,0 +1,137 @@
1
+ """A download that arrives short is never returned short (25 September 2026).
2
+
3
+ `s.files.read` of a 50 MB file returned 44 MB and raised nothing: a stream the
4
+ controller broke part way reached the client as a clean end of a chunked
5
+ body. The API now sends every body's length first (x-content-length) and a
6
+ small one's SHA-256; the SDK reads a short body again, then raises
7
+ ``download_incomplete``. A real HTTP server here, so the chunked parsing in
8
+ ``_http.py`` is what is tested, not a stand-in."""
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import threading
15
+ import unittest
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+
18
+ from withruntime import AsyncRuntime, Runtime
19
+ from withruntime._errors import RuntimeError
20
+
21
+ ID = "8a1f9c2e-0d1b-4c3a-9e8f-7a6b5c4d3e2f"
22
+ WHOLE = bytes((i * 31) & 0xFF for i in range(3 * 1_048_576 + 7))
23
+
24
+
25
+ class World:
26
+ def __init__(self, cuts, sha=None):
27
+ self.cuts, self.sha, self.reads = list(cuts), sha, 0
28
+ world = self
29
+
30
+ class Handler(BaseHTTPRequestHandler):
31
+ protocol_version = "HTTP/1.1"
32
+
33
+ def log_message(self, *args):
34
+ pass
35
+
36
+ def do_GET(self):
37
+ if "/files/stat" in self.path:
38
+ return self.json({"exists": True, "type": "file", "path": "/workspace/big.bin"})
39
+ if "/files/content" in self.path:
40
+ cut = world.cuts[world.reads] if world.reads < len(world.cuts) else None
41
+ world.reads += 1
42
+ body = WHOLE if cut is None else WHOLE[:cut]
43
+ self.send_response(200)
44
+ self.send_header("content-type", "application/octet-stream")
45
+ self.send_header("transfer-encoding", "chunked")
46
+ self.send_header("x-content-length", str(len(WHOLE)))
47
+ if world.sha:
48
+ self.send_header("x-content-sha256", world.sha)
49
+ self.end_headers()
50
+ for at in range(0, len(body), 65536):
51
+ piece = body[at:at + 65536]
52
+ self.wfile.write(b"%x\r\n" % len(piece) + piece + b"\r\n")
53
+ # A clean end even when short: what Caddy 2.6 sends.
54
+ self.wfile.write(b"0\r\n\r\n")
55
+ return
56
+ return self.json({"id": ID, "kind": "sandbox", "state": "running", "status": "active"})
57
+
58
+ def json(self, value):
59
+ data = json.dumps(value).encode()
60
+ self.send_response(200)
61
+ self.send_header("content-type", "application/json")
62
+ self.send_header("content-length", str(len(data)))
63
+ self.end_headers()
64
+ self.wfile.write(data)
65
+
66
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
67
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
68
+ self.url = f"http://localhost:{self.server.server_address[1]}"
69
+
70
+ def close(self):
71
+ self.server.shutdown()
72
+ self.server.server_close()
73
+
74
+
75
+ class Downloads(unittest.TestCase):
76
+ def world(self, cuts, sha=None):
77
+ world = World(cuts, sha)
78
+ self.addCleanup(world.close)
79
+ return world, Runtime(api_key="rt_test", base_url=world.url, max_retries=0)
80
+
81
+ def test_short_read_is_read_again(self):
82
+ world, runtime = self.world([2_966_523, 1_441_474])
83
+ self.assertEqual(runtime.sandboxes.get(ID).files.read("/workspace/big.bin"), WHOLE)
84
+ self.assertEqual(world.reads, 3)
85
+
86
+ def test_read_that_stays_short_raises(self):
87
+ world, runtime = self.world([10, 20, 30, 40])
88
+ with self.assertRaises(RuntimeError) as caught:
89
+ runtime.sandboxes.get(ID).files.read("/workspace/big.bin")
90
+ self.assertEqual(caught.exception.code, "download_incomplete")
91
+ self.assertEqual(caught.exception.details, {"received": 30, "expected": len(WHOLE)})
92
+ self.assertEqual(world.reads, 3)
93
+
94
+ def test_digest_mismatch_raises(self):
95
+ _, runtime = self.world([], sha="0" * 64)
96
+ with self.assertRaises(RuntimeError):
97
+ runtime.sandboxes.get(ID).files.read("/workspace/big.bin")
98
+ _, runtime = self.world([], sha=hashlib.sha256(WHOLE).hexdigest())
99
+ self.assertEqual(runtime.sandboxes.get(ID).files.read("/workspace/big.bin"), WHOLE)
100
+
101
+ def test_read_stream_raises_when_short(self):
102
+ _, runtime = self.world([2_000_000])
103
+ files = runtime.sandboxes.get(ID).files
104
+ with self.assertRaises(RuntimeError) as caught:
105
+ b"".join(files.read_stream("/workspace/big.bin"))
106
+ self.assertEqual(caught.exception.code, "download_incomplete")
107
+ self.assertEqual(b"".join(files.read_stream("/workspace/big.bin")), WHOLE)
108
+
109
+ def test_download_never_leaves_a_short_file(self):
110
+ with tempfile.TemporaryDirectory() as directory:
111
+ _, runtime = self.world([1_000_000])
112
+ target = os.path.join(directory, "o.bin")
113
+ runtime.sandboxes.get(ID).files.download("/workspace/big.bin", target)
114
+ with open(target, "rb") as handle:
115
+ self.assertEqual(handle.read(), WHOLE)
116
+ _, runtime = self.world([1, 2, 3, 4])
117
+ with self.assertRaises(RuntimeError):
118
+ runtime.sandboxes.get(ID).files.download("/workspace/big.bin", os.path.join(directory, "lost.bin"))
119
+ self.assertEqual(os.listdir(directory), ["o.bin"])
120
+
121
+ def test_async_read_and_stream(self):
122
+ world = World([5, 6])
123
+ self.addCleanup(world.close)
124
+
125
+ async def run():
126
+ async with AsyncRuntime(api_key="rt_test", base_url=world.url, max_retries=0) as runtime:
127
+ files = (await runtime.sandboxes.get(ID)).files
128
+ self.assertEqual(await files.read("/workspace/big.bin"), WHOLE)
129
+ world.cuts, world.reads = [7], 0
130
+ with self.assertRaises(RuntimeError):
131
+ async for _ in files.read_stream("/workspace/big.bin"):
132
+ pass
133
+ asyncio.run(run())
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -145,9 +145,9 @@ class FakeSandbox:
145
145
  yield {"type": "stdout", "offset": 0, "data": out.decode("utf-8", "replace")}
146
146
  if err:
147
147
  yield {"type": "stderr", "offset": 0, "data": err.decode("utf-8", "replace")}
148
- code = proc.returncode
149
- yield {"type": "exit", "exitCode": None if timed_out else (128 - code if code < 0 else code),
150
- "timedOut": timed_out}
148
+ # As the live API reports it: a command past its limit is killed and reads -9,
149
+ # and a command a signal ended reads the negative signal number (-15, -9).
150
+ yield {"type": "exit", "exitCode": -9 if timed_out else proc.returncode, "timedOut": timed_out}
151
151
 
152
152
  def keep_alive(self, every_seconds=60, margin_seconds=600):
153
153
  self.alive = True
@@ -0,0 +1,69 @@
1
+ """An answer later than the API holds its headers arrives as a 200 with
2
+ runtime-late-answer: true, then the answer or {"error": ...}. A failure is
3
+ raised as its typed error (packages/cloud/src/api/hold.ts). Sync and async."""
4
+ import asyncio
5
+ import json
6
+ import unittest
7
+
8
+ from withruntime import AsyncRuntime, NotFoundError, Runtime
9
+
10
+ ID = "8a1f9c2e-0d1b-4c3a-9e8f-7a6b5c4d3e2f"
11
+
12
+
13
+ class Wire:
14
+ def __init__(self, body, is_async):
15
+ self.body, self.is_async = body, is_async
16
+
17
+ def send(self, method, target, headers, data, timeout):
18
+ body = self.body.encode()
19
+ is_async = self.is_async
20
+
21
+ class Answer:
22
+ status, headers = 200, {"runtime-late-answer": "true"}
23
+ if is_async:
24
+ async def read(self):
25
+ return body
26
+ else:
27
+ def read(self):
28
+ return body
29
+ if is_async:
30
+ async def later():
31
+ return Answer()
32
+ return later()
33
+ return Answer()
34
+
35
+
36
+ def call(body):
37
+ outcomes = []
38
+ for is_async in (False, True):
39
+ runtime = (AsyncRuntime if is_async else Runtime)(api_key="rk", base_url="https://api.example.test")
40
+ runtime._t._http = Wire(body, is_async)
41
+ try:
42
+ if is_async:
43
+ outcomes.append(asyncio.run(runtime._t.json("POST", f"/v1/sandboxes/{ID}:exec", body={})))
44
+ else:
45
+ outcomes.append(runtime._t.json("POST", f"/v1/sandboxes/{ID}:exec", body={}))
46
+ except Exception as error: # noqa: BLE001 - the outcome under test
47
+ outcomes.append(error)
48
+ return outcomes
49
+
50
+
51
+ class LateAnswer(unittest.TestCase):
52
+ def test_a_late_success_is_the_answer(self):
53
+ for outcome in call('\n\n{"exitCode": 0, "stdout": "done"}'):
54
+ self.assertEqual(outcome["stdout"], "done")
55
+
56
+ def test_a_late_failure_is_raised_typed(self):
57
+ body = json.dumps({"error": {"code": "sandbox_not_found", "status": 404, "message": "Gone.",
58
+ "requestId": "req_1"}})
59
+ for outcome in call("\n" + body):
60
+ self.assertIsInstance(outcome, NotFoundError)
61
+ self.assertEqual(outcome.request_id, "req_1")
62
+
63
+ def test_an_answer_with_its_own_error_field_stays_an_answer(self):
64
+ for outcome in call(json.dumps({"status": "error", "error": {"name": "ValueError"}})):
65
+ self.assertEqual(outcome["error"]["name"], "ValueError")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()