runcloud-sdk 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.
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Newly
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: runcloud-sdk
3
+ Version: 0.7.0
4
+ Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
+ Author: Newly
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://run.cloud
8
+ Project-URL: Documentation, https://docs.run.cloud
9
+ Project-URL: Repository, https://github.com/newly-app/newly/tree/main/run-cloud/python-sdk
10
+ Keywords: runcloud,run-cloud,sandbox,microvm,firecracker,sdk
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # runcloud
19
+
20
+ Python SDK for [run.cloud](https://run.cloud).
21
+
22
+ ```bash
23
+ pip install runcloud-sdk
24
+ export RUN_CLOUD_API_KEY="rc_live_..."
25
+ ```
26
+
27
+ ```python
28
+ from runcloud import Client
29
+
30
+ cloud = Client()
31
+ box = cloud.create(image="runcloud/agent-base", cpu=2, memory=4096)
32
+ try:
33
+ result = box.exec("python -V")
34
+ print(result.stdout)
35
+ finally:
36
+ box.destroy()
37
+ ```
38
+
39
+ ## Compatibility adapters
40
+
41
+ The same distribution includes migration adapters:
42
+
43
+ | Provider | Import |
44
+ | --- | --- |
45
+ | Modal | `from runcloud.compat import modal` |
46
+ | E2B | `from runcloud.compat.e2b import Sandbox` |
47
+ | Daytona | `from runcloud.compat.daytona import Daytona` |
48
+ | Vercel Sandbox | `from runcloud.compat.vercel import Sandbox` |
49
+ | Blaxel async | `from runcloud.compat.blaxel import SandboxInstance` |
50
+ | Blaxel sync | `from runcloud.compat.blaxel import SyncSandboxInstance` |
51
+ | Fly Sprites | `from runcloud.compat.sprites import SpritesClient` |
52
+
53
+ Adapters cover creation, command execution, lookup/listing, and destruction
54
+ where the upstream SDK exposes them. Blaxel's current `SandboxInstance` API is
55
+ async; `SyncSandboxInstance` preserves its synchronous API. Unsupported
56
+ provider-specific features raise `UnsupportedCompatibilityFeatureError` with a
57
+ migration hint. They do not silently pretend that run.cloud implements a
58
+ provider-specific feature.
@@ -0,0 +1,41 @@
1
+ # runcloud
2
+
3
+ Python SDK for [run.cloud](https://run.cloud).
4
+
5
+ ```bash
6
+ pip install runcloud-sdk
7
+ export RUN_CLOUD_API_KEY="rc_live_..."
8
+ ```
9
+
10
+ ```python
11
+ from runcloud import Client
12
+
13
+ cloud = Client()
14
+ box = cloud.create(image="runcloud/agent-base", cpu=2, memory=4096)
15
+ try:
16
+ result = box.exec("python -V")
17
+ print(result.stdout)
18
+ finally:
19
+ box.destroy()
20
+ ```
21
+
22
+ ## Compatibility adapters
23
+
24
+ The same distribution includes migration adapters:
25
+
26
+ | Provider | Import |
27
+ | --- | --- |
28
+ | Modal | `from runcloud.compat import modal` |
29
+ | E2B | `from runcloud.compat.e2b import Sandbox` |
30
+ | Daytona | `from runcloud.compat.daytona import Daytona` |
31
+ | Vercel Sandbox | `from runcloud.compat.vercel import Sandbox` |
32
+ | Blaxel async | `from runcloud.compat.blaxel import SandboxInstance` |
33
+ | Blaxel sync | `from runcloud.compat.blaxel import SyncSandboxInstance` |
34
+ | Fly Sprites | `from runcloud.compat.sprites import SpritesClient` |
35
+
36
+ Adapters cover creation, command execution, lookup/listing, and destruction
37
+ where the upstream SDK exposes them. Blaxel's current `SandboxInstance` API is
38
+ async; `SyncSandboxInstance` preserves its synchronous API. Unsupported
39
+ provider-specific features raise `UnsupportedCompatibilityFeatureError` with a
40
+ migration hint. They do not silently pretend that run.cloud implements a
41
+ provider-specific feature.
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "runcloud-sdk"
3
+ version = "0.7.0"
4
+ description = "Python SDK for run.cloud, including sandbox-provider compatibility adapters"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "Apache-2.0"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Newly" }]
10
+ keywords = ["runcloud", "run-cloud", "sandbox", "microvm", "firecracker", "sdk"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+ dependencies = []
16
+
17
+ [project.urls]
18
+ Homepage = "https://run.cloud"
19
+ Documentation = "https://docs.run.cloud"
20
+ Repository = "https://github.com/newly-app/newly/tree/main/run-cloud/python-sdk"
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=77"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
28
+ include = ["runcloud*"]
@@ -0,0 +1,17 @@
1
+ """Public Python SDK for run.cloud."""
2
+
3
+ from .client import (
4
+ Client,
5
+ ExecResult,
6
+ RunCloudError,
7
+ Sandbox,
8
+ UnsupportedCompatibilityFeatureError,
9
+ )
10
+
11
+ __all__ = [
12
+ "Client",
13
+ "ExecResult",
14
+ "RunCloudError",
15
+ "Sandbox",
16
+ "UnsupportedCompatibilityFeatureError",
17
+ ]
@@ -0,0 +1,209 @@
1
+ """Dependency-free Python client for the public run.cloud API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable, Optional, Union
13
+
14
+ Transport = Callable[[str, str, Any], Any]
15
+
16
+
17
+ class RunCloudError(Exception):
18
+ def __init__(self, status: int, detail: str):
19
+ super().__init__(f"run.cloud API {status}: {detail}")
20
+ self.status = status
21
+ self.detail = detail
22
+
23
+
24
+ class UnsupportedCompatibilityFeatureError(Exception):
25
+ def __init__(self, provider: str, feature: str, alternative: str = ""):
26
+ message = f"{provider} compatibility does not support {feature} on run.cloud yet"
27
+ if alternative:
28
+ message += f"; {alternative}"
29
+ super().__init__(message)
30
+ self.provider = provider
31
+ self.feature = feature
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ExecResult:
36
+ exit_code: int
37
+ stdout: str
38
+ stderr: str
39
+
40
+
41
+ class Client:
42
+ def __init__(
43
+ self,
44
+ api_key: Optional[str] = None,
45
+ api_url: Optional[str] = None,
46
+ timeout: float = 60.0,
47
+ transport: Optional[Transport] = None,
48
+ ):
49
+ self.api_key = (
50
+ api_key
51
+ or os.environ.get("RUN_CLOUD_API_KEY")
52
+ or os.environ.get("RUN_CLOUD_API_TOKEN")
53
+ )
54
+ if not self.api_key:
55
+ raise ValueError(
56
+ "Run Cloud API key required. Set RUN_CLOUD_API_KEY or pass Client(api_key=...)."
57
+ )
58
+ self.api_url = (
59
+ api_url or os.environ.get("RUN_CLOUD_API_URL") or "https://api.run.cloud"
60
+ ).rstrip("/")
61
+ self.timeout = timeout
62
+ self._transport = transport
63
+
64
+ def request(self, method: str, path: str, body: Any = None) -> Any:
65
+ if self._transport is not None:
66
+ return self._transport(method, path, body)
67
+ data = json.dumps(body).encode() if body is not None else None
68
+ request = urllib.request.Request(
69
+ self.api_url + path,
70
+ data=data,
71
+ method=method,
72
+ headers={
73
+ "Authorization": "Bearer " + self.api_key,
74
+ "Content-Type": "application/json",
75
+ },
76
+ )
77
+ try:
78
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
79
+ raw = response.read()
80
+ return json.loads(raw) if raw else None
81
+ except urllib.error.HTTPError as error:
82
+ text = error.read().decode(errors="replace")
83
+ try:
84
+ parsed = json.loads(text)
85
+ text = parsed.get("detail") or parsed.get("error") or parsed.get("message") or text
86
+ except Exception:
87
+ pass
88
+ raise RunCloudError(error.code, str(text)) from None
89
+
90
+ def create(
91
+ self,
92
+ image: str = "runcloud/agent-base",
93
+ cpu: Optional[float] = None,
94
+ memory: Optional[int] = None,
95
+ disk: Optional[int] = None,
96
+ idle_pause_seconds: Optional[int] = None,
97
+ timeout_seconds: Optional[int] = None,
98
+ region: Optional[str] = None,
99
+ name: Optional[str] = None,
100
+ ) -> "Sandbox":
101
+ values = {
102
+ "image": image,
103
+ "cpu": cpu,
104
+ "memory": memory,
105
+ "disk": disk,
106
+ "idlePauseSeconds": idle_pause_seconds,
107
+ "timeoutSeconds": timeout_seconds,
108
+ "region": region,
109
+ "name": name,
110
+ }
111
+ body = {key: value for key, value in values.items() if value is not None}
112
+ return Sandbox(self, self.request("POST", "/run-cloud/sandboxes", body))
113
+
114
+ def get(self, sandbox_id: str) -> "Sandbox":
115
+ sid = urllib.parse.quote(sandbox_id, safe="")
116
+ return Sandbox(self, self.request("GET", f"/run-cloud/sandboxes/{sid}"))
117
+
118
+ def list(self, state: Optional[str] = None) -> list["Sandbox"]:
119
+ query = f"?state={urllib.parse.quote(state, safe='')}" if state else ""
120
+ payload = self.request("GET", "/run-cloud/sandboxes" + query) or {}
121
+ return [Sandbox(self, item) for item in payload.get("items", [])]
122
+
123
+
124
+ class Sandbox:
125
+ def __init__(self, client: Client, data: dict[str, Any]):
126
+ self._client = client
127
+ self._data = data
128
+ self.id = str(data["id"])
129
+
130
+ @property
131
+ def state(self) -> str:
132
+ return str(self._data.get("state", ""))
133
+
134
+ @property
135
+ def name(self) -> str:
136
+ return str(self._data.get("name") or "")
137
+
138
+ def refresh(self) -> "Sandbox":
139
+ self._data = self._client.request(
140
+ "GET", f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}"
141
+ )
142
+ return self
143
+
144
+ def exec(
145
+ self,
146
+ command: Union[str, list[str]],
147
+ cwd: Optional[str] = None,
148
+ env: Optional[dict[str, str]] = None,
149
+ timeout: Optional[int] = None,
150
+ ) -> ExecResult:
151
+ argv = ["/bin/sh", "-c", command] if isinstance(command, str) else command
152
+ if not argv:
153
+ raise ValueError("command must not be empty")
154
+ body: dict[str, Any] = {"cmd": argv}
155
+ if cwd:
156
+ body["cwd"] = cwd
157
+ if env:
158
+ body["env"] = env
159
+ if timeout is not None:
160
+ body["timeout_seconds"] = timeout
161
+ payload = self._client.request(
162
+ "POST",
163
+ f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/exec",
164
+ body,
165
+ )
166
+ return ExecResult(
167
+ int(payload.get("exit_code", 0)),
168
+ str(payload.get("stdout", "")),
169
+ str(payload.get("stderr", "")),
170
+ )
171
+
172
+ def read_file(self, path: str) -> bytes:
173
+ payload = self._client.request(
174
+ "POST",
175
+ f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/fs",
176
+ {"op": "read", "path": path},
177
+ )
178
+ if payload.get("error"):
179
+ raise RunCloudError(0, str(payload["error"]))
180
+ return base64.b64decode(payload.get("content") or "")
181
+
182
+ def pause(self) -> "Sandbox":
183
+ self._data = self._client.request(
184
+ "POST",
185
+ f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/pause",
186
+ {},
187
+ )
188
+ return self
189
+
190
+ def resume(self) -> "Sandbox":
191
+ self._data = self._client.request(
192
+ "POST",
193
+ f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/resume",
194
+ {},
195
+ )
196
+ return self
197
+
198
+ def set_timeout(self, seconds: int) -> "Sandbox":
199
+ self._data = self._client.request(
200
+ "POST",
201
+ f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}/timeout",
202
+ {"timeoutSeconds": seconds},
203
+ )
204
+ return self
205
+
206
+ def destroy(self) -> None:
207
+ self._client.request(
208
+ "DELETE", f"/run-cloud/sandboxes/{urllib.parse.quote(self.id, safe='')}"
209
+ )
@@ -0,0 +1,5 @@
1
+ """Provider-shaped migration adapters included with the runcloud package."""
2
+
3
+ from . import modal
4
+
5
+ __all__ = ["modal"]
@@ -0,0 +1,147 @@
1
+ """Blaxel-shaped compatibility adapter."""
2
+
3
+ from runcloud.client import Client, UnsupportedCompatibilityFeatureError
4
+
5
+
6
+ class _SyncProcess:
7
+ def __init__(self, native):
8
+ self._native = native
9
+
10
+ def exec(self, config):
11
+ if config.get("wait_for_completion") is False:
12
+ raise UnsupportedCompatibilityFeatureError("Blaxel", "background processes")
13
+ result = self._native.exec(
14
+ config["command"],
15
+ cwd=config.get("working_dir"),
16
+ env=config.get("env"),
17
+ )
18
+ return {
19
+ "pid": 0,
20
+ "name": config.get("name", ""),
21
+ "status": "completed" if result.exit_code == 0 else "failed",
22
+ "exitCode": result.exit_code,
23
+ "logs": result.stdout + result.stderr,
24
+ }
25
+
26
+
27
+ class _SyncDeleteDescriptor:
28
+ def __get__(self, instance, owner):
29
+ if instance is not None:
30
+
31
+ def delete():
32
+ instance.native.destroy()
33
+
34
+ return delete
35
+
36
+ def delete(sandbox_name, client=None, **client_options):
37
+ owner.get(sandbox_name, client=client, **client_options).delete()
38
+
39
+ return delete
40
+
41
+
42
+ class SyncSandboxInstance:
43
+ delete = _SyncDeleteDescriptor()
44
+
45
+ def __init__(self, native):
46
+ self.native = native
47
+ self.process = _SyncProcess(native)
48
+
49
+ @classmethod
50
+ def create(
51
+ cls,
52
+ sandbox=None,
53
+ safe=False,
54
+ create_if_not_exist=False,
55
+ client=None,
56
+ **options,
57
+ ):
58
+ config = dict(sandbox or {})
59
+ config.update(options)
60
+ actual = client or Client(
61
+ api_key=config.get("api_key"), api_url=config.get("api_url")
62
+ )
63
+ return cls(
64
+ actual.create(
65
+ image=config.get("image", "runcloud/agent-base"),
66
+ name=config.get("name"),
67
+ cpu=config.get("cpu"),
68
+ memory=config.get("memory"),
69
+ region=config.get("region"),
70
+ )
71
+ )
72
+
73
+ @classmethod
74
+ def get(cls, sandbox_name, client=None, **client_options):
75
+ actual = client or Client(**client_options)
76
+ match = next(
77
+ (item for item in actual.list() if item.name == sandbox_name), None
78
+ )
79
+ if match is None:
80
+ raise LookupError(f"no sandbox named {sandbox_name}")
81
+ return cls(match)
82
+
83
+
84
+ class _AsyncProcess:
85
+ def __init__(self, native):
86
+ self._sync = _SyncProcess(native)
87
+
88
+ async def exec(self, config):
89
+ return self._sync.exec(config)
90
+
91
+
92
+ class _AsyncDeleteDescriptor:
93
+ def __get__(self, instance, owner):
94
+ if instance is not None:
95
+
96
+ async def delete():
97
+ instance.native.destroy()
98
+
99
+ return delete
100
+
101
+ async def delete(sandbox_name, client=None, **client_options):
102
+ sandbox = await owner.get(
103
+ sandbox_name,
104
+ client=client,
105
+ **client_options,
106
+ )
107
+ await sandbox.delete()
108
+
109
+ return delete
110
+
111
+
112
+ class SandboxInstance:
113
+ delete = _AsyncDeleteDescriptor()
114
+
115
+ def __init__(self, native):
116
+ self.native = native
117
+ self.process = _AsyncProcess(native)
118
+
119
+ @classmethod
120
+ async def create(
121
+ cls, sandbox=None, safe=False, create_if_not_exist=False, **options
122
+ ):
123
+ config = dict(sandbox or {})
124
+ config.update(options)
125
+ actual = config.pop("client", None) or Client(
126
+ api_key=config.pop("api_key", None),
127
+ api_url=config.pop("api_url", None),
128
+ )
129
+ return cls(
130
+ actual.create(
131
+ image=config.get("image", "runcloud/agent-base"),
132
+ name=config.get("name"),
133
+ cpu=config.get("cpu"),
134
+ memory=config.get("memory"),
135
+ region=config.get("region"),
136
+ )
137
+ )
138
+
139
+ @classmethod
140
+ async def get(cls, sandbox_name, client=None, **client_options):
141
+ actual = client or Client(**client_options)
142
+ match = next(
143
+ (item for item in actual.list() if item.name == sandbox_name), None
144
+ )
145
+ if match is None:
146
+ raise LookupError(f"no sandbox named {sandbox_name}")
147
+ return cls(match)
@@ -0,0 +1,67 @@
1
+ """Daytona-shaped compatibility adapter."""
2
+
3
+ from runcloud.client import Client, UnsupportedCompatibilityFeatureError
4
+
5
+
6
+ class _Process:
7
+ def __init__(self, native):
8
+ self._native = native
9
+
10
+ def exec(self, command, cwd=None, env=None, timeout=None):
11
+ result = self._native.exec(command, cwd=cwd, env=env, timeout=timeout)
12
+ return type(
13
+ "ExecuteResponse",
14
+ (),
15
+ {
16
+ "exit_code": result.exit_code,
17
+ "exitCode": result.exit_code,
18
+ "result": result.stdout,
19
+ "stdout": result.stdout,
20
+ "stderr": result.stderr,
21
+ },
22
+ )()
23
+
24
+
25
+ class DaytonaSandbox:
26
+ def __init__(self, native):
27
+ self.native = native
28
+ self.id = native.id
29
+ self.process = _Process(native)
30
+
31
+ def get_preview_link(self, _port):
32
+ raise UnsupportedCompatibilityFeatureError(
33
+ "Daytona",
34
+ "preview links",
35
+ "run.cloud public port capabilities are not available in the SDK yet",
36
+ )
37
+
38
+ def delete(self):
39
+ self.native.destroy()
40
+
41
+
42
+ class Daytona:
43
+ def __init__(self, config=None, client=None, **options):
44
+ config = config or {}
45
+ self.client = client or Client(
46
+ api_key=options.get("api_key") or config.get("api_key"),
47
+ api_url=options.get("server_url") or config.get("server_url"),
48
+ )
49
+
50
+ def create(self, params=None):
51
+ params = params or {}
52
+ return DaytonaSandbox(
53
+ self.client.create(
54
+ image=params.get("image", "runcloud/agent-base"),
55
+ name=params.get("name"),
56
+ region=params.get("region"),
57
+ )
58
+ )
59
+
60
+ def get(self, sandbox_id_or_name):
61
+ return DaytonaSandbox(self.client.get(sandbox_id_or_name))
62
+
63
+ def remove(self, sandbox):
64
+ sandbox.delete()
65
+
66
+ def delete(self, sandbox, timeout=None, wait=True):
67
+ sandbox.delete()
@@ -0,0 +1,80 @@
1
+ """E2B Sandbox-shaped compatibility adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runcloud.client import Client, UnsupportedCompatibilityFeatureError
6
+
7
+
8
+ class _Commands:
9
+ def __init__(self, sandbox):
10
+ self._sandbox = sandbox
11
+
12
+ def run(self, command, cwd=None, envs=None, timeout=None, background=False):
13
+ if background:
14
+ raise UnsupportedCompatibilityFeatureError("E2B", "background commands")
15
+ return self._sandbox.native.exec(command, cwd=cwd, env=envs, timeout=timeout)
16
+
17
+
18
+ class _Files:
19
+ def __init__(self, sandbox):
20
+ self._sandbox = sandbox
21
+
22
+ def read(self, path):
23
+ return self._sandbox.native.read_file(path).decode()
24
+
25
+ def read_bytes(self, path):
26
+ return self._sandbox.native.read_file(path)
27
+
28
+ def write(self, *_args, **_kwargs):
29
+ raise UnsupportedCompatibilityFeatureError(
30
+ "E2B", "file writes", "use an image or command"
31
+ )
32
+
33
+
34
+ class Sandbox:
35
+ def __init__(self, native, metadata=None):
36
+ self.native = native
37
+ self.metadata = metadata or {}
38
+ self.commands = _Commands(self)
39
+ self.files = _Files(self)
40
+
41
+ @classmethod
42
+ def create(
43
+ cls,
44
+ template="base",
45
+ timeout=None,
46
+ metadata=None,
47
+ envs=None,
48
+ client=None,
49
+ api_key=None,
50
+ domain=None,
51
+ **_ignored,
52
+ ):
53
+ actual = client or Client(api_key=api_key, api_url=domain)
54
+ image = "runcloud/agent-base" if template == "base" else template
55
+ sandbox = cls(actual.create(image=image, timeout_seconds=timeout), metadata)
56
+ sandbox.envs = envs or {}
57
+ return sandbox
58
+
59
+ @classmethod
60
+ def connect(cls, sandbox_id, client=None, **client_options):
61
+ actual = client or Client(**client_options)
62
+ return cls(actual.get(sandbox_id))
63
+
64
+ @property
65
+ def sandbox_id(self):
66
+ return self.native.id
67
+
68
+ def set_timeout(self, timeout):
69
+ self.native.set_timeout(timeout)
70
+
71
+ def get_host(self, _port):
72
+ raise UnsupportedCompatibilityFeatureError(
73
+ "E2B",
74
+ "get_host",
75
+ "run.cloud public port capabilities are not available in the SDK yet",
76
+ )
77
+
78
+ def kill(self):
79
+ self.native.destroy()
80
+ return True
@@ -0,0 +1,165 @@
1
+ """Modal Sandbox-shaped compatibility adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from runcloud.client import (
8
+ Client,
9
+ UnsupportedCompatibilityFeatureError,
10
+ )
11
+ from runcloud.client import (
12
+ Sandbox as NativeSandbox,
13
+ )
14
+
15
+
16
+ class Image:
17
+ def __init__(self, ref: str):
18
+ self.ref = ref
19
+
20
+ @classmethod
21
+ def from_registry(cls, ref: str) -> "Image":
22
+ return cls(ref)
23
+
24
+ @classmethod
25
+ def debian_slim(cls, python_version: Optional[str] = None) -> "Image":
26
+ return cls("debian:12-slim")
27
+
28
+ def _unsupported(self, feature: str):
29
+ raise UnsupportedCompatibilityFeatureError(
30
+ "Modal", feature, "register a prebuilt OCI image with run.cloud"
31
+ )
32
+
33
+ def apt_install(self, *packages: str):
34
+ return self._unsupported("Image.apt_install")
35
+
36
+ def pip_install(self, *packages: str):
37
+ return self._unsupported("Image.pip_install")
38
+
39
+ def run_commands(self, *commands: str):
40
+ return self._unsupported("Image.run_commands")
41
+
42
+
43
+ class Process:
44
+ def __init__(self, result):
45
+ self._result = result
46
+ self.stdout = _Reader(result.stdout)
47
+ self.stderr = _Reader(result.stderr)
48
+ self.returncode = result.exit_code
49
+
50
+ def wait(self):
51
+ return self.returncode
52
+
53
+ def poll(self):
54
+ return self.returncode
55
+
56
+
57
+ class _Reader:
58
+ def __init__(self, value: str):
59
+ self._value = value
60
+
61
+ def read(self):
62
+ return self._value
63
+
64
+
65
+ class App:
66
+ def __init__(self, name: str, client: Client):
67
+ self.name = name
68
+ self.client = client
69
+
70
+ @classmethod
71
+ def lookup(
72
+ cls,
73
+ name: str,
74
+ create_if_missing: bool = True,
75
+ client: Optional[Client] = None,
76
+ **client_options,
77
+ ) -> "App":
78
+ return cls(name, client or Client(**client_options))
79
+
80
+ def create_sandbox(self, image=None, **options):
81
+ return Sandbox.create(app=self, image=image, **options)
82
+
83
+
84
+ class Sandbox:
85
+ def __init__(self, native: NativeSandbox, app: Optional[App] = None):
86
+ self.native = native
87
+ self.app = app
88
+
89
+ @property
90
+ def object_id(self):
91
+ return self.native.id
92
+
93
+ @classmethod
94
+ def create(
95
+ cls,
96
+ *args,
97
+ app: Optional[App] = None,
98
+ image=None,
99
+ cpu=None,
100
+ memory=None,
101
+ timeout=None,
102
+ idle_timeout=None,
103
+ name=None,
104
+ region=None,
105
+ command=None,
106
+ volumes=None,
107
+ **_ignored,
108
+ ):
109
+ if volumes:
110
+ raise UnsupportedCompatibilityFeatureError("Modal", "volumes")
111
+ if isinstance(cpu, tuple):
112
+ raise UnsupportedCompatibilityFeatureError(
113
+ "Modal", "CPU request/limit tuples", "pass one CPU reservation"
114
+ )
115
+ client = app.client if app else Client()
116
+ image_ref = image.ref if isinstance(image, Image) else image
117
+ native = client.create(
118
+ image=image_ref or "runcloud/agent-base",
119
+ cpu=cpu,
120
+ memory=memory,
121
+ timeout_seconds=timeout,
122
+ idle_pause_seconds=idle_timeout,
123
+ name=name,
124
+ region=region,
125
+ )
126
+ sandbox = cls(native, app)
127
+ entrypoint = list(args) if args else command
128
+ if entrypoint:
129
+ sandbox._entrypoint = sandbox.exec(*entrypoint)
130
+ return sandbox
131
+
132
+ @classmethod
133
+ def from_id(cls, sandbox_id: str, client: Optional[Client] = None):
134
+ actual = client or Client()
135
+ return cls(actual.get(sandbox_id))
136
+
137
+ @classmethod
138
+ def from_name(cls, app: App, name: str):
139
+ match = next((item for item in app.client.list() if item.name == name), None)
140
+ if match is None:
141
+ raise LookupError(f"no sandbox named {name}")
142
+ return cls(match, app)
143
+
144
+ @classmethod
145
+ def list(cls, app: Optional[App] = None):
146
+ client = app.client if app else Client()
147
+ return [cls(item, app) for item in client.list()]
148
+
149
+ def exec(self, *args, timeout=None, workdir=None, env=None, **_ignored):
150
+ cmd = list(args) if len(args) > 1 else args[0]
151
+ return Process(self.native.exec(cmd, cwd=workdir, env=env, timeout=timeout))
152
+
153
+ def terminate(self, wait=True):
154
+ self.native.destroy()
155
+
156
+ def poll(self):
157
+ self.native.refresh()
158
+ return None if self.native.state in {"running", "starting", "pending"} else 0
159
+
160
+ def tunnels(self):
161
+ raise UnsupportedCompatibilityFeatureError(
162
+ "Modal",
163
+ "tunnels",
164
+ "run.cloud public port capabilities are not available in the SDK yet",
165
+ )
@@ -0,0 +1,50 @@
1
+ """Fly Sprites-shaped compatibility adapter."""
2
+
3
+ from runcloud.client import Client, UnsupportedCompatibilityFeatureError
4
+
5
+
6
+ class Sprite:
7
+ def __init__(self, native, name):
8
+ self.native = native
9
+ self.name = name
10
+
11
+ def exec(self, command, args=None, cwd=None, env=None):
12
+ return self.native.exec([command] + list(args or []), cwd=cwd, env=env)
13
+
14
+ def filesystem(self, _base="/"):
15
+ raise UnsupportedCompatibilityFeatureError("Fly Sprites", "filesystem writes")
16
+
17
+ def url(self, _port=8080):
18
+ raise UnsupportedCompatibilityFeatureError(
19
+ "Fly Sprites",
20
+ "public URLs",
21
+ "run.cloud public port capabilities are not available in the SDK yet",
22
+ )
23
+
24
+ def delete(self):
25
+ self.native.destroy()
26
+
27
+
28
+ class SpritesClient:
29
+ def __init__(self, client=None, api_key=None, base_url=None):
30
+ self.client = client or Client(api_key=api_key, api_url=base_url)
31
+
32
+ def create_sprite(self, name, config=None):
33
+ config = config or {}
34
+ return Sprite(
35
+ self.client.create(
36
+ image=config.get("image", "runcloud/agent-base"),
37
+ name=name,
38
+ region=config.get("region"),
39
+ ),
40
+ name,
41
+ )
42
+
43
+ def get_sprite(self, name):
44
+ match = next((item for item in self.client.list() if item.name == name), None)
45
+ if match is None:
46
+ raise LookupError(f"no sprite named {name}")
47
+ return Sprite(match, name)
48
+
49
+ def destroy_sprite(self, name):
50
+ self.get_sprite(name).delete()
@@ -0,0 +1,55 @@
1
+ """Vercel Sandbox-shaped compatibility adapter."""
2
+
3
+ from runcloud.client import Client, UnsupportedCompatibilityFeatureError
4
+
5
+
6
+ class Sandbox:
7
+ def __init__(self, native):
8
+ self.native = native
9
+
10
+ @classmethod
11
+ def create(
12
+ cls,
13
+ client=None,
14
+ token=None,
15
+ runtime=None,
16
+ resources=None,
17
+ timeout=None,
18
+ **options,
19
+ ):
20
+ actual = client or Client(api_key=token)
21
+ resources = resources or {}
22
+ return cls(
23
+ actual.create(
24
+ image=options.get("image", "runcloud/agent-base"),
25
+ name=options.get("name"),
26
+ cpu=resources.get("vcpus"),
27
+ timeout_seconds=(timeout // 1000 if timeout else None),
28
+ region=options.get("region"),
29
+ )
30
+ )
31
+
32
+ @property
33
+ def sandbox_id(self):
34
+ return self.native.id
35
+
36
+ def run_command(self, command, args=None, cwd=None, env=None):
37
+ return self.native.exec([command] + list(args or []), cwd=cwd, env=env)
38
+
39
+ def read_file(self, path):
40
+ return self.native.read_file(path)
41
+
42
+ def write_files(self, *_args, **_kwargs):
43
+ raise UnsupportedCompatibilityFeatureError(
44
+ "Vercel Sandbox", "file writes", "use an image or command"
45
+ )
46
+
47
+ def domain(self, _port):
48
+ raise UnsupportedCompatibilityFeatureError(
49
+ "Vercel Sandbox",
50
+ "domain",
51
+ "run.cloud public port capabilities are not available in the SDK yet",
52
+ )
53
+
54
+ def stop(self):
55
+ self.native.destroy()
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: runcloud-sdk
3
+ Version: 0.7.0
4
+ Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
+ Author: Newly
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://run.cloud
8
+ Project-URL: Documentation, https://docs.run.cloud
9
+ Project-URL: Repository, https://github.com/newly-app/newly/tree/main/run-cloud/python-sdk
10
+ Keywords: runcloud,run-cloud,sandbox,microvm,firecracker,sdk
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # runcloud
19
+
20
+ Python SDK for [run.cloud](https://run.cloud).
21
+
22
+ ```bash
23
+ pip install runcloud-sdk
24
+ export RUN_CLOUD_API_KEY="rc_live_..."
25
+ ```
26
+
27
+ ```python
28
+ from runcloud import Client
29
+
30
+ cloud = Client()
31
+ box = cloud.create(image="runcloud/agent-base", cpu=2, memory=4096)
32
+ try:
33
+ result = box.exec("python -V")
34
+ print(result.stdout)
35
+ finally:
36
+ box.destroy()
37
+ ```
38
+
39
+ ## Compatibility adapters
40
+
41
+ The same distribution includes migration adapters:
42
+
43
+ | Provider | Import |
44
+ | --- | --- |
45
+ | Modal | `from runcloud.compat import modal` |
46
+ | E2B | `from runcloud.compat.e2b import Sandbox` |
47
+ | Daytona | `from runcloud.compat.daytona import Daytona` |
48
+ | Vercel Sandbox | `from runcloud.compat.vercel import Sandbox` |
49
+ | Blaxel async | `from runcloud.compat.blaxel import SandboxInstance` |
50
+ | Blaxel sync | `from runcloud.compat.blaxel import SyncSandboxInstance` |
51
+ | Fly Sprites | `from runcloud.compat.sprites import SpritesClient` |
52
+
53
+ Adapters cover creation, command execution, lookup/listing, and destruction
54
+ where the upstream SDK exposes them. Blaxel's current `SandboxInstance` API is
55
+ async; `SyncSandboxInstance` preserves its synchronous API. Unsupported
56
+ provider-specific features raise `UnsupportedCompatibilityFeatureError` with a
57
+ migration hint. They do not silently pretend that run.cloud implements a
58
+ provider-specific feature.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ runcloud/__init__.py
5
+ runcloud/client.py
6
+ runcloud/compat/__init__.py
7
+ runcloud/compat/blaxel.py
8
+ runcloud/compat/daytona.py
9
+ runcloud/compat/e2b.py
10
+ runcloud/compat/modal.py
11
+ runcloud/compat/sprites.py
12
+ runcloud/compat/vercel.py
13
+ runcloud_sdk.egg-info/PKG-INFO
14
+ runcloud_sdk.egg-info/SOURCES.txt
15
+ runcloud_sdk.egg-info/dependency_links.txt
16
+ runcloud_sdk.egg-info/top_level.txt
17
+ tests/test_sdk.py
@@ -0,0 +1 @@
1
+ runcloud
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ import asyncio
2
+ import base64
3
+ import unittest
4
+
5
+ from runcloud import Client, UnsupportedCompatibilityFeatureError
6
+ from runcloud.compat import modal
7
+ from runcloud.compat.blaxel import SandboxInstance, SyncSandboxInstance
8
+ from runcloud.compat.daytona import Daytona
9
+ from runcloud.compat.e2b import Sandbox as E2BSandbox
10
+ from runcloud.compat.sprites import SpritesClient
11
+ from runcloud.compat.vercel import Sandbox as VercelSandbox
12
+
13
+ SANDBOX = {"id": "sb_python", "name": "compat", "state": "running"}
14
+
15
+
16
+ class Recorder:
17
+ def __init__(self):
18
+ self.calls = []
19
+
20
+ def __call__(self, method, path, body):
21
+ self.calls.append((method, path, body))
22
+ if path.endswith("/exec"):
23
+ return {"exit_code": 0, "stdout": "ok\n", "stderr": ""}
24
+ if path.endswith("/fs"):
25
+ return {"content": base64.b64encode(b"artifact").decode()}
26
+ if path == "/run-cloud/sandboxes" and method == "GET":
27
+ return {"items": [SANDBOX]}
28
+ return SANDBOX
29
+
30
+
31
+ class SDKTests(unittest.TestCase):
32
+ def setUp(self):
33
+ self.recorder = Recorder()
34
+ self.client = Client(api_key="rc_live_test", transport=self.recorder)
35
+
36
+ def test_native_routes(self):
37
+ box = self.client.create(cpu=1.5, memory=2048)
38
+ self.assertEqual(box.exec("echo ok").stdout, "ok\n")
39
+ self.assertEqual(box.read_file("/tmp/a"), b"artifact")
40
+ box.pause()
41
+ box.resume()
42
+ box.destroy()
43
+ self.assertIn(
44
+ (
45
+ "POST",
46
+ "/run-cloud/sandboxes",
47
+ {
48
+ "image": "runcloud/agent-base",
49
+ "cpu": 1.5,
50
+ "memory": 2048,
51
+ },
52
+ ),
53
+ self.recorder.calls,
54
+ )
55
+
56
+ def test_compatibility_adapters_share_the_public_client(self):
57
+ app = modal.App.lookup("compat", client=self.client)
58
+ self.assertEqual(modal.Sandbox.create(app=app).exec("echo ok").wait(), 0)
59
+ self.assertEqual(
60
+ E2BSandbox.create(client=self.client).commands.run("echo ok").exit_code, 0
61
+ )
62
+ self.assertEqual(
63
+ Daytona(client=self.client).create().process.exec("echo ok").exit_code, 0
64
+ )
65
+ self.assertEqual(
66
+ VercelSandbox.create(client=self.client).run_command("echo").exit_code, 0
67
+ )
68
+ self.assertTrue(SyncSandboxInstance.create(client=self.client))
69
+ async_blaxel = asyncio.run(SandboxInstance.create(client=self.client))
70
+ asyncio.run(async_blaxel.delete())
71
+ self.assertTrue(SpritesClient(client=self.client).create_sprite("compat"))
72
+
73
+ def test_unsupported_features_fail_loudly(self):
74
+ with self.assertRaises(UnsupportedCompatibilityFeatureError):
75
+ modal.Image.debian_slim().pip_install("requests")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()