withruntime 0.6.1__tar.gz → 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {withruntime-0.6.1/withruntime.egg-info → withruntime-0.7.1}/PKG-INFO +7 -2
- {withruntime-0.6.1 → withruntime-0.7.1}/README.md +1 -1
- {withruntime-0.6.1 → withruntime-0.7.1}/pyproject.toml +8 -1
- withruntime-0.7.1/tests/test_account_errors.py +46 -0
- withruntime-0.7.1/tests/test_billing.py +72 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_capacity_wait.py +9 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_client.py +3 -1
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_e2b_sandbox.py +42 -4
- withruntime-0.7.1/tests/test_eval_sandbox.py +43 -0
- withruntime-0.7.1/tests/test_exec_stream.py +131 -0
- withruntime-0.7.1/tests/test_files_download.py +137 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_harbor.py +3 -3
- withruntime-0.7.1/tests/test_late_answer.py +69 -0
- withruntime-0.7.1/tests/test_secrets.py +193 -0
- withruntime-0.7.1/tests/test_sso.py +29 -0
- withruntime-0.7.1/tests/test_wake_awake.py +34 -0
- withruntime-0.7.1/tests/test_wallet.py +55 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/__init__.py +4 -3
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_client.py +335 -57
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/__init__.py +6 -1
- withruntime-0.7.1/withruntime/_async_products/billing.py +31 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/images.py +2 -2
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/network_products.py +46 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/secrets.py +23 -3
- withruntime-0.7.1/withruntime/_async_products/sso.py +20 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_clock.py +10 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_errors.py +8 -1
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_eval_sandbox.py +18 -3
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_http.py +29 -5
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_proxy.py +2 -4
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_client.py +335 -57
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/__init__.py +6 -1
- withruntime-0.7.1/withruntime/_sync_products/billing.py +32 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/images.py +2 -2
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/network_products.py +46 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/secrets.py +23 -3
- withruntime-0.7.1/withruntime/_sync_products/sso.py +21 -0
- withruntime-0.7.1/withruntime/_version.py +1 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/_async_daytona.py +3 -6
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/_sync_daytona.py +3 -6
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/_async_io.py +14 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/_async_sandbox.py +30 -11
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/_core.py +20 -5
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/_sync_io.py +5 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/_sync_sandbox.py +30 -11
- withruntime-0.7.1/withruntime/wallet.py +62 -0
- {withruntime-0.6.1 → withruntime-0.7.1/withruntime.egg-info}/PKG-INFO +7 -2
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime.egg-info/SOURCES.txt +14 -0
- withruntime-0.6.1/tests/test_secrets.py +0 -91
- withruntime-0.6.1/withruntime/_version.py +0 -1
- {withruntime-0.6.1 → withruntime-0.7.1}/LICENSE +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/setup.cfg +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_async_stream_cleanup.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_audit.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_daytona.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_deepagents.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_e2b_code_interpreter.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_files_mode.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_identity_token.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_images_v2.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_images_volumes_interpreter.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_inspect_ai.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_lifecycle_settings.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_limits.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_mounts.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_network_funding.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_network_products.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_observability.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_openai_agents.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_previews_network_desktop.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_proxy.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_referrals.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_snapshot_after_fork.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_switching.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_tools.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_vercel.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_volume_backups.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/tests/test_watch.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/audit.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/desktop.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/interpreter.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/limits.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/mcp.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/mounts.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/network.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/observability.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/previews.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/referrals.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/switching.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/volumes.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_async_products/watch.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_connection.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/audit.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/desktop.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/interpreter.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/limits.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/mcp.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/mounts.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/network.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/observability.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/previews.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/referrals.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/switching.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/volumes.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_sync_products/watch.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_tunnel.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/_ws.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/__init__.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/_async_io.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/_core.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/daytona/_sync_io.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/deepagents.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/__init__.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/code_interpreter/__init__.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/code_interpreter/_models.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/harbor.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/inspect_ai.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/openai_agents.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/tools.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/__init__.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/_async_io.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/_async_sandbox.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/_core.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/_sync_io.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/_sync_sandbox.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/api.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/sandbox/__init__.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/vercel/sandbox/sync.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime/webhooks.py +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime.egg-info/dependency_links.txt +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime.egg-info/entry_points.txt +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime.egg-info/requires.txt +0 -0
- {withruntime-0.6.1 → withruntime-0.7.1}/withruntime.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withruntime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
|
|
5
5
|
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://withruntime.com
|
|
7
|
+
Project-URL: Documentation, https://withruntime.com/docs/python
|
|
8
|
+
Project-URL: Source, https://github.com/withruntime/runtime/tree/main/sdks/python
|
|
9
|
+
Project-URL: Issues, https://github.com/withruntime/runtime/issues
|
|
10
|
+
Keywords: sandbox,ai-agents,agents,microvm,firecracker,code-execution,code-interpreter,mcp,llm
|
|
6
11
|
Requires-Python: >=3.10
|
|
7
12
|
Description-Content-Type: text/markdown
|
|
8
13
|
License-File: LICENSE
|
|
@@ -43,7 +48,7 @@ with Sandbox.create() as sbx:
|
|
|
43
48
|
|
|
44
49
|
`Sandbox.create()` needs no arguments and returns once the sandbox is running;
|
|
45
50
|
leaving the `with` block stops it. With no arguments you get the free trial
|
|
46
|
-
while it lasts:
|
|
51
|
+
while it lasts: 100 free hours, no card, up to eight sandboxes running at once.
|
|
47
52
|
The current default image includes NumPy, pandas and matplotlib; see the
|
|
48
53
|
[sandbox environment](https://withruntime.com/docs/sandbox-environment).
|
|
49
54
|
`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:
|
|
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,12 +4,19 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "withruntime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.1"
|
|
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" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
dependencies = []
|
|
13
|
+
keywords = ["sandbox", "ai-agents", "agents", "microvm", "firecracker", "code-execution", "code-interpreter", "mcp", "llm"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://withruntime.com"
|
|
17
|
+
Documentation = "https://withruntime.com/docs/python"
|
|
18
|
+
Source = "https://github.com/withruntime/runtime/tree/main/sdks/python"
|
|
19
|
+
Issues = "https://github.com/withruntime/runtime/issues"
|
|
13
20
|
|
|
14
21
|
[project.optional-dependencies]
|
|
15
22
|
# withruntime.openai_agents: a sandbox client for the OpenAI Agents SDK's SandboxAgent.
|
|
@@ -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(
|
|
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)
|
|
@@ -145,6 +145,29 @@ class Commands(Base):
|
|
|
145
145
|
result = sbx.commands.run("python3 -c 'print(\"x\"*70000)'", timeout=60)
|
|
146
146
|
self.assertEqual((result.exit_code, len(result.stdout), len(result.stderr)), (0, 70_001, 70_000))
|
|
147
147
|
|
|
148
|
+
def test_output_dropped_before_it_was_read_is_marked_and_warned(self):
|
|
149
|
+
# A customer's report, 24 September 2026: output lost in a stream came
|
|
150
|
+
# back looking whole.
|
|
151
|
+
sbx = self.create()
|
|
152
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
153
|
+
warnings.simplefilter("always")
|
|
154
|
+
self.world.exec = lambda *_: Result(0, "tail\n", lost=True)
|
|
155
|
+
self.assertTrue(sbx.commands.run("big").truncated)
|
|
156
|
+
self.world.exec = lambda *_: Result(1, "tail\n", lost=True)
|
|
157
|
+
with self.assertRaises(CommandExitException) as failed:
|
|
158
|
+
sbx.commands.run("big")
|
|
159
|
+
self.assertTrue(failed.exception.truncated)
|
|
160
|
+
self.world.exec = lambda *_: Result(0, "whole\n")
|
|
161
|
+
self.assertFalse(sbx.commands.run("small").truncated)
|
|
162
|
+
self.assertEqual(len(caught), 2)
|
|
163
|
+
self.assertIn("output was dropped", str(caught[0].message))
|
|
164
|
+
self.world.output = lambda _: [{"type": "truncated", "droppedBytes": 10, "resumeAt": 10},
|
|
165
|
+
{"type": "stdout", "data": "tail\n", "offset": 10},
|
|
166
|
+
{"type": "exit", "exitCode": 0, "state": "exited", "timedOut": False}]
|
|
167
|
+
with warnings.catch_warnings():
|
|
168
|
+
warnings.simplefilter("ignore")
|
|
169
|
+
self.assertTrue(sbx.commands.run("big", background=True).wait().truncated)
|
|
170
|
+
|
|
148
171
|
def test_non_zero_exit(self):
|
|
149
172
|
self.world.exec = lambda *_: Result(3, "partial\n", "boom\n")
|
|
150
173
|
sbx = self.create()
|
|
@@ -340,11 +363,15 @@ class ForksPortsAndGaps(Base):
|
|
|
340
363
|
self.assertEqual(caught.exception.code, "fork_unavailable")
|
|
341
364
|
|
|
342
365
|
def test_ports(self):
|
|
366
|
+
# 25 September 2026: get_host raised, so E2B code did not run unchanged.
|
|
343
367
|
sbx = self.create()
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
self.
|
|
347
|
-
self.assertEqual(sbx.get_public_host(3000),
|
|
368
|
+
host = f"3000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com"
|
|
369
|
+
self.assertEqual(sbx.get_host(3000), host)
|
|
370
|
+
self.assertEqual(sbx.get_host(3000), host)
|
|
371
|
+
self.assertEqual(sbx.get_public_host(3000), host)
|
|
372
|
+
self.assertEqual(len(self.world.called("previews.create")), 1)
|
|
373
|
+
with self.assertRaises(e2b.InvalidArgumentException):
|
|
374
|
+
sbx.get_host(0)
|
|
348
375
|
|
|
349
376
|
def test_gaps(self):
|
|
350
377
|
sbx = self.create()
|
|
@@ -356,6 +383,17 @@ class ForksPortsAndGaps(Base):
|
|
|
356
383
|
|
|
357
384
|
|
|
358
385
|
class Async(unittest.TestCase):
|
|
386
|
+
def test_get_host_answers_at_once_and_the_share_lands_beside_it(self):
|
|
387
|
+
world = World()
|
|
388
|
+
|
|
389
|
+
async def scenario():
|
|
390
|
+
async with await AsyncSandbox.create(client=world.async_client()) as sbx:
|
|
391
|
+
host = sbx.get_host(8000) # no await, as in E2B
|
|
392
|
+
self.assertEqual(host, f"8000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com")
|
|
393
|
+
self.assertEqual(await sbx.get_public_host(8000), host)
|
|
394
|
+
self.assertEqual(len(world.called("previews.create")), 1)
|
|
395
|
+
asyncio.run(scenario())
|
|
396
|
+
|
|
359
397
|
def test_async_sandbox_end_to_end(self):
|
|
360
398
|
world = World()
|
|
361
399
|
|
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|