deno-sandbox 0.1.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,35 @@
1
+ Metadata-Version: 2.3
2
+ Name: deno-sandbox
3
+ Version: 0.1.0
4
+ Summary: Deno Sandbox Python SDK
5
+ Author: The Deno Team
6
+ Author-email: The Deno Team <support@deno.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: uuid>=1.30
10
+ Requires-Dist: websockets>=15.0.1
11
+ Requires-Python: >=3.14
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Deno Sandbox Python SDK
15
+
16
+ Create isolated [sandboxes on Deno Deploy](https://deno.com/deploy/sandboxes) to
17
+ securely run code in a lightweight Linux microVM. You can securely run shell
18
+ scripts, spawn processes, execute JavaScript applications and REPLs, and
19
+ interact with files remotely.
20
+
21
+ This Python SDK let's you create and manage sandboxes programmatically.
22
+
23
+ ## Installation
24
+
25
+ ```sh
26
+ uv pip install deno-sandbox
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ TODO
32
+
33
+ ## License
34
+
35
+ `MIT`, see the [LICENSE file](./LICENSE)
@@ -0,0 +1,22 @@
1
+ # Deno Sandbox Python SDK
2
+
3
+ Create isolated [sandboxes on Deno Deploy](https://deno.com/deploy/sandboxes) to
4
+ securely run code in a lightweight Linux microVM. You can securely run shell
5
+ scripts, spawn processes, execute JavaScript applications and REPLs, and
6
+ interact with files remotely.
7
+
8
+ This Python SDK let's you create and manage sandboxes programmatically.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ uv pip install deno-sandbox
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ TODO
19
+
20
+ ## License
21
+
22
+ `MIT`, see the [LICENSE file](./LICENSE)
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "deno-sandbox"
3
+ version = "0.1.0"
4
+ description = "Deno Sandbox Python SDK"
5
+ readme = "README.md"
6
+ authors = [{ name = "The Deno Team", email = "support@deno.com" }]
7
+ requires-python = ">=3.14"
8
+ dependencies = [
9
+ "httpx>=0.28.1",
10
+ "pydantic>=2.12.5",
11
+ "uuid>=1.30",
12
+ "websockets>=15.0.1",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.13,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "basedpyright>=1.35.0",
22
+ "pytest>=9.0.2",
23
+ "pytest-asyncio>=1.3.0",
24
+ "pytest-timeout>=2.4.0",
25
+ "ruff>=0.14.8",
26
+ ]
27
+
28
+ [tool.pytest.ini_options]
29
+ timeout = 10
@@ -0,0 +1,5 @@
1
+ from deno_sandbox.sandbox import AsyncSandbox
2
+ from deno_sandbox.client import AsyncClient
3
+ from deno_sandbox.options import Options
4
+
5
+ __all__ = ["AsyncSandbox", "AsyncClient", "Options"]
@@ -0,0 +1,10 @@
1
+ from deno_sandbox.options import Options, get_internal_options
2
+ from deno_sandbox.sandbox import AsyncSandbox
3
+ from deno_sandbox.volumes import AsyncVolumes
4
+
5
+
6
+ class AsyncClient:
7
+ def __init__(self, options: Options):
8
+ self._options = get_internal_options(options or Options())
9
+ self.sandbox = AsyncSandbox(options)
10
+ self.volumes = AsyncVolumes(options)
@@ -0,0 +1,41 @@
1
+ import httpx
2
+ from pydantic import HttpUrl
3
+
4
+ from deno_sandbox.options import Options, get_internal_options
5
+
6
+
7
+ class AsyncConsoleClient:
8
+ def __init__(self, options: Options):
9
+ self.__options = get_internal_options(options or Options())
10
+
11
+ async def post(self, path: str, data: any) -> dict:
12
+ async with httpx.AsyncClient() as client:
13
+ req_url = HttpUrl(self.__options.console_url, path=path)
14
+ headers = {
15
+ "Content-Type": "application/json",
16
+ "Authorization": f"Bearer {self.__options.token}",
17
+ }
18
+ response = await client.post(req_url, headers=headers, json=data)
19
+ response.raise_for_status()
20
+ return await response.json()
21
+
22
+ async def get(self, path: str, search: dict | None = None) -> dict:
23
+ async with httpx.AsyncClient() as client:
24
+ req_url = HttpUrl(self.__options.console_url, path=path, search=search)
25
+ headers = {
26
+ "Content-Type": "application/json",
27
+ "Authorization": f"Bearer {self.__options.token}",
28
+ }
29
+ response = await client.get(req_url, headers=headers)
30
+ response.raise_for_status()
31
+ return await response.json()
32
+
33
+ async def delete(self, path: str) -> None:
34
+ async with httpx.AsyncClient() as client:
35
+ req_url = HttpUrl(self.__options.console_url, path=path)
36
+ headers = {
37
+ "Content-Type": "application/json",
38
+ "Authorization": f"Bearer {self.__options.token}",
39
+ }
40
+ response = await client.delete(req_url, headers=headers)
41
+ response.raise_for_status()
@@ -0,0 +1,47 @@
1
+ from dataclasses import dataclass
2
+ import os
3
+
4
+ from urllib.parse import urlparse
5
+
6
+ from pydantic_core import Url
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Options:
11
+ token: str | Url | None = None
12
+ regions: list[str] | None = None
13
+
14
+
15
+ @dataclass
16
+ class InternalOptions:
17
+ sandbox_ws_url: Url
18
+ console_url: Url
19
+ token: Url
20
+ regions: list[str]
21
+
22
+
23
+ def get_internal_options(options: Options) -> InternalOptions:
24
+ options = options or Options()
25
+
26
+ url = urlparse(
27
+ os.environ.get("DENO_DEPLOY_URL", "https://ams.sandbox-api.deno.net")
28
+ )
29
+
30
+ token = options.token or os.environ.get("DENO_DEPLOY_TOKEN", "")
31
+
32
+ scheme = url.scheme.replace("http", "ws")
33
+ sandbox_ws_url = Url(f"{scheme}://{url.netloc}/api/v2/sandbox/ws?format=json")
34
+
35
+ console_url = os.environ.get("DENO_DEPLOY_CONSOLE_URL", "https://console.deno.com")
36
+ parsed_console_url = urlparse(console_url)
37
+
38
+ regions = options.regions or os.environ.get(
39
+ "DENO_AVAILABLE_REGIONS", "ams1,ord"
40
+ ).split(",")
41
+
42
+ return InternalOptions(
43
+ console_url=parsed_console_url,
44
+ sandbox_ws_url=sandbox_ws_url,
45
+ token=token,
46
+ regions=regions,
47
+ )
File without changes
@@ -0,0 +1,97 @@
1
+ import asyncio
2
+ import base64
3
+ import json
4
+ from typing import Any, Dict, Optional
5
+ from pydantic import BaseModel
6
+ from websockets import ConnectionClosed
7
+
8
+ from deno_sandbox.transport import Transport
9
+
10
+
11
+ class RpcRequest(BaseModel):
12
+ method: str
13
+ params: Dict[str, Any]
14
+ id: int
15
+ jsonrpc: str = "2.0"
16
+
17
+
18
+ class RpcResult[T](BaseModel):
19
+ ok: Optional[T] = None
20
+ error: Optional[Any] = None
21
+
22
+
23
+ class RpcResponse[T](BaseModel):
24
+ jsonrpc: str = "2.0"
25
+ result: Optional[RpcResult[T]] = None
26
+ error: Dict[str, Any] | None = None
27
+ id: int
28
+
29
+
30
+ class AsyncRpcClient:
31
+ def __init__(self, transport: Transport):
32
+ self._transport = transport
33
+ self._id = 0
34
+ self._pending_requests: Dict[int, asyncio.Future[Any]] = {}
35
+ self._listen_task = asyncio.create_task(self._listener())
36
+ self._pending_processes: Dict[int, asyncio.StreamReader] = {}
37
+
38
+ async def close(self):
39
+ await self._transport.close()
40
+
41
+ async def call(self, method: str, params: Dict[str, Any]) -> Any:
42
+ req_id = self._id + 1
43
+ self._id = req_id
44
+
45
+ payload = RpcRequest(method=method, params=params, id=req_id)
46
+
47
+ loop = asyncio.get_running_loop()
48
+ future = loop.create_future()
49
+ self._pending_requests[req_id] = future
50
+
51
+ await self._transport.send(payload.model_dump_json())
52
+
53
+ raw_response = await future
54
+ response = RpcResponse[Any](**raw_response)
55
+
56
+ if response.error:
57
+ raise Exception(response.error)
58
+
59
+ if response.result and response.result.error:
60
+ raise Exception(f"Application Error: {response.result.error}")
61
+
62
+ return response.result.ok if response.result else None
63
+
64
+ async def _listener(self) -> None:
65
+ try:
66
+ async for raw in self._transport:
67
+ data = json.loads(raw)
68
+ req_id = data.get("id")
69
+
70
+ if req_id is not None and req_id in self._pending_requests:
71
+ future = self._pending_requests.pop(req_id)
72
+ if not future.done():
73
+ future.set_result(data)
74
+
75
+ elif "method" in data:
76
+ method = data["method"]
77
+ params = data.get("params", {})
78
+
79
+ if method == "$sandbox.stream.enqueue":
80
+ stream_id = params.get("streamId")
81
+ chunk = base64.b64decode(params.get("data", ""))
82
+ stream = self._pending_processes.get(stream_id)
83
+ if stream:
84
+ stream.feed_data(chunk)
85
+ elif method == "$sandbox.stream.end":
86
+ stream_id = params.get("streamId")
87
+ stream = self._pending_processes.get(stream_id)
88
+ if stream:
89
+ stream.feed_eof()
90
+ del self._pending_processes[stream_id]
91
+
92
+ except ConnectionClosed:
93
+ pass
94
+ except Exception as e:
95
+ for future in self._pending_requests.values():
96
+ if not future.done():
97
+ future.set_exception(e)
@@ -0,0 +1,189 @@
1
+ import asyncio
2
+ from contextlib import asynccontextmanager
3
+ from dataclasses import asdict, dataclass, field
4
+ import json
5
+ from typing import AsyncIterator
6
+ import uuid
7
+ from pydantic import BaseModel, Field
8
+ from typing_extensions import Literal
9
+
10
+ from deno_sandbox.options import Options, get_internal_options
11
+ from deno_sandbox.rpc import AsyncRpcClient
12
+ from deno_sandbox.sandbox_generated import (
13
+ Sandbox as GeneratedSandbox,
14
+ SpawnArgs,
15
+ SandboxProcess as GeneratedSandboxProcess,
16
+ )
17
+ from deno_sandbox.transport import (
18
+ Transport,
19
+ WebSocketTransportFactory,
20
+ )
21
+
22
+
23
+ type Mode = Literal["connect", "create"]
24
+ type StdIo = Literal["piped", "null"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SandboxCreateOptions:
29
+ env: dict[str, str] | None = None
30
+ """Environment variables to set in the sandbox."""
31
+ memory_mb: int | None = 1024
32
+ """Memory limit for the sandbox in megabytes (default: 1024)."""
33
+ labels: dict[str, str] | None = None
34
+ """
35
+ Labels to set on the sandbox. Up to 5 labels can be specified.
36
+ Each label key must be at most 64 bytes, and each label value must be at most 128 bytes.
37
+ """
38
+ volumes: dict[str, str] | None = None
39
+ """
40
+ Volumes to mount on the sandbox.
41
+ The key is the mount path inside the sandbox, and the value is
42
+ the volume ID or slug
43
+ """
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SandboxConnectOptions:
48
+ id: uuid.UUID
49
+ """Unique id of the sandbox."""
50
+
51
+
52
+ @dataclass
53
+ class SandboxRequestResult:
54
+ revision_id: str
55
+
56
+
57
+ @dataclass
58
+ class SandboxDeployOptions:
59
+ path: str | None = None
60
+ entrypoint: str = "main.ts"
61
+ args: list[str] = field(default_factory=list)
62
+
63
+
64
+ @dataclass
65
+ class SandboxInfo:
66
+ sandboxId: uuid.UUID
67
+ """The ID of the sandbox."""
68
+ createdAt: str
69
+ """The creation time of the sandbox."""
70
+ region: str
71
+ """The region where the sandbox is running."""
72
+
73
+
74
+ @dataclass
75
+ class AppConfig:
76
+ sandbox_id: str
77
+ mode: Mode
78
+ stop_at_ms: int | None
79
+ labels: dict[str, str] | None
80
+ memory_mb: int | None
81
+
82
+
83
+ class AsyncSandboxProcess(GeneratedSandboxProcess):
84
+ async def spawn(self, args: SpawnArgs) -> RemoteProcess:
85
+ result: SpawnResult = await super().spawn(args)
86
+ return RemoteProcess(result, self._rpc)
87
+
88
+
89
+ class AsyncSandboxHandle(GeneratedSandbox):
90
+ def __init__(self, rpc: AsyncRpcClient, sandbox_id: str):
91
+ super().__init__(rpc, sandbox_id)
92
+ self.process = AsyncSandboxProcess(rpc)
93
+
94
+
95
+ class AsyncSandbox:
96
+ def __init__(
97
+ self,
98
+ options: Options | None = None,
99
+ ):
100
+ self.__options = get_internal_options(options or Options())
101
+ self._transport_factory = WebSocketTransportFactory()
102
+
103
+ async def _init_transport(self, app_config: AppConfig) -> Transport:
104
+ transport = self._transport_factory.create_transport()
105
+
106
+ headers = {
107
+ "Authorization": f"Bearer {self.__options.token}",
108
+ "x-denodeploy-config-v2": json.dumps(asdict(app_config)),
109
+ }
110
+
111
+ await transport.connect(url=self.__options.sandbox_ws_url, headers=headers)
112
+ return transport
113
+
114
+ @asynccontextmanager
115
+ async def create(
116
+ self, options: SandboxCreateOptions | None = None
117
+ ) -> AsyncIterator[AsyncSandboxHandle]:
118
+ """Creates a new sandbox instance."""
119
+
120
+ sandbox_id = str(uuid.uuid4())
121
+ app_config = AppConfig(
122
+ sandbox_id=sandbox_id,
123
+ mode="create",
124
+ stop_at_ms=None,
125
+ labels=options.labels if options else None,
126
+ memory_mb=options.memory_mb if options else None,
127
+ )
128
+ transport = await self._init_transport(app_config)
129
+
130
+ try:
131
+ rpc = AsyncRpcClient(transport)
132
+ yield AsyncSandboxHandle(rpc, sandbox_id)
133
+ finally:
134
+ await transport.close()
135
+
136
+ @asynccontextmanager
137
+ async def connect(
138
+ self, options: SandboxConnectOptions
139
+ ) -> AsyncIterator[AsyncSandboxHandle]:
140
+ """Connects to an existing sandbox instance."""
141
+
142
+ sandbox_id = str(options.id)
143
+ app_config = AppConfig(
144
+ sandbox_id=sandbox_id,
145
+ mode="connect",
146
+ stop_at_ms=None,
147
+ labels=None,
148
+ memory_mb=None,
149
+ )
150
+ transport = await self._init_transport(app_config)
151
+
152
+ try:
153
+ rpc = AsyncRpcClient(transport)
154
+ yield AsyncSandboxHandle(rpc, sandbox_id)
155
+ finally:
156
+ await transport.close()
157
+
158
+
159
+ class SpawnResult(BaseModel):
160
+ pid: int
161
+ stdout_stream_id: int | None = Field(default=None, alias="stdoutStreamId")
162
+ stderr_stream_id: int | None = Field(default=None, alias="stderrStreamId")
163
+
164
+
165
+ class ProcessExit(BaseModel):
166
+ code: int
167
+
168
+
169
+ class RemoteProcess:
170
+ def __init__(self, res: SpawnResult, rpc: AsyncRpcClient):
171
+ self.pid = res.pid
172
+ self._stdout_stream_id = res.stdout_stream_id
173
+ self._stderr_stream_id = res.stderr_stream_id
174
+ self.returncode: int | None = None
175
+
176
+ self.stdout = asyncio.StreamReader()
177
+ self.stderr = asyncio.StreamReader()
178
+ self._wait_task = asyncio.create_task(
179
+ rpc.call("processWait", {"pid": self.pid})
180
+ )
181
+
182
+ rpc._pending_processes[self._stdout_stream_id] = self.stdout
183
+ rpc._pending_processes[self._stderr_stream_id] = self.stderr
184
+
185
+ async def wait(self) -> int:
186
+ raw = await self._wait_task
187
+ result = ProcessExit.model_validate(raw)
188
+ self.returncode = result.code
189
+ return self.returncode