cubesandbox 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.
- cubesandbox-0.1.0/PKG-INFO +14 -0
- cubesandbox-0.1.0/README.md +233 -0
- cubesandbox-0.1.0/cubesandbox/__init__.py +24 -0
- cubesandbox-0.1.0/cubesandbox/_commands.py +51 -0
- cubesandbox-0.1.0/cubesandbox/_config.py +33 -0
- cubesandbox-0.1.0/cubesandbox/_exceptions.py +16 -0
- cubesandbox-0.1.0/cubesandbox/_filesystem.py +19 -0
- cubesandbox-0.1.0/cubesandbox/_models.py +62 -0
- cubesandbox-0.1.0/cubesandbox/_stream.py +64 -0
- cubesandbox-0.1.0/cubesandbox/_transport.py +56 -0
- cubesandbox-0.1.0/cubesandbox/sandbox.py +408 -0
- cubesandbox-0.1.0/cubesandbox.egg-info/PKG-INFO +14 -0
- cubesandbox-0.1.0/cubesandbox.egg-info/SOURCES.txt +17 -0
- cubesandbox-0.1.0/cubesandbox.egg-info/dependency_links.txt +1 -0
- cubesandbox-0.1.0/cubesandbox.egg-info/requires.txt +7 -0
- cubesandbox-0.1.0/cubesandbox.egg-info/top_level.txt +1 -0
- cubesandbox-0.1.0/pyproject.toml +49 -0
- cubesandbox-0.1.0/setup.cfg +4 -0
- cubesandbox-0.1.0/tests/test_sandbox.py +603 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cubesandbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for CubeSandbox
|
|
5
|
+
Author: TencentCloud CubeSandbox Team
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/TencentCloud/CubeSandbox
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
14
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<strong>cubesandbox</strong> — Python SDK for CubeSandbox
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/TencentCloud/CubeSandbox"><img src="https://img.shields.io/badge/CubeSandbox-GitHub-blue" alt="CubeSandbox" /></a>
|
|
7
|
+
<a href="../../LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-green" alt="Apache 2.0" /></a>
|
|
8
|
+
<img src="https://img.shields.io/badge/Python-3.9%2B-blue" alt="Python 3.9+" />
|
|
9
|
+
<img src="https://img.shields.io/badge/version-0.1.0-orange" alt="v0.1.0" />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
`cubesandbox` is the official Python SDK for [CubeSandbox](https://github.com/TencentCloud/CubeSandbox).
|
|
15
|
+
It provides a simple, Pythonic interface to create sandboxes, execute code,
|
|
16
|
+
and control the full sandbox lifecycle — including pause/resume with memory snapshot.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install cubesandbox
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or install from source:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd sdk/python
|
|
28
|
+
pip install -e .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
Set the required environment variables:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export CUBE_API_URL=http://<your-cubeapi-host>:3000
|
|
37
|
+
export CUBE_TEMPLATE_ID=<your-template-id>
|
|
38
|
+
|
|
39
|
+
# Required for remote access (bypasses DNS for *.cube.app)
|
|
40
|
+
export CUBE_PROXY_NODE_IP=<your-cubeproxy-node-ip>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run your first sandbox:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from cubesandbox import Sandbox
|
|
47
|
+
|
|
48
|
+
with Sandbox.create() as sb:
|
|
49
|
+
result = sb.run_code("1 + 1")
|
|
50
|
+
print(result.text) # "2"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
### Execute code
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from cubesandbox import Sandbox
|
|
59
|
+
|
|
60
|
+
with Sandbox.create() as sb:
|
|
61
|
+
# Simple expression
|
|
62
|
+
result = sb.run_code("x = 42\nx * 2")
|
|
63
|
+
print(result.text) # "84"
|
|
64
|
+
|
|
65
|
+
# Capture stdout
|
|
66
|
+
result = sb.run_code('print("hello")')
|
|
67
|
+
print(result.logs.stdout) # ["hello\n"]
|
|
68
|
+
|
|
69
|
+
# Stream output in real time
|
|
70
|
+
sb.run_code(
|
|
71
|
+
'for i in range(3): print(i)',
|
|
72
|
+
on_stdout=lambda msg: print("out:", msg.text),
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Persistent variables within a sandbox
|
|
77
|
+
|
|
78
|
+
Variables assigned in one `run_code` call persist for the lifetime of the sandbox —
|
|
79
|
+
no separate context object needed:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
with Sandbox.create() as sb:
|
|
83
|
+
sb.run_code("x = 100")
|
|
84
|
+
result = sb.run_code("x + 1")
|
|
85
|
+
print(result.text) # "101"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
### Pause & resume
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
sb = Sandbox.create()
|
|
93
|
+
|
|
94
|
+
# Pause — preserves memory snapshot, polls until state=paused
|
|
95
|
+
sb.pause() # wait=True, timeout=30s by default
|
|
96
|
+
sb.pause(wait=False) # fire-and-forget
|
|
97
|
+
sb.pause(timeout=60, interval=0.5) # custom poll params
|
|
98
|
+
|
|
99
|
+
# Resume by connecting — auto-resumes paused sandbox
|
|
100
|
+
sb2 = Sandbox.connect(sb.sandbox_id)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Network policy
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import json
|
|
107
|
+
from cubesandbox import Sandbox
|
|
108
|
+
|
|
109
|
+
# Deny all outbound traffic
|
|
110
|
+
with Sandbox.create(metadata={"network-policy": "deny-all"}) as sb:
|
|
111
|
+
result = sb.run_code(
|
|
112
|
+
"import urllib.request; urllib.request.urlopen('http://example.com')"
|
|
113
|
+
)
|
|
114
|
+
print(result.error.name) # "URLError"
|
|
115
|
+
|
|
116
|
+
# Custom allow-list — NOTE: only IP addresses are supported, not domain names
|
|
117
|
+
rules = json.dumps({"allow": ["151.101.0.0/16"]})
|
|
118
|
+
with Sandbox.create(
|
|
119
|
+
metadata={"network-policy": "custom", "network-rules": rules}
|
|
120
|
+
) as sb:
|
|
121
|
+
sb.run_code("import subprocess; subprocess.run(['pip', 'install', 'requests'])")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Host-directory mount
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import json
|
|
128
|
+
from cubesandbox import Sandbox
|
|
129
|
+
|
|
130
|
+
mounts = json.dumps([{"hostPath": "/data/shared", "mountPath": "/mnt/data"}])
|
|
131
|
+
with Sandbox.create(metadata={"hostdir-mount": mounts}) as sb:
|
|
132
|
+
result = sb.run_code("open('/mnt/data/hello.txt').read()")
|
|
133
|
+
print(result.text)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### List & health check
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from cubesandbox import Sandbox
|
|
140
|
+
|
|
141
|
+
print(Sandbox.health()) # {"status": "ok", "sandboxes": 4}
|
|
142
|
+
print(Sandbox.list()) # list of running sandbox dicts
|
|
143
|
+
print(Sandbox.list_v2()) # v2 API (supports filtering)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
| Environment Variable | Required | Default | Description |
|
|
149
|
+
|---|:---:|---|---|
|
|
150
|
+
| `CUBE_API_URL` | ✅ | `http://127.0.0.1:3000` | CubeAPI management plane address |
|
|
151
|
+
| `CUBE_TEMPLATE_ID` | ✅ | — | Template ID for sandbox creation |
|
|
152
|
+
| `CUBE_PROXY_NODE_IP` | remote | — | CubeProxy node IP, bypasses DNS for `*.cube.app` |
|
|
153
|
+
| `CUBE_PROXY_PORT_HTTP` | | `80` | CubeProxy HTTP port |
|
|
154
|
+
| `CUBE_SANDBOX_DOMAIN` | | `cube.app` | Sandbox domain suffix |
|
|
155
|
+
|
|
156
|
+
You can also pass a `Config` object directly:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from cubesandbox import Config, Sandbox
|
|
160
|
+
|
|
161
|
+
cfg = Config(
|
|
162
|
+
api_url="http://10.0.0.1:3000",
|
|
163
|
+
template_id="tpl-xxxxxxxxxxxxxxxxxxxxxxxx",
|
|
164
|
+
proxy_node_ip="10.0.0.1",
|
|
165
|
+
)
|
|
166
|
+
with Sandbox.create(config=cfg) as sb:
|
|
167
|
+
print(sb.run_code("2 ** 10").text) # "1024"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## API Reference
|
|
171
|
+
|
|
172
|
+
### `Sandbox` — class methods
|
|
173
|
+
|
|
174
|
+
| Method | Description |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `Sandbox.create(template, *, timeout, env_vars, metadata, config)` | `POST /sandboxes` — create a new sandbox |
|
|
177
|
+
| `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` — connect (auto-resumes if paused) |
|
|
178
|
+
| `Sandbox.list(config)` | `GET /sandboxes` — list running sandboxes (v1) |
|
|
179
|
+
| `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — list sandboxes (v2) |
|
|
180
|
+
| `Sandbox.health(config)` | `GET /health` — service health check |
|
|
181
|
+
|
|
182
|
+
### `Sandbox` — instance methods
|
|
183
|
+
|
|
184
|
+
| Method | Description |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `sb.run_code(code, *, on_stdout, on_stderr, on_result, on_error, envs, timeout)` | `POST /execute` — execute code, returns `Execution` |
|
|
187
|
+
| `sb.get_info()` | `GET /sandboxes/:id` — get sandbox state and metadata |
|
|
188
|
+
| `sb.get_info()` | `GET /sandboxes/:id` — get sandbox detail |
|
|
189
|
+
| `sb.pause(*, wait, timeout, interval)` | `POST /sandboxes/:id/pause` — pause sandbox |
|
|
190
|
+
| `sb.resume(timeout)` | `POST /sandboxes/:id/resume` — resume (deprecated, use `connect`) |
|
|
191
|
+
| `sb.kill()` | `DELETE /sandboxes/:id` — destroy sandbox |
|
|
192
|
+
| `sb.get_host(port)` | Return virtual hostname `{port}-{id}.{domain}` |
|
|
193
|
+
|
|
194
|
+
### `Execution` object
|
|
195
|
+
|
|
196
|
+
| Attribute | Type | Description |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `.text` | `str \| None` | Final expression value (main result) |
|
|
199
|
+
| `.logs.stdout` | `list[str]` | All stdout lines |
|
|
200
|
+
| `.logs.stderr` | `list[str]` | All stderr lines |
|
|
201
|
+
| `.error` | `ExecutionError \| None` | Exception info if execution failed |
|
|
202
|
+
| `.results` | `list[Result]` | All result events |
|
|
203
|
+
|
|
204
|
+
## Examples
|
|
205
|
+
|
|
206
|
+
| Script | Description |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `examples/create_and_run.py` | Create sandbox and run code |
|
|
209
|
+
| `examples/context.py` | Kernel context (server-side not yet implemented) |
|
|
210
|
+
| `examples/lifecycle.py` | Pause / connect / kill |
|
|
211
|
+
| `examples/list_and_health.py` | List sandboxes and health check |
|
|
212
|
+
| `examples/network_policy.py` | Network policy (deny-all / custom) |
|
|
213
|
+
| `examples/volume.py` | Host-directory mount |
|
|
214
|
+
| `examples/run_all.py` | Run all examples |
|
|
215
|
+
|
|
216
|
+
## DNS Bypass (Remote Access)
|
|
217
|
+
|
|
218
|
+
When running outside the CubeSandbox node, `*.cube.app` cannot be resolved by the OS DNS.
|
|
219
|
+
Set `CUBE_PROXY_NODE_IP` to enable `IPOverrideTransport`: all data-plane connections are
|
|
220
|
+
routed directly to that IP with the virtual `Host` header preserved for CubeProxy routing.
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
Without CUBE_PROXY_NODE_IP:
|
|
224
|
+
SDK → OS DNS (*.cube.app) → CubeProxy
|
|
225
|
+
|
|
226
|
+
With CUBE_PROXY_NODE_IP:
|
|
227
|
+
SDK → TCP direct to CUBE_PROXY_NODE_IP:80
|
|
228
|
+
Host: 49999-{sandboxID}.cube.app (preserved for routing)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
Apache-2.0 © 2026 Tencent Inc.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from .sandbox import Sandbox
|
|
5
|
+
from ._config import Config
|
|
6
|
+
from ._models import Execution, Result, Logs, ExecutionError, OutputMessage
|
|
7
|
+
from ._exceptions import CubeSandboxError, SandboxNotFoundError, ApiError
|
|
8
|
+
from ._commands import CommandResult
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Sandbox",
|
|
12
|
+
"Config",
|
|
13
|
+
"Execution",
|
|
14
|
+
"Result",
|
|
15
|
+
"Logs",
|
|
16
|
+
"ExecutionError",
|
|
17
|
+
"OutputMessage",
|
|
18
|
+
"CubeSandboxError",
|
|
19
|
+
"SandboxNotFoundError",
|
|
20
|
+
"ApiError",
|
|
21
|
+
"CommandResult",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .sandbox import Sandbox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CommandResult:
|
|
14
|
+
stdout: str
|
|
15
|
+
stderr: str
|
|
16
|
+
exit_code: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Commands:
|
|
20
|
+
def __init__(self, sandbox: "Sandbox") -> None:
|
|
21
|
+
self._sandbox = sandbox
|
|
22
|
+
|
|
23
|
+
def run(self, cmd: str, *, timeout: float | None = None, **kwargs) -> CommandResult:
|
|
24
|
+
"""Run a shell command inside the sandbox via Python subprocess."""
|
|
25
|
+
code = (
|
|
26
|
+
"import subprocess as _sp\n"
|
|
27
|
+
f"_r = _sp.run({cmd!r}, shell=True, capture_output=True, text=True)\n"
|
|
28
|
+
"import sys as _sys\n"
|
|
29
|
+
"_sys.stdout.write(_r.stdout)\n"
|
|
30
|
+
"_sys.stderr.write(_r.stderr)\n"
|
|
31
|
+
"print(_r.returncode)\n"
|
|
32
|
+
)
|
|
33
|
+
stdout_lines: list[str] = []
|
|
34
|
+
execution = self._sandbox.run_code(
|
|
35
|
+
code,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
on_stdout=lambda m: stdout_lines.append(m.text),
|
|
38
|
+
)
|
|
39
|
+
# last stdout line is the exit code printed by the wrapper
|
|
40
|
+
all_stdout = "".join(stdout_lines)
|
|
41
|
+
lines = all_stdout.splitlines()
|
|
42
|
+
if lines and lines[-1].strip().lstrip("-").isdigit():
|
|
43
|
+
exit_code = int(lines[-1].strip())
|
|
44
|
+
stdout = "\n".join(lines[:-1])
|
|
45
|
+
if lines[:-1]: # restore trailing newline if there was content
|
|
46
|
+
stdout += "\n"
|
|
47
|
+
else:
|
|
48
|
+
exit_code = 1 if execution.error else 0
|
|
49
|
+
stdout = all_stdout
|
|
50
|
+
stderr = "".join(execution.logs.stderr)
|
|
51
|
+
return CommandResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
"""SDK configuration. All fields can be set via environment variables."""
|
|
13
|
+
|
|
14
|
+
api_url: str = field(
|
|
15
|
+
default_factory=lambda: os.environ.get("CUBE_API_URL", "http://127.0.0.1:3000")
|
|
16
|
+
)
|
|
17
|
+
template_id: str | None = field(
|
|
18
|
+
default_factory=lambda: os.environ.get("CUBE_TEMPLATE_ID")
|
|
19
|
+
)
|
|
20
|
+
proxy_node_ip: str | None = field(
|
|
21
|
+
default_factory=lambda: os.environ.get("CUBE_PROXY_NODE_IP")
|
|
22
|
+
)
|
|
23
|
+
proxy_port: int = field(
|
|
24
|
+
default_factory=lambda: int(os.environ.get("CUBE_PROXY_PORT_HTTP", "80"))
|
|
25
|
+
)
|
|
26
|
+
sandbox_domain: str = field(
|
|
27
|
+
default_factory=lambda: os.environ.get("CUBE_SANDBOX_DOMAIN", "cube.app")
|
|
28
|
+
)
|
|
29
|
+
timeout: int = 300
|
|
30
|
+
request_timeout: float = 30.0
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
self.api_url = self.api_url.rstrip("/")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CubeSandboxError(Exception):
|
|
8
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SandboxNotFoundError(CubeSandboxError): ...
|
|
14
|
+
class TemplateNotFoundError(CubeSandboxError): ...
|
|
15
|
+
class AuthenticationError(CubeSandboxError): ...
|
|
16
|
+
class ApiError(CubeSandboxError): ...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .sandbox import Sandbox
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Filesystem:
|
|
12
|
+
def __init__(self, sandbox: "Sandbox") -> None:
|
|
13
|
+
self._sandbox = sandbox
|
|
14
|
+
|
|
15
|
+
def read(self, path: str) -> str:
|
|
16
|
+
result = self._sandbox.run_code(f"open({path!r}).read()")
|
|
17
|
+
if result.error:
|
|
18
|
+
raise IOError(f"Failed to read {path}: {result.error.value}")
|
|
19
|
+
return result.text or ""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Logs:
|
|
12
|
+
stdout: List[str] = field(default_factory=list)
|
|
13
|
+
stderr: List[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ExecutionError:
|
|
18
|
+
name: str
|
|
19
|
+
value: str
|
|
20
|
+
traceback: List[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Result:
|
|
25
|
+
text: Optional[str] = None
|
|
26
|
+
html: Optional[str] = None
|
|
27
|
+
markdown: Optional[str] = None
|
|
28
|
+
svg: Optional[str] = None
|
|
29
|
+
png: Optional[str] = None
|
|
30
|
+
jpeg: Optional[str] = None
|
|
31
|
+
pdf: Optional[str] = None
|
|
32
|
+
latex: Optional[str] = None
|
|
33
|
+
json_data: Optional[dict] = None
|
|
34
|
+
javascript: Optional[str] = None
|
|
35
|
+
is_main_result: bool = False
|
|
36
|
+
extra: Optional[dict] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Execution:
|
|
41
|
+
results: List[Result] = field(default_factory=list)
|
|
42
|
+
logs: Logs = field(default_factory=Logs)
|
|
43
|
+
error: Optional[ExecutionError] = None
|
|
44
|
+
execution_count: Optional[int] = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def text(self) -> Optional[str]:
|
|
48
|
+
"""Text of the main result (last expression value)."""
|
|
49
|
+
for r in self.results:
|
|
50
|
+
if r.is_main_result:
|
|
51
|
+
return r.text
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"Execution(text={self.text!r}, error={self.error})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class OutputMessage:
|
|
60
|
+
text: str
|
|
61
|
+
timestamp: str = ""
|
|
62
|
+
is_stderr: bool = False
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from ._models import Execution, ExecutionError, OutputMessage, Result
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_line(
|
|
16
|
+
execution: Execution,
|
|
17
|
+
line: str,
|
|
18
|
+
on_stdout: Optional[Callable[[OutputMessage], None]] = None,
|
|
19
|
+
on_stderr: Optional[Callable[[OutputMessage], None]] = None,
|
|
20
|
+
on_result: Optional[Callable[[Result], None]] = None,
|
|
21
|
+
on_error: Optional[Callable[[ExecutionError], None]] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
if not line:
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
data = json.loads(line)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
logger.debug("_parse_line: malformed JSON, skipping: %r", line)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
event_type = data.pop("type", None)
|
|
32
|
+
|
|
33
|
+
if event_type == "result":
|
|
34
|
+
result = Result(**{k: v for k, v in data.items() if k in Result.__dataclass_fields__})
|
|
35
|
+
execution.results.append(result)
|
|
36
|
+
if on_result:
|
|
37
|
+
on_result(result)
|
|
38
|
+
|
|
39
|
+
elif event_type == "stdout":
|
|
40
|
+
text = data.get("text", "")
|
|
41
|
+
execution.logs.stdout.append(text)
|
|
42
|
+
if on_stdout:
|
|
43
|
+
on_stdout(OutputMessage(text, data.get("timestamp", "")))
|
|
44
|
+
|
|
45
|
+
elif event_type == "stderr":
|
|
46
|
+
text = data.get("text", "")
|
|
47
|
+
execution.logs.stderr.append(text)
|
|
48
|
+
if on_stderr:
|
|
49
|
+
on_stderr(OutputMessage(text, data.get("timestamp", ""), is_stderr=True))
|
|
50
|
+
|
|
51
|
+
elif event_type == "error":
|
|
52
|
+
execution.error = ExecutionError(
|
|
53
|
+
name=data.get("name", ""),
|
|
54
|
+
value=data.get("value", ""),
|
|
55
|
+
traceback=data.get("traceback", []),
|
|
56
|
+
)
|
|
57
|
+
if on_error:
|
|
58
|
+
on_error(execution.error)
|
|
59
|
+
|
|
60
|
+
elif event_type == "number_of_executions":
|
|
61
|
+
execution.execution_count = data.get("execution_count")
|
|
62
|
+
|
|
63
|
+
else:
|
|
64
|
+
logger.debug("_parse_line: unknown event type %r, skipping", event_type)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ssl
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IPOverrideTransport(httpx.HTTPTransport):
|
|
14
|
+
"""Routes all TCP connections to a fixed IP:port while preserving the Host header.
|
|
15
|
+
|
|
16
|
+
Equivalent to ``curl --resolve host:port:ip``.
|
|
17
|
+
Used when ``CUBE_PROXY_NODE_IP`` is set to bypass DNS resolution of ``*.cube.app``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, ip: str, port: int, ssl_context: ssl.SSLContext | None = None, **kw):
|
|
21
|
+
super().__init__(verify=ssl_context or True, **kw)
|
|
22
|
+
self._ip = ip
|
|
23
|
+
self._port = port
|
|
24
|
+
|
|
25
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
26
|
+
original_host = request.url.host
|
|
27
|
+
url = request.url.copy_with(host=self._ip, port=self._port)
|
|
28
|
+
proxied = httpx.Request(
|
|
29
|
+
method=request.method,
|
|
30
|
+
url=url,
|
|
31
|
+
headers=[
|
|
32
|
+
(k, original_host if k.lower() == "host" else v)
|
|
33
|
+
for k, v in request.headers.raw
|
|
34
|
+
],
|
|
35
|
+
content=request.content,
|
|
36
|
+
)
|
|
37
|
+
return super().handle_request(proxied)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_client(config: Config) -> httpx.Client:
|
|
41
|
+
"""Build an httpx client for sandbox stream requests.
|
|
42
|
+
|
|
43
|
+
When ``config.proxy_node_ip`` is set, all connections are routed directly
|
|
44
|
+
to that IP, bypassing DNS. The ``Host`` header retains the virtual hostname
|
|
45
|
+
so CubeProxy can route to the correct sandbox.
|
|
46
|
+
"""
|
|
47
|
+
if config.proxy_node_ip:
|
|
48
|
+
transport = IPOverrideTransport(config.proxy_node_ip, config.proxy_port)
|
|
49
|
+
else:
|
|
50
|
+
transport = httpx.HTTPTransport()
|
|
51
|
+
|
|
52
|
+
return httpx.Client(
|
|
53
|
+
transport=transport,
|
|
54
|
+
timeout=httpx.Timeout(connect=config.request_timeout, read=None, write=30, pool=30),
|
|
55
|
+
follow_redirects=True,
|
|
56
|
+
)
|