cubesandbox 0.1.0__py3-none-any.whl
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/__init__.py +24 -0
- cubesandbox/_commands.py +51 -0
- cubesandbox/_config.py +33 -0
- cubesandbox/_exceptions.py +16 -0
- cubesandbox/_filesystem.py +19 -0
- cubesandbox/_models.py +62 -0
- cubesandbox/_stream.py +64 -0
- cubesandbox/_transport.py +56 -0
- cubesandbox/sandbox.py +408 -0
- cubesandbox-0.1.0.dist-info/METADATA +14 -0
- cubesandbox-0.1.0.dist-info/RECORD +13 -0
- cubesandbox-0.1.0.dist-info/WHEEL +5 -0
- cubesandbox-0.1.0.dist-info/top_level.txt +1 -0
cubesandbox/__init__.py
ADDED
|
@@ -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"
|
cubesandbox/_commands.py
ADDED
|
@@ -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)
|
cubesandbox/_config.py
ADDED
|
@@ -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 ""
|
cubesandbox/_models.py
ADDED
|
@@ -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
|
cubesandbox/_stream.py
ADDED
|
@@ -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
|
+
)
|
cubesandbox/sandbox.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tencent Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._commands import CommandResult, Commands
|
|
13
|
+
from ._config import Config
|
|
14
|
+
from ._exceptions import ApiError, AuthenticationError, CubeSandboxError, SandboxNotFoundError, TemplateNotFoundError
|
|
15
|
+
from ._filesystem import Filesystem
|
|
16
|
+
from ._models import Execution, ExecutionError, OutputMessage, Result
|
|
17
|
+
from ._stream import _parse_line
|
|
18
|
+
from ._transport import build_client
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
JUPYTER_PORT = 49999
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_response(resp: httpx.Response) -> None:
|
|
26
|
+
if resp.is_success:
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
msg = resp.json().get("message") or resp.json().get("detail") or resp.text
|
|
30
|
+
except Exception:
|
|
31
|
+
msg = resp.text or f"HTTP {resp.status_code}"
|
|
32
|
+
code = resp.status_code
|
|
33
|
+
if code in (401, 403):
|
|
34
|
+
raise AuthenticationError(msg, code)
|
|
35
|
+
if code == 404:
|
|
36
|
+
raise (TemplateNotFoundError if "template" in msg.lower() else SandboxNotFoundError)(msg, code)
|
|
37
|
+
raise ApiError(msg, code)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Sandbox:
|
|
41
|
+
"""A CubeSandbox code execution environment.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
with Sandbox.create() as sb:
|
|
46
|
+
sb.run_code("x = 1")
|
|
47
|
+
result = sb.run_code("x + 1")
|
|
48
|
+
print(result.text) # "2"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, data: dict, config: Optional[Config] = None) -> None:
|
|
52
|
+
self._data = data
|
|
53
|
+
self._config = config or Config()
|
|
54
|
+
self._session = self._build_session()
|
|
55
|
+
self._client: httpx.Client | None = None
|
|
56
|
+
self._commands = Commands(self)
|
|
57
|
+
self._files = Filesystem(self)
|
|
58
|
+
|
|
59
|
+
# ── properties ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def sandbox_id(self) -> str:
|
|
63
|
+
return self._data["sandboxID"]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def template_id(self) -> str:
|
|
67
|
+
return self._data["templateID"]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def domain(self) -> str:
|
|
71
|
+
return self._data.get("domain") or self._config.sandbox_domain
|
|
72
|
+
|
|
73
|
+
def get_host(self, port: int) -> str:
|
|
74
|
+
"""Return the virtual hostname for a sandbox port.
|
|
75
|
+
|
|
76
|
+
e.g. ``49999-<sandboxID>.cube.app``
|
|
77
|
+
"""
|
|
78
|
+
return f"{port}-{self.sandbox_id}.{self.domain}"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def commands(self) -> "Commands":
|
|
82
|
+
return self._commands
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def files(self) -> "Filesystem":
|
|
86
|
+
return self._files
|
|
87
|
+
|
|
88
|
+
# ── factory methods ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def create(
|
|
92
|
+
cls,
|
|
93
|
+
template: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
timeout: int | None = None,
|
|
96
|
+
env_vars: Dict[str, str] | None = None,
|
|
97
|
+
metadata: Dict[str, str] | None = None,
|
|
98
|
+
allow_internet_access: bool = True,
|
|
99
|
+
network: Dict[str, Any] | None = None,
|
|
100
|
+
config: Config | None = None,
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> "Sandbox":
|
|
103
|
+
"""POST /sandboxes - Create a new sandbox.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
template: Template ID. Falls back to ``CUBE_TEMPLATE_ID`` env var.
|
|
107
|
+
timeout: Sandbox TTL in seconds. Defaults to ``Config.timeout`` (300).
|
|
108
|
+
env_vars: Environment variables injected into the sandbox.
|
|
109
|
+
metadata: Arbitrary key-value metadata (e.g. network-policy, hostdir-mount).
|
|
110
|
+
config: SDK config. Uses default (env-based) config if omitted.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A running :class:`Sandbox` instance.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValueError: If no template ID is provided.
|
|
117
|
+
ApiError: On unexpected backend error (HTTP 500).
|
|
118
|
+
"""
|
|
119
|
+
cfg = config or Config()
|
|
120
|
+
tpl = template or cfg.template_id
|
|
121
|
+
if not tpl:
|
|
122
|
+
raise ValueError("template is required. Set CUBE_TEMPLATE_ID or pass template=")
|
|
123
|
+
|
|
124
|
+
payload: dict = {"templateID": tpl, "timeout": timeout or cfg.timeout}
|
|
125
|
+
if env_vars:
|
|
126
|
+
payload["envVars"] = env_vars
|
|
127
|
+
if metadata:
|
|
128
|
+
payload["metadata"] = metadata
|
|
129
|
+
if not allow_internet_access:
|
|
130
|
+
payload["allowInternetAccess"] = False
|
|
131
|
+
if network:
|
|
132
|
+
net: dict = {}
|
|
133
|
+
if "allow_out" in network:
|
|
134
|
+
net["allowOut"] = network["allow_out"]
|
|
135
|
+
if "deny_out" in network:
|
|
136
|
+
net["denyOut"] = network["deny_out"]
|
|
137
|
+
if net:
|
|
138
|
+
payload["network"] = net
|
|
139
|
+
payload.update(kwargs)
|
|
140
|
+
|
|
141
|
+
with httpx.Client(headers={"Content-Type": "application/json"}) as s:
|
|
142
|
+
resp = s.post(f"{cfg.api_url}/sandboxes", json=payload)
|
|
143
|
+
_check_response(resp)
|
|
144
|
+
return cls(resp.json(), config=cfg)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def connect(cls, sandbox_id: str, *, config: Config | None = None) -> "Sandbox":
|
|
148
|
+
"""POST /sandboxes/:sandboxID/connect - Connect to an existing sandbox.
|
|
149
|
+
|
|
150
|
+
Resumes the sandbox if it is currently paused.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
sandbox_id: Sandbox identifier.
|
|
154
|
+
config: SDK config. Uses default (env-based) config if omitted.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A :class:`Sandbox` instance connected to the existing sandbox.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
|
|
161
|
+
ApiError: On unexpected backend error (HTTP 500).
|
|
162
|
+
"""
|
|
163
|
+
cfg = config or Config()
|
|
164
|
+
with httpx.Client(headers={"Content-Type": "application/json"}) as s:
|
|
165
|
+
resp = s.post(
|
|
166
|
+
f"{cfg.api_url}/sandboxes/{sandbox_id}/connect",
|
|
167
|
+
json={"timeout": cfg.timeout},
|
|
168
|
+
)
|
|
169
|
+
_check_response(resp)
|
|
170
|
+
return cls(resp.json(), config=cfg)
|
|
171
|
+
|
|
172
|
+
# ── class-level API methods ───────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def list(cls, config: Config | None = None) -> list[dict]:
|
|
176
|
+
"""GET /sandboxes - List all running sandboxes (v1).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
config: SDK config. Uses default (env-based) config if omitted.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
A list of sandbox info dicts, each containing at least
|
|
183
|
+
``sandboxID``, ``templateID``, and ``state`` keys.
|
|
184
|
+
"""
|
|
185
|
+
cfg = config or Config()
|
|
186
|
+
with httpx.Client() as s:
|
|
187
|
+
resp = s.get(f"{cfg.api_url}/sandboxes")
|
|
188
|
+
_check_response(resp)
|
|
189
|
+
return resp.json()
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def list_v2(cls, config: Config | None = None) -> list[dict]:
|
|
193
|
+
"""GET /v2/sandboxes - List all running sandboxes (v2).
|
|
194
|
+
|
|
195
|
+
Supports state / metadata filtering on the server side.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
config: SDK config. Uses default (env-based) config if omitted.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A list of sandbox info dicts.
|
|
202
|
+
"""
|
|
203
|
+
cfg = config or Config()
|
|
204
|
+
with httpx.Client() as s:
|
|
205
|
+
resp = s.get(f"{cfg.api_url}/v2/sandboxes")
|
|
206
|
+
_check_response(resp)
|
|
207
|
+
return resp.json()
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def health(cls, config: Config | None = None) -> dict:
|
|
211
|
+
"""GET /health - Check the health of the CubeAPI service.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
config: SDK config. Uses default (env-based) config if omitted.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A dict with at least a ``status`` key, e.g.
|
|
218
|
+
``{"status": "ok", "sandboxes": 0}``.
|
|
219
|
+
"""
|
|
220
|
+
cfg = config or Config()
|
|
221
|
+
with httpx.Client() as s:
|
|
222
|
+
resp = s.get(f"{cfg.api_url}/health")
|
|
223
|
+
_check_response(resp)
|
|
224
|
+
return resp.json()
|
|
225
|
+
|
|
226
|
+
# ── code execution ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def run_code(
|
|
229
|
+
self,
|
|
230
|
+
code: str,
|
|
231
|
+
*,
|
|
232
|
+
language: str | None = None,
|
|
233
|
+
on_stdout: Callable[[OutputMessage], None] | None = None,
|
|
234
|
+
on_stderr: Callable[[OutputMessage], None] | None = None,
|
|
235
|
+
on_result: Callable[[Result], None] | None = None,
|
|
236
|
+
on_error: Callable[[ExecutionError], None] | None = None,
|
|
237
|
+
envs: Dict[str, str] | None = None,
|
|
238
|
+
timeout: float | None = None,
|
|
239
|
+
) -> Execution:
|
|
240
|
+
"""POST /execute - Execute code inside the sandbox.
|
|
241
|
+
|
|
242
|
+
Streams the ndjson response from the sandbox's envd process via
|
|
243
|
+
CubeProxy. When ``CUBE_PROXY_NODE_IP`` is set, connections bypass
|
|
244
|
+
DNS resolution using :class:`IPOverrideTransport`.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
code: Python code to execute.
|
|
248
|
+
language: Kernel language override (default: ``"python"``).
|
|
249
|
+
pass ``None`` (or omit) to use the sandbox's global namespace.
|
|
250
|
+
on_stdout: Callback invoked for each stdout event.
|
|
251
|
+
on_stderr: Callback invoked for each stderr event.
|
|
252
|
+
on_result: Callback invoked for each result event.
|
|
253
|
+
on_error: Callback invoked on execution error.
|
|
254
|
+
envs: Per-execution environment variables.
|
|
255
|
+
timeout: Read timeout in seconds (default: no timeout).
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
:class:`Execution` with ``.text``, ``.logs``, and ``.error``.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
ApiError: If the execute endpoint returns HTTP 4xx/5xx.
|
|
262
|
+
"""
|
|
263
|
+
if self._client is None:
|
|
264
|
+
self._client = build_client(self._config)
|
|
265
|
+
|
|
266
|
+
url = f"http://{self.get_host(JUPYTER_PORT)}/execute"
|
|
267
|
+
payload = {
|
|
268
|
+
"code": code,
|
|
269
|
+
"language": language,
|
|
270
|
+
"env_vars": envs,
|
|
271
|
+
}
|
|
272
|
+
execution = Execution()
|
|
273
|
+
|
|
274
|
+
with self._client.stream(
|
|
275
|
+
"POST", url,
|
|
276
|
+
json=payload,
|
|
277
|
+
headers={"Content-Type": "application/json"},
|
|
278
|
+
timeout=httpx.Timeout(
|
|
279
|
+
connect=self._config.request_timeout,
|
|
280
|
+
read=timeout,
|
|
281
|
+
write=30,
|
|
282
|
+
pool=30,
|
|
283
|
+
),
|
|
284
|
+
) as resp:
|
|
285
|
+
if resp.status_code >= 400:
|
|
286
|
+
raise ApiError(f"execute failed: HTTP {resp.status_code}", resp.status_code)
|
|
287
|
+
for line in resp.iter_lines():
|
|
288
|
+
_parse_line(execution, line,
|
|
289
|
+
on_stdout=on_stdout, on_stderr=on_stderr,
|
|
290
|
+
on_result=on_result, on_error=on_error)
|
|
291
|
+
|
|
292
|
+
return execution
|
|
293
|
+
|
|
294
|
+
# ── lifecycle ─────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def pause(self, *, wait: bool = True, timeout: float = 30, interval: float = 1.0) -> None:
|
|
297
|
+
"""POST /sandboxes/:sandboxID/pause - Pause a sandbox.
|
|
298
|
+
|
|
299
|
+
Preserves the sandbox memory snapshot. The sandbox can be resumed
|
|
300
|
+
later via :meth:`connect`.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
wait: If ``True`` (default), poll :meth:`get_info` until the sandbox
|
|
304
|
+
state becomes ``"paused"`` before returning.
|
|
305
|
+
timeout: Maximum seconds to wait when ``wait=True`` (default: 30).
|
|
306
|
+
interval: Polling interval in seconds (default: 1.0).
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
|
|
310
|
+
ApiError: If the sandbox cannot be paused (HTTP 409) or on
|
|
311
|
+
unexpected backend error (HTTP 500).
|
|
312
|
+
TimeoutError: If ``wait=True`` and sandbox does not reach
|
|
313
|
+
``"paused"`` state within ``timeout`` seconds.
|
|
314
|
+
"""
|
|
315
|
+
resp = self._session.post(f"{self._config.api_url}/sandboxes/{self.sandbox_id}/pause")
|
|
316
|
+
_check_response(resp)
|
|
317
|
+
if wait:
|
|
318
|
+
deadline = time.monotonic() + timeout
|
|
319
|
+
while time.monotonic() < deadline:
|
|
320
|
+
if self.get_info().get("state") == "paused":
|
|
321
|
+
return
|
|
322
|
+
time.sleep(interval)
|
|
323
|
+
raise TimeoutError(
|
|
324
|
+
f"Sandbox {self.sandbox_id!r} did not reach 'paused' state within {timeout}s"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def resume(self, timeout: int = 300) -> None:
|
|
328
|
+
"""POST /sandboxes/:sandboxID/resume - Resume a paused sandbox.
|
|
329
|
+
|
|
330
|
+
.. deprecated::
|
|
331
|
+
Use :meth:`connect` instead, which auto-resumes paused sandboxes
|
|
332
|
+
and returns a fresh :class:`Sandbox` instance.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
timeout: Sandbox TTL in seconds after resume (default: 300).
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
|
|
339
|
+
ApiError: If the sandbox is already running (HTTP 409) or on
|
|
340
|
+
unexpected backend error (HTTP 500).
|
|
341
|
+
"""
|
|
342
|
+
resp = self._session.post(
|
|
343
|
+
f"{self._config.api_url}/sandboxes/{self.sandbox_id}/resume",
|
|
344
|
+
json={"timeout": timeout},
|
|
345
|
+
)
|
|
346
|
+
_check_response(resp)
|
|
347
|
+
|
|
348
|
+
def kill(self) -> None:
|
|
349
|
+
"""DELETE /sandboxes/:sandboxID - Destroy a sandbox.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
|
|
353
|
+
ApiError: On unexpected backend error (HTTP 500).
|
|
354
|
+
"""
|
|
355
|
+
resp = self._session.delete(f"{self._config.api_url}/sandboxes/{self.sandbox_id}")
|
|
356
|
+
_check_response(resp)
|
|
357
|
+
|
|
358
|
+
def get_info(self) -> dict:
|
|
359
|
+
"""GET /sandboxes/:sandboxID - Get sandbox detail.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A dict containing ``sandboxID``, ``state``, ``cpuCount``,
|
|
363
|
+
``memoryMB``, ``startedAt``, and other sandbox metadata.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
SandboxNotFoundError: If the sandbox does not exist (HTTP 404).
|
|
367
|
+
ApiError: On unexpected backend error (HTTP 500).
|
|
368
|
+
"""
|
|
369
|
+
resp = self._session.get(f"{self._config.api_url}/sandboxes/{self.sandbox_id}")
|
|
370
|
+
_check_response(resp)
|
|
371
|
+
return resp.json()
|
|
372
|
+
|
|
373
|
+
def close(self) -> None:
|
|
374
|
+
"""Close the underlying httpx streaming client.
|
|
375
|
+
|
|
376
|
+
Called automatically by :meth:`__exit__` and :meth:`__del__`.
|
|
377
|
+
Safe to call multiple times.
|
|
378
|
+
"""
|
|
379
|
+
if self._client is not None:
|
|
380
|
+
self._client.close()
|
|
381
|
+
self._client = None
|
|
382
|
+
self._session.close()
|
|
383
|
+
|
|
384
|
+
# ── context manager ───────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
def __enter__(self) -> "Sandbox":
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
def __exit__(self, *_: Any) -> None:
|
|
390
|
+
try:
|
|
391
|
+
self.kill()
|
|
392
|
+
except CubeSandboxError:
|
|
393
|
+
pass
|
|
394
|
+
self.close()
|
|
395
|
+
|
|
396
|
+
def __del__(self) -> None:
|
|
397
|
+
self.close()
|
|
398
|
+
|
|
399
|
+
def __repr__(self) -> str:
|
|
400
|
+
return f"Sandbox(id={self.sandbox_id!r}, domain={self.domain!r})"
|
|
401
|
+
|
|
402
|
+
# ── internal ──────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
def _build_session(self) -> httpx.Client:
|
|
405
|
+
return httpx.Client(
|
|
406
|
+
headers={"Content-Type": "application/json"},
|
|
407
|
+
base_url=self._config.api_url,
|
|
408
|
+
)
|
|
@@ -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,13 @@
|
|
|
1
|
+
cubesandbox/__init__.py,sha256=2jkmkpIHJY3Wa7a5s0h1zjNpP_S18C2Xy8JvN5iec7o,559
|
|
2
|
+
cubesandbox/_commands.py,sha256=zTctnxC1q8_Ve0nsEf9CjZVMj0B5Eu0_oA--_fe47pI,1727
|
|
3
|
+
cubesandbox/_config.py,sha256=fXP_5tpjYPcMshyTrXGSYJ3PzxBSrYx8FaD597mlyFw,991
|
|
4
|
+
cubesandbox/_exceptions.py,sha256=7RF0nrKH2DEOv-TFVP_zwcIBEq38orLnKJyTEcSI5T8,478
|
|
5
|
+
cubesandbox/_filesystem.py,sha256=AiGoPcid4uebgnwl8vhDa4EvWw5ejvNMVSq4c6R8BBI,534
|
|
6
|
+
cubesandbox/_models.py,sha256=l_tafAEd1E-MG35zTweZckWltxYt9hLpg_RT2vblx3w,1494
|
|
7
|
+
cubesandbox/_stream.py,sha256=gjoY9MAx_4gdJPDV-G2dRkUZArUKf8S78BvpzUm-dNQ,1964
|
|
8
|
+
cubesandbox/_transport.py,sha256=vHy4bEpFKr4Gdn-Hcmbu51rqs6YzJBYnrpiT2NEz_-g,1839
|
|
9
|
+
cubesandbox/sandbox.py,sha256=m13fwxv20-_LogPz_hoPMqSpWdwWmzxmmoLYvlSLacw,14931
|
|
10
|
+
cubesandbox-0.1.0.dist-info/METADATA,sha256=-3Q4evAA1HiAXbB8fdT5eZsLYHJn6Qi3dmVWCDclV44,458
|
|
11
|
+
cubesandbox-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
cubesandbox-0.1.0.dist-info/top_level.txt,sha256=DOmupcsRljFKJANtyvcP81YtDhglveTqjHNVozaY14I,12
|
|
13
|
+
cubesandbox-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cubesandbox
|