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.
@@ -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)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cubesandbox