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.
- deno_sandbox-0.1.0/PKG-INFO +35 -0
- deno_sandbox-0.1.0/README.md +22 -0
- deno_sandbox-0.1.0/pyproject.toml +29 -0
- deno_sandbox-0.1.0/src/deno_sandbox/__init__.py +5 -0
- deno_sandbox-0.1.0/src/deno_sandbox/client.py +10 -0
- deno_sandbox-0.1.0/src/deno_sandbox/console.py +41 -0
- deno_sandbox-0.1.0/src/deno_sandbox/options.py +47 -0
- deno_sandbox-0.1.0/src/deno_sandbox/py.typed +0 -0
- deno_sandbox-0.1.0/src/deno_sandbox/rpc.py +97 -0
- deno_sandbox-0.1.0/src/deno_sandbox/sandbox.py +189 -0
- deno_sandbox-0.1.0/src/deno_sandbox/sandbox_generated.py +897 -0
- deno_sandbox-0.1.0/src/deno_sandbox/transport.py +55 -0
- deno_sandbox-0.1.0/src/deno_sandbox/volumes.py +60 -0
|
@@ -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,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
|