Skip to content

Commit 3f56794

Browse files
feat: Deno process wrapper + new sandbox methods (#14)
1 parent 0497a6a commit 3f56794

File tree

11 files changed

+1032
-173
lines changed

11 files changed

+1032
-173
lines changed

src/deno_sandbox/console.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any, Literal, Optional, cast
1+
from datetime import datetime, timezone
2+
from typing import Any, Literal, Optional, TypedDict, cast
23
import httpx
34

45
from deno_sandbox.api_types_generated import (
@@ -24,6 +25,16 @@ class Revision(RevisionWithoutTimelines):
2425
"""The timelines associated with the revision."""
2526

2627

28+
class ExposeSSHResult(TypedDict):
29+
hostname: str
30+
username: str
31+
port: int
32+
33+
34+
class ExposeHTTPResult(TypedDict):
35+
domain: str
36+
37+
2738
class AsyncPaginatedList[T, O]:
2839
def __init__(
2940
self,
@@ -123,7 +134,10 @@ def client(self) -> httpx.AsyncClient:
123134
return self._client
124135

125136
async def _request(
126-
self, method: Literal["POST", "GET"], url: httpx.URL, data: Optional[Any] = None
137+
self,
138+
method: Literal["POST", "GET", "PATCH", "PUT", "DELETE"],
139+
url: httpx.URL,
140+
data: Optional[Any] = None,
127141
) -> httpx.Response:
128142
response = await self.client.request(
129143
method=method, url=url, json=data, timeout=10.0
@@ -135,13 +149,11 @@ async def _request(
135149
async def post(self, path: str, data: any) -> dict:
136150
req_url = self._options["console_url"].join(path)
137151
response = await self._request("POST", req_url, data)
138-
response.raise_for_status()
139152
return response.json()
140153

141154
async def patch(self, path: str, data: any) -> dict:
142155
req_url = self._options["console_url"].join(path)
143156
response = await self._request("PATCH", req_url, data)
144-
response.raise_for_status()
145157
return response.json()
146158

147159
async def get(
@@ -153,7 +165,6 @@ async def get(
153165
req_url = req_url.copy_add_params(params)
154166

155167
response = await self._request("GET", req_url)
156-
response.raise_for_status()
157168
return response.json()
158169

159170
async def get_or_none(
@@ -166,10 +177,10 @@ async def get_or_none(
166177
return None
167178
raise
168179

169-
async def delete(self, path: str) -> None:
180+
async def delete(self, path: str) -> httpx.Response:
170181
req_url = self._options["console_url"].join(path)
171182
response = await self._request("DELETE", req_url)
172-
response.raise_for_status()
183+
return response
173184

174185
async def get_paginated[T](
175186
self, path: str, cursor: Optional[str], params: Optional[dict[str, Any]] = None
@@ -261,6 +272,36 @@ async def _volumes_list(
261272
)
262273
return volumes
263274

275+
async def _kill_sandbox(self, sandbox_id: str) -> None:
276+
await self.delete(f"/api/v3/sandboxes/{sandbox_id}")
277+
278+
async def _extend_timeout(self, sandbox_id: str, stop_at_ms: int) -> datetime:
279+
url = self._options["sandbox_url"].join(f"/api/v3/sandbox/{sandbox_id}")
280+
281+
result = await self._request("PATCH", url, {"stop_at_ms": stop_at_ms})
282+
283+
data = result.json()
284+
285+
return datetime.fromtimestamp(data["stop_at_ms"] / 1000, tz=timezone.utc)
286+
287+
async def _expose_http(self, sandbox_id: str, params: dict[str, int]) -> str:
288+
url = self._options["sandbox_url"].join(
289+
f"/api/v3/sandbox/{sandbox_id}/expose/http"
290+
)
291+
292+
result = await self._request("POST", url, params)
293+
294+
data = cast(ExposeHTTPResult, result.json())
295+
return data["domain"]
296+
297+
async def _expose_ssh(self, sandbox_id: str) -> ExposeSSHResult:
298+
url = self._options["sandbox_url"].join(
299+
f"/api/v3/sandbox/{sandbox_id}/expose/ssh"
300+
)
301+
response = await self._request("POST", url, {})
302+
303+
return cast(ExposeSSHResult, response.json())
304+
264305

265306
class ConsoleClient:
266307
def __init__(self, options: InternalOptions, bridge: AsyncBridge):
@@ -342,5 +383,17 @@ def _volumes_list(
342383
)
343384
return PaginatedList(self._bridge, paginated)
344385

386+
def _kill_sandbox(self, sandbox_id: str) -> None:
387+
self._bridge.run(self._async._kill_sandbox(sandbox_id))
388+
389+
def _extend_timeout(self, sandbox_id: str, stop_at_ms: int) -> None:
390+
self._bridge.run(self._async._extend_timeout(sandbox_id, stop_at_ms))
391+
392+
def _expose_http(self, sandbox_id: str, params: dict[str, int]) -> str:
393+
return self._bridge.run(self._async._expose_http(sandbox_id, params))
394+
395+
def _expose_ssh(self, sandbox_id: str) -> ExposeSSHResult:
396+
return self._bridge.run(self._async._expose_ssh(sandbox_id))
397+
345398

346399
__all__ = ["AsyncConsoleClient", "ConsoleClient"]

src/deno_sandbox/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ class UnknownRpcMethod(Exception):
1919
pass
2020

2121

22+
class ProcessAlreadyExited(Exception):
23+
"""Raised when trying to interact with a process that has already exited."""
24+
25+
pass
26+
27+
28+
class HTTPStatusError(Exception):
29+
"""Raised when an HTTP request returns a non-success status code."""
30+
31+
def __init__(self, status_code: int, message: str) -> None:
32+
self.status_code = status_code
33+
self.message = message
34+
super().__init__(f"HTTP Status {status_code}: {message}")
35+
36+
2237
class ZodErrorRaw(TypedDict):
2338
expected: str
2439
code: str

src/deno_sandbox/options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class Options(TypedDict):
1414

1515
class InternalOptions(TypedDict):
1616
sandbox_ws_url: URL
17+
sandbox_url: URL
1718
console_url: URL
1819
token: str
1920
regions: list[str]
@@ -45,6 +46,7 @@ def get_internal_options(options: Optional[Options] = None) -> InternalOptions:
4546
return InternalOptions(
4647
console_url=console_url,
4748
sandbox_ws_url=sandbox_ws_url,
49+
sandbox_url=sandbox_url,
4850
token=token,
4951
regions=regions,
5052
)

src/deno_sandbox/rpc.py

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import asyncio
22
import base64
33
import json
4-
from typing import Any, Dict, TypedDict, cast
4+
from typing import Any, Dict, Literal, Optional, TypedDict, cast
55
from websockets import ConnectionClosed
66

77
from deno_sandbox.bridge import AsyncBridge
8-
from deno_sandbox.errors import RpcValidationError, UnknownRpcMethod, ZodErrorRaw
8+
from deno_sandbox.errors import (
9+
HTTPStatusError,
10+
ProcessAlreadyExited,
11+
RpcValidationError,
12+
UnknownRpcMethod,
13+
ZodErrorRaw,
14+
)
915
from deno_sandbox.transport import WebSocketTransport
1016
from deno_sandbox.utils import (
1117
convert_to_camel_case,
@@ -41,6 +47,7 @@ def __init__(self, transport: WebSocketTransport):
4147
self._listen_task: asyncio.Task[Any] | None = None
4248
self._pending_processes: Dict[int, asyncio.StreamReader] = {}
4349
self._loop: asyncio.AbstractEventLoop | None = None
50+
self._signal_id = 0
4451

4552
async def close(self):
4653
await self._transport.close()
@@ -89,7 +96,16 @@ async def call(self, method: str, params: Dict[str, Any]) -> Any:
8996
raise Exception(response["error"])
9097

9198
if response.get("result") and response["result"].get("error"):
92-
raise Exception(f"Application Error: {response['result']['error']}")
99+
err = response["result"]["error"]
100+
if (
101+
"constructor_name" in err
102+
and err["constructor_name"] == "TypeError"
103+
and "code" in err
104+
and err["code"] == "ENOENT"
105+
):
106+
raise ProcessAlreadyExited("Process has already exited")
107+
108+
raise Exception(f"Application Error: {err}")
93109
return response["result"]["ok"] if response.get("result") else None
94110

95111
async def _listener(self) -> None:
@@ -128,6 +144,34 @@ async def _listener(self) -> None:
128144
if not future.done():
129145
future.set_exception(e)
130146

147+
async def fetch(
148+
self,
149+
url: str,
150+
method: Optional[str] = "GET",
151+
headers: Optional[dict[str, str]] = None,
152+
redirect: Literal["follow", "manual"] = None,
153+
pid: Optional[int] = None,
154+
) -> AsyncFetchResponse:
155+
self._signal_id += 1
156+
signal_id = self._signal_id
157+
158+
params: FetchParams = {
159+
"url": url,
160+
"method": method or "GET",
161+
"headers": list(headers.items()) if headers else [],
162+
"redirect": redirect or "follow",
163+
"abortId": signal_id,
164+
}
165+
166+
if pid is not None:
167+
params["pid"] = pid
168+
169+
response_data = await self.call("fetch", params)
170+
response = cast(FetchResponseData, response_data)
171+
172+
fetch_response = AsyncFetchResponse(self, response)
173+
return fetch_response
174+
131175

132176
class RpcClient:
133177
def __init__(self, async_client: AsyncRpcClient, bridge: AsyncBridge):
@@ -137,5 +181,154 @@ def __init__(self, async_client: AsyncRpcClient, bridge: AsyncBridge):
137181
def call(self, method: str, params: Dict[str, Any]) -> Any:
138182
return self._bridge.run(self._async_client.call(method, params))
139183

184+
def fetch(
185+
self,
186+
url: str,
187+
method: Optional[str] = "GET",
188+
headers: Optional[dict[str, str]] = None,
189+
redirect: Literal["follow", "manual"] = None,
190+
pid: Optional[int] = None,
191+
) -> FetchResponse:
192+
response = self._bridge.run(
193+
self._async_client.fetch(url, method, headers, redirect, pid)
194+
)
195+
196+
return FetchResponse(self, response)
197+
140198
def close(self):
141199
self._bridge.run(self._async_client.close())
200+
201+
202+
class FetchParams(TypedDict):
203+
method: str
204+
url: str
205+
headers: list[tuple[str, str]]
206+
redirect: str
207+
pid: int
208+
abortId: int
209+
210+
211+
class FetchResponseData(TypedDict):
212+
status: int
213+
status_text: str
214+
headers: list[tuple[str, str]]
215+
body_stream_id: int
216+
217+
218+
class AsyncFetchResponse:
219+
def __init__(self, rpc: AsyncRpcClient, response: FetchResponseData):
220+
self._rpc = rpc
221+
self._response = response
222+
223+
def raise_for_status(self) -> HTTPStatusError | None:
224+
if self.is_success:
225+
return
226+
227+
message = "{self} resulted in a {error_type} (status code: {self.status})"
228+
status_class = self.status // 100
229+
error_types = {
230+
1: "Informational response",
231+
3: "Redirect response",
232+
4: "Client error",
233+
5: "Server error",
234+
}
235+
error_type = error_types.get(status_class, "Invalid status code")
236+
message = message.format(self, error_type=error_type)
237+
238+
return HTTPStatusError(self.status, message)
239+
240+
@property
241+
def headers(self) -> list[tuple[str, str]]:
242+
return self._response["headers"]
243+
244+
@property
245+
def status_code(self) -> int:
246+
return self._response["status"]
247+
248+
@property
249+
def is_informational(self) -> int:
250+
return 100 <= self.status_code <= 199
251+
252+
@property
253+
def is_success(self) -> int:
254+
return 200 <= self.status_code <= 299
255+
256+
@property
257+
def is_redirect(self) -> int:
258+
return 300 <= self.status_code <= 399
259+
260+
@property
261+
def is_client_error(self) -> int:
262+
return 400 <= self.status_code <= 499
263+
264+
@property
265+
def is_server_error(self) -> int:
266+
return 500 <= self.status_code <= 599
267+
268+
@property
269+
def is_error(self) -> int:
270+
return 400 <= self.status_code <= 599
271+
272+
@property
273+
def has_redirect_location(self) -> bool:
274+
return (
275+
self.status_code in (301, 302, 303, 307, 308)
276+
and "location" in self._response["headers"]
277+
)
278+
279+
async def cancel(self) -> None:
280+
await self._rpc.call("abort", {"abortId": self._response["abortId"]})
281+
282+
def __repr__(self) -> str:
283+
return f"<Response [{self.status_code}]>"
284+
285+
286+
class FetchResponse(AsyncFetchResponse):
287+
def __init__(self, rpc: AsyncRpcClient, async_res: AsyncFetchResponse):
288+
self._rpc = rpc
289+
self._async = async_res
290+
291+
def raise_for_status(self) -> HTTPStatusError | None:
292+
return self._async.raise_for_status()
293+
294+
@property
295+
def headers(self) -> list[tuple[str, str]]:
296+
return self._async.headers
297+
298+
@property
299+
def status_code(self) -> int:
300+
return self._async.status_code
301+
302+
@property
303+
def is_informational(self) -> int:
304+
return self._async.is_informational
305+
306+
@property
307+
def is_success(self) -> int:
308+
return self._async.is_success
309+
310+
@property
311+
def is_redirect(self) -> int:
312+
return self._async.is_redirect
313+
314+
@property
315+
def is_client_error(self) -> int:
316+
return self._async.is_client_error
317+
318+
@property
319+
def is_server_error(self) -> int:
320+
return self._async.is_server_error
321+
322+
@property
323+
def is_error(self) -> int:
324+
return self._async.is_error
325+
326+
@property
327+
def has_redirect_location(self) -> bool:
328+
return self._async.has_redirect_location
329+
330+
def cancel(self) -> None:
331+
self._rpc._bridge.run(self._async.cancel())
332+
333+
def __repr__(self) -> str:
334+
return f"<Response [{self.status_code}]>"

0 commit comments

Comments
 (0)