ap-sandbox 0.1.2__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.
@@ -0,0 +1,5 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ dist/
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: ap-sandbox
3
+ Version: 0.1.2
4
+ Summary: A Client for Agent Platform Sandbox.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: e2b-code-interpreter>=2.4.1
8
+ Requires-Dist: pydantic-settings>=2.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.0; extra == 'dev'
12
+ Requires-Dist: pytest>=7.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ A Client for Agent Platform Sandbox.
@@ -0,0 +1,14 @@
1
+ """ap-sandbox — E2B sandbox lifecycle management for ACS environments."""
2
+
3
+ from ap_sandbox.config import SandboxConfig
4
+ from ap_sandbox.errors import SandboxError, SandboxErrorCode
5
+ from ap_sandbox.manager import SandboxManager
6
+
7
+ __all__ = [
8
+ "SandboxManager",
9
+ "SandboxConfig",
10
+ "SandboxError",
11
+ "SandboxErrorCode",
12
+ ]
13
+
14
+ __version__ = "0.1.1"
@@ -0,0 +1,180 @@
1
+ """E2B SDK wrapper for ap-sandbox.
2
+
3
+ All SDK interactions are funnelled through this module so that the rest
4
+ of the package never touches ``e2b`` directly. Every public function
5
+ accepts a ``SandboxConfig`` and raises ``SandboxError`` on failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import uuid
12
+ from typing import Any
13
+
14
+ from e2b_code_interpreter import Sandbox
15
+
16
+ from ap_sandbox.config import SandboxConfig
17
+ from ap_sandbox.errors import SandboxError, SandboxErrorCode
18
+
19
+ logger = logging.getLogger("ap_sandbox.client")
20
+
21
+
22
+ def make_request_id(biz_id: str) -> str:
23
+ """Generate a unique request ID with *biz_id* as prefix.
24
+
25
+ Format: ``{biz_id}-{8-char-hex}``
26
+ """
27
+ return f"{biz_id}-{uuid.uuid4().hex[:8]}"
28
+
29
+
30
+ def _classify_and_raise(
31
+ action: str, error: Exception, default_code: SandboxErrorCode
32
+ ) -> None:
33
+ """Inspect *error* and raise a ``SandboxError`` with the best-fit code."""
34
+ message = str(error)
35
+ lowered = message.lower()
36
+
37
+ if ("api" in lowered and "key" in lowered) or "unauthor" in lowered:
38
+ raise SandboxError(
39
+ SandboxErrorCode.AUTH_FAILED,
40
+ f"Failed to {action}: credentials rejected. Check E2B_API_KEY. "
41
+ f"Original: {message}",
42
+ cause=error,
43
+ ) from error
44
+
45
+ if "template" in lowered and (
46
+ "not found" in lowered or "unknown" in lowered
47
+ ):
48
+ raise SandboxError(
49
+ SandboxErrorCode.TEMPLATE_NOT_FOUND,
50
+ f"Failed to {action}: template not found. Check SANDBOX_TEMPLATE. "
51
+ f"Original: {message}",
52
+ cause=error,
53
+ ) from error
54
+
55
+ if "quota" in lowered or "limit" in lowered:
56
+ raise SandboxError(
57
+ SandboxErrorCode.QUOTA_EXCEEDED,
58
+ f"Failed to {action}: quota exceeded. Original: {message}",
59
+ cause=error,
60
+ ) from error
61
+
62
+ raise SandboxError(
63
+ default_code,
64
+ f"Failed to {action}: {message}",
65
+ cause=error,
66
+ ) from error
67
+
68
+
69
+ def _build_request_headers(config: SandboxConfig) -> dict[str, str]:
70
+ """Build X-Request-ID header from config for API-layer calls."""
71
+ if config.ap_sandbox_metadata and "ap-job-id" in config.ap_sandbox_metadata:
72
+ biz_id = config.ap_sandbox_metadata["ap-job-id"]
73
+ else:
74
+ biz_id = config.sandbox_template
75
+ return {"X-Request-ID": make_request_id(biz_id)}
76
+
77
+
78
+ def build_metadata(config: SandboxConfig) -> dict[str, str]:
79
+ """Build sandbox metadata by merging defaults with user-supplied extras."""
80
+ metadata = {
81
+ "e2b.agents.kruise.io/skip-init-runtime": "true",
82
+ "e2b.agents.kruise.io/create-on-no-stock": "true",
83
+ "e2b.agents.kruise.io/claim-timeout-seconds": str(
84
+ config.sandbox_create_timeout
85
+ ),
86
+ "e2b.agents.kruise.io/wait-ready-timeout-seconds": str(
87
+ config.wait_ready_timeout_sec
88
+ ),
89
+ "e2b.agents.kruise.io/reserve-failed-sandbox-for": config.reserve_failed_sandbox_for,
90
+ "e2b.agents.kruise.io/return-sandbox-ip": "true",
91
+ }
92
+ if config.ap_sandbox_metadata:
93
+ metadata.update(config.ap_sandbox_metadata)
94
+ return metadata
95
+
96
+
97
+ def create_sandbox(config: SandboxConfig, **kwargs: Any) -> Sandbox:
98
+ """Create and return an E2B ``Sandbox`` instance.
99
+
100
+ Extra *kwargs* are forwarded directly to ``Sandbox.create()``, allowing
101
+ callers to customise parameters such as ``envs``, ``secure``,
102
+ ``network``, ``lifecycle``, etc.
103
+
104
+ The ``metadata`` key is treated specially: caller-supplied metadata is
105
+ **merged** into the built-in metadata (caller values win on conflict).
106
+ All other *kwargs* fully override the corresponding defaults.
107
+ """
108
+ try:
109
+ config.validate_required()
110
+ except ValueError as value_error:
111
+ raise SandboxError(
112
+ SandboxErrorCode.CONFIG_MISSING,
113
+ str(value_error),
114
+ cause=value_error,
115
+ ) from value_error
116
+
117
+ metadata = build_metadata(config)
118
+ # Merge caller-supplied metadata into built-in metadata (caller wins)
119
+ if "metadata" in kwargs:
120
+ metadata.update(kwargs.pop("metadata"))
121
+
122
+ # Build X-Request-ID header for tracing
123
+ headers = _build_request_headers(config)
124
+ # Merge caller-supplied headers (caller wins)
125
+ if "headers" in kwargs:
126
+ headers.update(kwargs.pop("headers"))
127
+
128
+ create_params: dict[str, Any] = {
129
+ "template": config.sandbox_template,
130
+ "timeout": config.sandbox_timeout_sec,
131
+ "metadata": metadata,
132
+ "domain": config.e2b_domain,
133
+ "api_key": config.e2b_api_key,
134
+ "request_timeout": config.sandbox_create_timeout,
135
+ "headers": headers,
136
+ }
137
+ # Remaining kwargs override defaults
138
+ create_params.update(kwargs)
139
+
140
+ try:
141
+ return Sandbox.create(**create_params)
142
+ except Exception as error:
143
+ _classify_and_raise("create sandbox", error, SandboxErrorCode.CREATE_FAILED)
144
+
145
+
146
+ def kill_sandbox(sandbox: Any, config: SandboxConfig | None = None) -> None:
147
+ """Kill a running sandbox instance.
148
+
149
+ If *config* is provided, an ``X-Request-ID`` header is injected for
150
+ API-layer tracing.
151
+ """
152
+ opts: dict[str, Any] = {}
153
+ if config is not None:
154
+ opts["headers"] = _build_request_headers(config)
155
+
156
+ try:
157
+ sandbox.kill(**opts)
158
+ except AttributeError as attribute_error:
159
+ raise SandboxError(
160
+ SandboxErrorCode.KILL_FAILED,
161
+ "Sandbox object has no kill() method",
162
+ cause=attribute_error,
163
+ ) from attribute_error
164
+ except Exception as error:
165
+ _classify_and_raise("kill sandbox", error, SandboxErrorCode.KILL_FAILED)
166
+
167
+
168
+ def get_sandbox_ip(sandbox: Any, config: SandboxConfig | None = None) -> str:
169
+ """Query the sandbox for its VM IP address.
170
+
171
+ If *config* is provided, an ``X-Request-ID`` header is injected for
172
+ API-layer tracing.
173
+ """
174
+ opts: dict[str, Any] = {}
175
+ if config is not None:
176
+ opts["headers"] = _build_request_headers(config)
177
+
178
+ info = sandbox.get_info(**opts)
179
+ metadata = info.metadata or {}
180
+ return metadata.get("e2b.agents.kruise.io/sandbox-ip", "")
@@ -0,0 +1,100 @@
1
+ """Pydantic-based configuration for ap-sandbox.
2
+
3
+ All sandbox settings are loaded from environment variables with sensible
4
+ defaults. Aliases are supported so that both legacy names
5
+ (``E2B_DOMAIN``) and new names (``AP_SANDBOX_PROVIDER_ENDPOINT``) work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from typing import Any
13
+
14
+ from pydantic import model_validator
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+
18
+ class SandboxConfig(BaseSettings):
19
+ """Sandbox configuration backed by environment variables."""
20
+
21
+ model_config = SettingsConfigDict(
22
+ env_prefix="",
23
+ case_sensitive=False,
24
+ )
25
+
26
+ # --- E2B / ACS connection ---
27
+ e2b_domain: str = ""
28
+ e2b_api_key: str = ""
29
+
30
+ # --- Sandbox template & lifecycle ---
31
+ sandbox_template: str = ""
32
+ sandbox_timeout_sec: int = 3600
33
+ wait_ready_timeout_sec: int = 120
34
+ reserve_failed_sandbox_for: str = "never"
35
+
36
+ @property
37
+ def sandbox_create_timeout(self) -> int:
38
+ """Request timeout for the sandbox create API call (seconds)."""
39
+ return self.wait_ready_timeout_sec + 15
40
+
41
+ # --- Optional extra metadata (JSON string from env) ---
42
+ ap_sandbox_metadata: dict[str, str] | None = None
43
+
44
+ # ------------------------------------------------------------------
45
+ # Alias support: legacy env vars mapped to canonical fields
46
+ # ------------------------------------------------------------------
47
+ @model_validator(mode="before")
48
+ @classmethod
49
+ def apply_aliases(cls, values: dict[str, Any]) -> dict[str, Any]:
50
+ """Map alternative environment variable names to canonical fields."""
51
+ alias_map: dict[str, list[str]] = {
52
+ "e2b_domain": ["AP_SANDBOX_PROVIDER_ENDPOINT"],
53
+ "e2b_api_key": ["AP_SANDBOX_PROVIDER_CREDENTIAL"],
54
+ "sandbox_template": ["SANDBOX_TEMPLATE", "SANDBOX_TEMPLATE_ID"],
55
+ "sandbox_timeout_sec": ["SANDBOX_TIMEOUT_SEC"],
56
+ "wait_ready_timeout_sec": ["SANDBOX_READY_TIMEOUT_SEC"],
57
+ "reserve_failed_sandbox_for": ["SANDBOX_RESERVE_FAILED_FOR"],
58
+ }
59
+ for canonical, aliases in alias_map.items():
60
+ if values.get(canonical):
61
+ continue
62
+ for alias in aliases:
63
+ env_value = os.environ.get(alias, "")
64
+ if env_value:
65
+ values[canonical] = env_value
66
+ break
67
+
68
+ # Parse AP_SANDBOX_METADATA from JSON string
69
+ raw_metadata = values.get("ap_sandbox_metadata") or values.get(
70
+ "AP_SANDBOX_METADATA"
71
+ )
72
+ if isinstance(raw_metadata, str):
73
+ try:
74
+ parsed = json.loads(raw_metadata)
75
+ if isinstance(parsed, dict):
76
+ values["ap_sandbox_metadata"] = parsed
77
+ else:
78
+ values["ap_sandbox_metadata"] = None
79
+ except (json.JSONDecodeError, TypeError):
80
+ values["ap_sandbox_metadata"] = None
81
+ elif raw_metadata is not None and not isinstance(raw_metadata, dict):
82
+ # pydantic-settings may have already parsed JSON into a non-dict type
83
+ values["ap_sandbox_metadata"] = None
84
+
85
+ return values
86
+
87
+ def validate_required(self) -> None:
88
+ """Raise ``ValueError`` if critical fields are empty."""
89
+ missing: list[str] = []
90
+ if not self.e2b_domain:
91
+ missing.append("E2B_DOMAIN / AP_SANDBOX_PROVIDER_ENDPOINT")
92
+ if not self.e2b_api_key:
93
+ missing.append("E2B_API_KEY / AP_SANDBOX_PROVIDER_CREDENTIAL")
94
+ if not self.sandbox_template:
95
+ missing.append("SANDBOX_TEMPLATE / SANDBOX_TEMPLATE_ID")
96
+ if missing:
97
+ raise ValueError(
98
+ f"Missing required sandbox configuration: {', '.join(missing)}. "
99
+ "Set these environment variables before creating a sandbox."
100
+ )
@@ -0,0 +1,41 @@
1
+ """Unified error codes and exception hierarchy for ap-sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class SandboxErrorCode(IntEnum):
9
+ """Numeric error codes for programmatic branching."""
10
+
11
+ UNKNOWN = 1000
12
+ CONFIG_MISSING = 1001
13
+ AUTH_FAILED = 1002
14
+ TEMPLATE_NOT_FOUND = 1003
15
+ QUOTA_EXCEEDED = 1004
16
+ CREATE_FAILED = 1005
17
+ CONNECT_FAILED = 1006
18
+ KILL_FAILED = 1007
19
+ IP_QUERY_FAILED = 1008
20
+ TIMEOUT = 1009
21
+
22
+
23
+ class SandboxError(Exception):
24
+ """Base exception for all sandbox operations.
25
+
26
+ Attributes:
27
+ code: A ``SandboxErrorCode`` identifying the failure category.
28
+ message: Human-readable description of what went wrong.
29
+ cause: Optional underlying exception that triggered this error.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ code: SandboxErrorCode,
35
+ message: str,
36
+ cause: Exception | None = None,
37
+ ) -> None:
38
+ self.code = code
39
+ self.message = message
40
+ self.cause = cause
41
+ super().__init__(f"[{code.name}({code.value})] {message}")
@@ -0,0 +1,166 @@
1
+ """SandboxManager — the primary public interface for ap-sandbox.
2
+
3
+ Lifecycle management with signal handling, context-manager support,
4
+ and ``atexit`` safety net.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import atexit
10
+ import logging
11
+ import signal
12
+ from types import FrameType
13
+ from typing import Any
14
+ from e2b_code_interpreter import Sandbox
15
+
16
+ from ap_sandbox.client import create_sandbox, get_sandbox_ip, kill_sandbox
17
+ from ap_sandbox.config import SandboxConfig
18
+ from ap_sandbox.errors import SandboxError, SandboxErrorCode
19
+
20
+ logger = logging.getLogger("ap_sandbox.manager")
21
+
22
+
23
+ class SandboxManager:
24
+ """Create, use, and automatically clean up E2B sandboxes.
25
+
26
+ Features:
27
+ - **Signal handling**: ``SIGINT`` / ``SIGTERM`` trigger automatic cleanup.
28
+ - **Context manager**: ``with SandboxManager() as mgr: ...``
29
+ - **atexit**: registered as a last-resort cleanup.
30
+
31
+ Example::
32
+
33
+ from ap_sandbox import SandboxManager
34
+
35
+ with SandboxManager() as mgr:
36
+ mgr.create()
37
+ print(mgr.sandbox_ip)
38
+ """
39
+
40
+ def __init__(self, config: SandboxConfig | None = None) -> None:
41
+ self.config = config or SandboxConfig()
42
+ self._sandbox: Sandbox | None = None
43
+ self._sandbox_ip: str | None = None
44
+
45
+ self._prev_sigint: Any = None
46
+ self._prev_sigterm: Any = None
47
+ self._killed = False
48
+
49
+ self._install_signal_handlers()
50
+ atexit.register(self.kill)
51
+
52
+ # ------------------------------------------------------------------
53
+ # Public API
54
+ # ------------------------------------------------------------------
55
+
56
+ def create(self, **kwargs: Any) -> "SandboxManager":
57
+ """Create a sandbox and query its Sandbox IP. Returns *self* for chaining.
58
+
59
+ Extra *kwargs* are forwarded to ``Sandbox.create()``. This allows
60
+ callers to pass E2B-supported parameters like ``envs``, ``secure``,
61
+ ``network``, ``lifecycle``, ``volume_mounts``, etc.
62
+
63
+ Example::
64
+
65
+ mgr.create(envs={"MY_VAR": "value"}, secure=False)
66
+ """
67
+ self._sandbox = create_sandbox(self.config, **kwargs)
68
+ sandbox_id = self._get_sandbox_id()
69
+ logger.info("Sandbox %s created", sandbox_id)
70
+
71
+ self._sandbox_ip = get_sandbox_ip(self._sandbox, self.config)
72
+ logger.info("Sandbox %s IP: %s", sandbox_id, self._sandbox_ip)
73
+ return self
74
+
75
+ def kill(self) -> None:
76
+ """Idempotently destroy the sandbox and clean up internal state."""
77
+ if self._killed or self._sandbox is None:
78
+ return
79
+ sandbox_id = self._get_sandbox_id()
80
+ try:
81
+ kill_sandbox(self._sandbox, self.config)
82
+ logger.info("Sandbox %s killed", sandbox_id)
83
+ except Exception as error:
84
+ logger.error("Failed to kill sandbox %s: %s", sandbox_id, error)
85
+ finally:
86
+ self._sandbox = None
87
+ self._sandbox_ip = None
88
+ self._killed = True
89
+
90
+ @property
91
+ def sandbox(self) -> Sandbox | None:
92
+ """The underlying E2B sandbox object, or ``None`` if not created."""
93
+ return self._sandbox
94
+
95
+ @property
96
+ def sandbox_id(self) -> str | None:
97
+ """The sandbox ID string, or ``None`` if not created."""
98
+ if self._sandbox is None:
99
+ return None
100
+ return self._get_sandbox_id()
101
+
102
+ @property
103
+ def sandbox_ip(self) -> str | None:
104
+ """The sandbox IP string, or ``None`` if not created."""
105
+ if self._sandbox is None:
106
+ return None
107
+ return self._sandbox_ip
108
+
109
+ # ------------------------------------------------------------------
110
+ # Context manager
111
+ # ------------------------------------------------------------------
112
+
113
+ def __enter__(self) -> "SandboxManager":
114
+ return self
115
+
116
+ def __exit__(self, *_exc: object) -> None:
117
+ self.kill()
118
+
119
+ # ------------------------------------------------------------------
120
+ # Signal handling
121
+ # ------------------------------------------------------------------
122
+
123
+ def _install_signal_handlers(self) -> None:
124
+ """Register ``SIGINT`` / ``SIGTERM`` handlers that clean up the sandbox."""
125
+ try:
126
+ self._prev_sigint = signal.getsignal(signal.SIGINT)
127
+ self._prev_sigterm = signal.getsignal(signal.SIGTERM)
128
+ signal.signal(signal.SIGINT, self._signal_handler)
129
+ signal.signal(signal.SIGTERM, self._signal_handler)
130
+ except (OSError, ValueError):
131
+ # Not on main thread or signals not supported — skip silently.
132
+ pass
133
+
134
+ def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
135
+ logger.warning("Signal %s received, cleaning up sandbox", signum)
136
+ self.kill()
137
+ self._restore_signal_handlers()
138
+
139
+ if signum == signal.SIGINT:
140
+ if callable(self._prev_sigint):
141
+ self._prev_sigint(signum, frame)
142
+ raise KeyboardInterrupt
143
+ if callable(self._prev_sigterm):
144
+ self._prev_sigterm(signum, frame)
145
+ raise SystemExit(0)
146
+
147
+ def _restore_signal_handlers(self) -> None:
148
+ try:
149
+ if self._prev_sigint is not None:
150
+ signal.signal(signal.SIGINT, self._prev_sigint)
151
+ if self._prev_sigterm is not None:
152
+ signal.signal(signal.SIGTERM, self._prev_sigterm)
153
+ except (OSError, ValueError):
154
+ pass
155
+
156
+ # ------------------------------------------------------------------
157
+ # Internals
158
+ # ------------------------------------------------------------------
159
+
160
+ def _get_sandbox_id(self) -> str:
161
+ sandbox_id = getattr(self._sandbox, "id", None) or getattr(
162
+ self._sandbox, "sandbox_id", None
163
+ )
164
+ if not sandbox_id:
165
+ return "<unknown>"
166
+ return str(sandbox_id)
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ap-sandbox"
7
+ version = "0.1.2"
8
+ description = "A Client for Agent Platform Sandbox."
9
+ readme = { text = "A Client for Agent Platform Sandbox.", content-type = "text/markdown" }
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "e2b-code-interpreter>=2.4.1",
14
+ "pydantic>=2.0",
15
+ "pydantic-settings>=2.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=7.0",
21
+ "mypy>=1.0",
22
+ ]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
26
+ addopts = "-v --tb=short"
27
+
28
+ [tool.hatch.build.targets.sdist]
29
+ only-include = [
30
+ "/ap_sandbox",
31
+ "/pyproject.toml",
32
+ ]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["ap_sandbox"]