chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
@@ -0,0 +1,420 @@
import threading
from typing import Callable, Dict, List, Literal, Optional, Union, overload
import e2b_connect
import httpx
from packaging.version import Version
from e2b.api import make_logging_event_hooks
from e2b.api.client_sync import get_envd_transport
from e2b.connection_config import (
ConnectionConfig,
Username,
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
)
from e2b.envd.process import process_connect, process_pb2
from e2b.envd.api import check_sandbox_health
from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health
from e2b.envd.versions import ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE
from e2b.exceptions import SandboxException
from e2b.sandbox.commands.main import ProcessInfo
from e2b.sandbox.commands.command_handle import CommandResult
from e2b.sandbox_sync.commands.command_handle import CommandHandle
class Commands:
"""
Module for executing commands in the sandbox.
"""
def __init__(
self,
envd_api_url: str,
connection_config: ConnectionConfig,
envd_version: Version,
) -> None:
self._envd_api_url = envd_api_url
self._connection_config = connection_config
self._envd_version = envd_version
self._thread_local = threading.local()
def _create_envd_api(self) -> httpx.Client:
transport = get_envd_transport(self._connection_config)
return httpx.Client(
base_url=self._envd_api_url,
transport=transport,
headers=self._connection_config.sandbox_headers,
event_hooks=make_logging_event_hooks(self._connection_config.logger),
)
def _create_rpc(self) -> process_connect.ProcessClient:
transport = get_envd_transport(self._connection_config)
return process_connect.ProcessClient(
self._envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
pool=transport.pool,
json=True,
headers=self._connection_config.sandbox_headers,
logger=self._connection_config.logger,
)
@property
def _envd_api(self) -> httpx.Client:
envd_api = getattr(self._thread_local, "envd_api", None)
if envd_api is None:
envd_api = self._create_envd_api()
self._thread_local.envd_api = envd_api
return envd_api
@property
def _rpc(self) -> process_connect.ProcessClient:
rpc = getattr(self._thread_local, "rpc", None)
if rpc is None:
rpc = self._create_rpc()
self._thread_local.rpc = rpc
return rpc
def _check_health(self) -> Optional[bool]:
return check_sandbox_health(self._envd_api)
def list(
self,
request_timeout: Optional[float] = None,
) -> List[ProcessInfo]:
"""
Lists all running commands and PTY sessions.
:param request_timeout: Timeout for the request in **seconds**
:return: List of running commands and PTY sessions
"""
try:
res = self._rpc.list(
process_pb2.ListRequest(),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
return [
ProcessInfo(
pid=p.pid,
tag=p.tag if p.HasField("tag") else None,
cmd=p.config.cmd,
args=list(p.config.args),
envs=dict(p.config.envs),
cwd=p.config.cwd if p.config.HasField("cwd") else None,
)
for p in res.processes
]
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
def kill(
self,
pid: int,
request_timeout: Optional[float] = None,
) -> bool:
"""
Kill a running command specified by its process ID.
It uses `SIGKILL` signal to kill the command.
:param pid: Process ID of the command. You can get the list of processes using `sandbox.commands.list()`
:param request_timeout: Timeout for the request in **seconds**
:return: `True` if the command was killed, `False` if the command was not found
"""
try:
self._rpc.send_signal(
process_pb2.SendSignalRequest(
process=process_pb2.ProcessSelector(pid=pid),
signal=process_pb2.Signal.SIGNAL_SIGKILL,
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
return True
except Exception as e:
if isinstance(e, e2b_connect.ConnectException):
if e.status == e2b_connect.Code.not_found:
return False
raise handle_rpc_exception_with_health(e, self._check_health)
def send_stdin(
self,
pid: int,
data: Union[str, bytes],
request_timeout: Optional[float] = None,
):
"""
Send data to command stdin.
:param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`.
:param data: Data to send to the command
:param request_timeout: Timeout for the request in **seconds**
"""
try:
self._rpc.send_input(
process_pb2.SendInputRequest(
process=process_pb2.ProcessSelector(pid=pid),
input=process_pb2.ProcessInput(
stdin=data.encode() if isinstance(data, str) else data,
),
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
def close_stdin(
self,
pid: int,
request_timeout: Optional[float] = None,
) -> None:
"""
Close the command stdin.
This signals EOF to the command. The command must have been started with `stdin=True`.
:param pid Process ID of the command. You can get the list of processes using `sandbox.commands.list()`.
:param request_timeout: Timeout for the request in **seconds**
"""
if self._envd_version < ENVD_ENVD_CLOSE:
raise SandboxException(
f"Sandbox envd version {self._envd_version} doesn't support closing stdin. "
f"Please rebuild your template to pick up the latest sandbox version."
)
try:
self._rpc.close_stdin(
process_pb2.CloseStdinRequest(
process=process_pb2.ProcessSelector(pid=pid),
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
@overload
def run(
self,
cmd: str,
background: Union[Literal[False], None] = None,
envs: Optional[Dict[str, str]] = None,
user: Optional[Username] = None,
cwd: Optional[str] = None,
on_stdout: Optional[Callable[[str], None]] = None,
on_stderr: Optional[Callable[[str], None]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> CommandResult:
"""
Start a new command and wait until it finishes executing.
:param cmd: Command to execute
:param background: **`False` if the command should be executed in the foreground**, `True` if the command should be executed in the background
:param envs: Environment variables used for the command
:param user: User to run the command as
:param cwd: Working directory to run the command
:param on_stdout: Callback for command stdout output
:param on_stderr: Callback for command stderr output
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for the request in **seconds**
:return: `CommandResult` result of the command execution
"""
...
@overload
def run(
self,
cmd: str,
background: Literal[True],
envs: Optional[Dict[str, str]] = None,
user: Optional[Username] = None,
cwd: Optional[str] = None,
on_stdout: None = None,
on_stderr: None = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> CommandHandle:
"""
Start a new command and return a handle to interact with it.
:param cmd: Command to execute
:param background: `False` if the command should be executed in the foreground, **`True` if the command should be executed in the background**
:param envs: Environment variables used for the command
:param user: User to run the command as
:param cwd: Working directory to run the command
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param request_timeout: Timeout for the request in **seconds**
:return: `CommandHandle` handle to interact with the running command
"""
...
def run(
self,
cmd: str,
background: Union[bool, None] = None,
envs: Optional[Dict[str, str]] = None,
user: Optional[Username] = None,
cwd: Optional[str] = None,
on_stdout: Optional[Callable[[str], None]] = None,
on_stderr: Optional[Callable[[str], None]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
):
# Check version for stdin support
if stdin is False and self._envd_version < ENVD_COMMANDS_STDIN:
raise SandboxException(
f"Sandbox envd version {self._envd_version} can't specify stdin, it's always turned on. "
f"Please rebuild your template if you need this feature."
)
# Default to `False`
stdin = stdin or False
proc = self._start(
cmd,
envs,
user,
cwd,
stdin,
timeout,
request_timeout,
)
return (
proc
if background
else proc.wait(
on_stdout=on_stdout,
on_stderr=on_stderr,
)
)
def _start(
self,
cmd: str,
envs: Optional[Dict[str, str]],
user: Optional[Username],
cwd: Optional[str],
stdin: bool,
timeout: Optional[float],
request_timeout: Optional[float],
):
events = self._rpc.start(
process_pb2.StartRequest(
process=process_pb2.ProcessConfig(
cmd="/bin/bash",
envs=envs,
args=["-l", "-c", cmd],
cwd=cwd,
),
stdin=stdin,
),
headers={
**authentication_header(self._envd_version, user),
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
timeout=timeout,
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
try:
start_event = events.__next__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to start process: expected start event, got {start_event}"
)
pid = start_event.event.start.pid
return CommandHandle(
pid=pid,
handle_kill=lambda: self.kill(pid),
events=events,
handle_send_stdin=lambda data, request_timeout=None: self.send_stdin(
pid, data, request_timeout
),
handle_close_stdin=lambda request_timeout=None: self.close_stdin(
pid, request_timeout
),
check_health=self._check_health,
)
except Exception as e:
try:
events.close()
except Exception:
pass
raise handle_rpc_exception_with_health(e, self._check_health)
def connect(
self,
pid: int,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
):
"""
Connects to a running command.
You can use `CommandHandle.wait()` to wait for the command to finish and get execution results.
:param pid: Process ID of the command to connect to. You can get the list of processes using `sandbox.commands.list()`
:param timeout: Timeout for the connection in **seconds**. Using `0` will not limit the connection time
:param request_timeout: Timeout for the request in **seconds**
:return: `CommandHandle` handle to interact with the running command
"""
events = self._rpc.connect(
process_pb2.ConnectRequest(
process=process_pb2.ProcessSelector(pid=pid),
),
headers={
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
timeout=timeout,
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
try:
start_event = events.__next__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to connect to process: expected start event, got {start_event}"
)
pid = start_event.event.start.pid
return CommandHandle(
pid=pid,
handle_kill=lambda: self.kill(pid),
events=events,
handle_send_stdin=lambda data, request_timeout=None: self.send_stdin(
pid, data, request_timeout
),
handle_close_stdin=lambda request_timeout=None: self.close_stdin(
pid, request_timeout
),
check_health=self._check_health,
)
except Exception as e:
try:
events.close()
except Exception:
pass
raise handle_rpc_exception_with_health(e, self._check_health)
@@ -0,0 +1,239 @@
import codecs
from typing import Optional, Callable, Any, Generator, List, Union, Tuple
from e2b.envd.rpc import handle_rpc_exception_with_health
from e2b.envd.process import process_pb2
from e2b.exceptions import SandboxException
from e2b.sandbox.commands.command_handle import (
CommandExitException,
CommandResult,
Stderr,
Stdout,
PtyOutput,
)
class CommandHandle:
"""
Command execution handle.
It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command.
"""
@property
def pid(self):
"""
Command process ID.
"""
return self._pid
def __init__(
self,
pid: int,
handle_kill: Callable[[], bool],
events: Generator[
Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any, None
],
handle_send_stdin: Optional[
Callable[[Union[str, bytes], Optional[float]], None]
] = None,
handle_close_stdin: Optional[Callable[[Optional[float]], None]] = None,
check_health: Optional[Callable[[], Optional[bool]]] = None,
):
self._pid = pid
self._handle_kill = handle_kill
self._handle_send_stdin = handle_send_stdin
self._handle_close_stdin = handle_close_stdin
self._check_health = check_health
self._events = events
self._stdout_chunks: List[str] = []
self._stderr_chunks: List[str] = []
self._stdout_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
self._stderr_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
self._result: Optional[CommandResult] = None
self._iteration_exception: Optional[Exception] = None
def __iter__(self):
"""
Iterate over the command output.
:return: Generator of command outputs
"""
return self._handle_events()
def _flush_decoders(
self,
) -> List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]]:
"""
Flush any bytes still buffered in the stream decoders.
Incomplete trailing UTF-8 sequences are emitted as replacement
characters, matching the per-chunk decoding behavior.
"""
events: List[Union[Tuple[Stdout, None, None], Tuple[None, Stderr, None]]] = []
out = self._stdout_decoder.decode(b"", final=True)
if out:
self._stdout_chunks.append(out)
events.append((out, None, None))
err = self._stderr_decoder.decode(b"", final=True)
if err:
self._stderr_chunks.append(err)
events.append((None, err, None))
return events
def _handle_events(
self,
) -> Generator[
Union[
Tuple[Stdout, None, None],
Tuple[None, Stderr, None],
Tuple[None, None, PtyOutput],
],
None,
None,
]:
try:
for event in self._events:
if event.event.HasField("data"):
if event.event.data.stdout:
out = self._stdout_decoder.decode(event.event.data.stdout)
if out:
self._stdout_chunks.append(out)
yield out, None, None
if event.event.data.stderr:
out = self._stderr_decoder.decode(event.event.data.stderr)
if out:
self._stderr_chunks.append(out)
yield None, out, None
if event.event.data.pty:
yield None, None, event.event.data.pty
if event.event.HasField("end"):
# Flush trailing decoder bytes into the accumulators and
# record the result before yielding the flushed chunks, so a
# consumer that stops iterating on the first flushed chunk
# still observes the exit code.
flushed = list(self._flush_decoders())
self._result = CommandResult(
stdout="".join(self._stdout_chunks),
stderr="".join(self._stderr_chunks),
exit_code=event.event.end.exit_code,
error=event.event.end.error,
)
yield from flushed
# If the stream closed without an end event (e.g. disconnect or a
# dropped connection), flush any bytes still buffered in the
# decoders so incomplete trailing sequences surface as replacement
# characters instead of being silently dropped.
if self._result is None:
yield from self._flush_decoders()
except Exception as e:
# The stream raised before an end event (e.g. disconnect or RPC
# failure). Flush any bytes still buffered in the decoders so
# incomplete trailing sequences surface as replacement characters
# instead of being silently dropped, then surface the error.
yield from self._flush_decoders()
raise handle_rpc_exception_with_health(e, self._check_health)
def disconnect(self) -> None:
"""
Disconnect from the command.
The command is not killed, but SDK stops receiving events from the command.
You can reconnect to the command using `sandbox.commands.connect` method.
"""
self._events.close()
def wait(
self,
on_pty: Optional[Callable[[PtyOutput], None]] = None,
on_stdout: Optional[Callable[[str], None]] = None,
on_stderr: Optional[Callable[[str], None]] = None,
) -> CommandResult:
"""
Wait for the command to finish and returns the result.
If the command exits with a non-zero exit code, it throws a `CommandExitException`.
:param on_pty: Callback for pty output
:param on_stdout: Callback for stdout output
:param on_stderr: Callback for stderr output
:return: `CommandResult` result of command execution
"""
try:
for stdout, stderr, pty in self:
if stdout is not None and on_stdout:
on_stdout(stdout)
elif stderr is not None and on_stderr:
on_stderr(stderr)
elif pty is not None and on_pty:
on_pty(pty)
except StopIteration:
pass
except Exception as e:
self._iteration_exception = handle_rpc_exception_with_health(
e, self._check_health
)
if self._iteration_exception:
raise self._iteration_exception
if self._result is None:
raise Exception("Command ended without an end event")
if self._result.exit_code != 0:
raise CommandExitException(
stdout="".join(self._stdout_chunks),
stderr="".join(self._stderr_chunks),
exit_code=self._result.exit_code,
error=self._result.error,
)
return self._result
def kill(self) -> bool:
"""
Kills the command.
It uses `SIGKILL` signal to kill the command.
:return: Whether the command was killed successfully
"""
return self._handle_kill()
def send_stdin(
self,
data: Union[str, bytes],
request_timeout: Optional[float] = None,
) -> None:
"""
Send data to the command stdin.
The command must have been started with `stdin=True`.
:param data: Data to send to the command
:param request_timeout: Timeout for the request in **seconds**
"""
if self._handle_send_stdin is None:
raise SandboxException(
"Sending stdin is not supported for this command handle."
)
self._handle_send_stdin(data, request_timeout)
def close_stdin(self, request_timeout: Optional[float] = None) -> None:
"""
Close the command stdin.
This signals EOF to the command. The command must have been started with `stdin=True`.
:param request_timeout: Timeout for the request in **seconds**
"""
if self._handle_close_stdin is None:
raise SandboxException(
"Closing stdin is not supported for this command handle."
)
self._handle_close_stdin(request_timeout)
@@ -0,0 +1,279 @@
import e2b_connect
import httpx
import threading
from typing import Dict, Optional
from packaging.version import Version
from e2b.api import make_logging_event_hooks
from e2b.api.client_sync import get_envd_transport
from e2b.envd.process import process_connect, process_pb2
from e2b.connection_config import (
Username,
ConnectionConfig,
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
)
from e2b.exceptions import SandboxException
from e2b.envd.api import check_sandbox_health
from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health
from e2b.sandbox.commands.command_handle import PtySize
from e2b.sandbox_sync.commands.command_handle import CommandHandle
class Pty:
"""
Module for interacting with PTYs (pseudo-terminals) in the sandbox.
"""
def __init__(
self,
envd_api_url: str,
connection_config: ConnectionConfig,
envd_version: Version,
) -> None:
self._envd_api_url = envd_api_url
self._connection_config = connection_config
self._envd_version = envd_version
self._thread_local = threading.local()
def _create_envd_api(self) -> httpx.Client:
transport = get_envd_transport(self._connection_config)
return httpx.Client(
base_url=self._envd_api_url,
transport=transport,
headers=self._connection_config.sandbox_headers,
event_hooks=make_logging_event_hooks(self._connection_config.logger),
)
def _create_rpc(self) -> process_connect.ProcessClient:
transport = get_envd_transport(self._connection_config)
return process_connect.ProcessClient(
self._envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
pool=transport.pool,
json=True,
headers=self._connection_config.sandbox_headers,
logger=self._connection_config.logger,
)
@property
def _envd_api(self) -> httpx.Client:
envd_api = getattr(self._thread_local, "envd_api", None)
if envd_api is None:
envd_api = self._create_envd_api()
self._thread_local.envd_api = envd_api
return envd_api
@property
def _rpc(self) -> process_connect.ProcessClient:
rpc = getattr(self._thread_local, "rpc", None)
if rpc is None:
rpc = self._create_rpc()
self._thread_local.rpc = rpc
return rpc
def _check_health(self) -> Optional[bool]:
return check_sandbox_health(self._envd_api)
def kill(
self,
pid: int,
request_timeout: Optional[float] = None,
) -> bool:
"""
Kill PTY.
:param pid: Process ID of the PTY
:param request_timeout: Timeout for the request in **seconds**
:return: `true` if the PTY was killed, `false` if the PTY was not found
"""
try:
self._rpc.send_signal(
process_pb2.SendSignalRequest(
process=process_pb2.ProcessSelector(pid=pid),
signal=process_pb2.Signal.SIGNAL_SIGKILL,
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
return True
except Exception as e:
if isinstance(e, e2b_connect.ConnectException):
if e.status == e2b_connect.Code.not_found:
return False
raise handle_rpc_exception_with_health(e, self._check_health)
def send_stdin(
self,
pid: int,
data: bytes,
request_timeout: Optional[float] = None,
) -> None:
"""
Send input to a PTY.
:param pid: Process ID of the PTY
:param data: Input data to send
:param request_timeout: Timeout for the request in **seconds**
"""
try:
self._rpc.send_input(
process_pb2.SendInputRequest(
process=process_pb2.ProcessSelector(pid=pid),
input=process_pb2.ProcessInput(
pty=data,
),
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
def create(
self,
size: PtySize,
user: Optional[Username] = None,
cwd: Optional[str] = None,
envs: Optional[Dict[str, str]] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> CommandHandle:
"""
Start a new PTY (pseudo-terminal).
:param size: Size of the PTY
:param user: User to use for the PTY
:param cwd: Working directory for the PTY
:param envs: Environment variables for the PTY
:param timeout: Timeout for the PTY in **seconds**
:param request_timeout: Timeout for the request in **seconds**
:return: Handle to interact with the PTY
"""
envs = dict(envs) if envs else {}
envs.setdefault("TERM", "xterm-256color")
envs.setdefault("LANG", "C.UTF-8")
envs.setdefault("LC_ALL", "C.UTF-8")
events = self._rpc.start(
process_pb2.StartRequest(
process=process_pb2.ProcessConfig(
cmd="/bin/bash",
envs=envs,
args=["-i", "-l"],
cwd=cwd,
),
pty=process_pb2.PTY(
size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols)
),
),
headers={
**authentication_header(self._envd_version, user),
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
timeout=timeout,
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
try:
start_event = events.__next__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to start process: expected start event, got {start_event}"
)
return CommandHandle(
pid=start_event.event.start.pid,
handle_kill=lambda: self.kill(start_event.event.start.pid),
events=events,
check_health=self._check_health,
)
except Exception as e:
try:
events.close()
except Exception:
pass
raise handle_rpc_exception_with_health(e, self._check_health)
def connect(
self,
pid: int,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> CommandHandle:
"""
Connect to a running PTY.
:param pid: Process ID of the PTY to connect to. You can get the list of running PTYs using `sandbox.pty.list()`.
:param timeout: Timeout for the PTY connection in **seconds**. Using `0` will not limit the connection time
:param request_timeout: Timeout for the request in **seconds**
:return: Handle to interact with the PTY
"""
events = self._rpc.connect(
process_pb2.ConnectRequest(
process=process_pb2.ProcessSelector(pid=pid),
),
headers={
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
timeout=timeout,
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
try:
start_event = events.__next__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to connect to process: expected start event, got {start_event}"
)
return CommandHandle(
pid=start_event.event.start.pid,
handle_kill=lambda: self.kill(start_event.event.start.pid),
events=events,
check_health=self._check_health,
)
except Exception as e:
try:
events.close()
except Exception:
pass
raise handle_rpc_exception_with_health(e, self._check_health)
def resize(
self,
pid: int,
size: PtySize,
request_timeout: Optional[float] = None,
) -> None:
"""
Resize PTY.
Call this when the terminal window is resized and the number of columns and rows has changed.
:param pid: Process ID of the PTY
:param size: New size of the PTY
:param request_timeout: Timeout for the request in **seconds**
"""
self._rpc.update(
process_pb2.UpdateRequest(
process=process_pb2.ProcessSelector(pid=pid),
pty=process_pb2.PTY(
size=process_pb2.PTY.Size(rows=size.rows, cols=size.cols),
),
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
)
@@ -0,0 +1,710 @@
import threading
from typing import IO, Dict, List, Literal, Optional, Union, overload
import httpx
from packaging.version import Version
import e2b_connect
from e2b.api import make_logging_event_hooks
from e2b.api.client_sync import get_envd_transport
from e2b.connection_config import (
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
ConnectionConfig,
Username,
default_username,
)
from e2b_connect.client import Code
from e2b.envd.api import (
ENVD_API_FILES_ROUTE,
check_sandbox_health,
handle_envd_api_exception,
handle_envd_api_transport_exception_with_health,
)
from e2b.envd.filesystem import filesystem_connect, filesystem_pb2
from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health
from e2b.envd.versions import (
ENVD_DEFAULT_USER,
ENVD_FILE_METADATA,
ENVD_OCTET_STREAM_UPLOAD,
ENVD_VERSION_FS_EVENT_ENTRY_INFO,
ENVD_VERSION_RECURSIVE_WATCH,
ENVD_VERSION_WATCH_NETWORK_MOUNTS,
)
from e2b.exceptions import (
FileNotFoundException,
InvalidArgumentException,
SandboxException,
TemplateException,
)
from e2b.sandbox.filesystem.filesystem import (
EntryInfo,
FileStreamReader,
WriteEntry,
WriteInfo,
_to_httpx_file,
map_entry_info,
map_file_type,
metadata_to_headers,
to_upload_body,
validate_metadata,
)
from e2b.sandbox_sync.filesystem.watch_handle import WatchHandle
_FILESYSTEM_RPC_ERROR_MAP = {
Code.not_found: FileNotFoundException,
}
_FILESYSTEM_HTTP_ERROR_MAP = {
404: FileNotFoundException,
}
def _handle_filesystem_rpc_exception(e: Exception, envd_api: httpx.Client) -> Exception:
return handle_rpc_exception_with_health(
e, lambda: check_sandbox_health(envd_api), _FILESYSTEM_RPC_ERROR_MAP
)
def _handle_filesystem_envd_api_exception(r):
return handle_envd_api_exception(r, _FILESYSTEM_HTTP_ERROR_MAP)
class Filesystem:
"""
Module for interacting with the filesystem in the sandbox.
"""
def __init__(
self,
envd_api_url: str,
envd_version: Version,
connection_config: ConnectionConfig,
) -> None:
self._envd_api_url = envd_api_url
self._envd_version = envd_version
self._connection_config = connection_config
self._thread_local = threading.local()
def _create_envd_api(self) -> httpx.Client:
transport = get_envd_transport(self._connection_config)
return httpx.Client(
base_url=self._envd_api_url,
transport=transport,
headers=self._connection_config.sandbox_headers,
event_hooks=make_logging_event_hooks(self._connection_config.logger),
)
def _create_rpc(self) -> filesystem_connect.FilesystemClient:
transport = get_envd_transport(self._connection_config)
return filesystem_connect.FilesystemClient(
self._envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
pool=transport.pool,
json=True,
headers=self._connection_config.sandbox_headers,
logger=self._connection_config.logger,
)
@property
def _envd_api(self) -> httpx.Client:
envd_api = getattr(self._thread_local, "envd_api", None)
if envd_api is None:
envd_api = self._create_envd_api()
self._thread_local.envd_api = envd_api
return envd_api
@property
def _rpc(self) -> filesystem_connect.FilesystemClient:
rpc = getattr(self._thread_local, "rpc", None)
if rpc is None:
rpc = self._create_rpc()
self._thread_local.rpc = rpc
return rpc
@overload
def read(
self,
path: str,
format: Literal["text"] = "text",
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
) -> str:
"""
Read file content as a `str`.
:param path: Path to the file
:param user: Run the operation as this user
:param format: Format of the file content—`text` by default
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:return: File content as a `str`
"""
...
@overload
def read(
self,
path: str,
format: Literal["bytes"],
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
) -> bytearray:
"""
Read file content as a `bytearray`.
:param path: Path to the file
:param user: Run the operation as this user
:param format: Format of the file content—`bytes`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:return: File content as a `bytearray`
"""
...
@overload
def read(
self,
path: str,
format: Literal["stream"],
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
stream_idle_timeout: Optional[float] = None,
) -> FileStreamReader:
"""
Read file content as a `FileStreamReader` (an `Iterator[bytes]`).
The request timeout bounds only the initial handshake—the returned
iterator is not killed by it while being consumed. A stalled stream is
reclaimed by `stream_idle_timeout` (raising `httpx.ReadTimeout`). The
reader releases its connection once fully consumed; if you don't read it
to the end, use it as a context manager or call `close()` for
deterministic cleanup.
:param path: Path to the file
:param user: Run the operation as this user
:param format: Format of the file content—`stream`
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the request
:param stream_idle_timeout: Idle timeout in **seconds** for the streamed
body—abort if no chunk arrives within this window. Resets on every
chunk, so it bounds a stalled stream without limiting total transfer
time. Defaults to the request timeout; pass `0` to disable.
:return: File content as a `FileStreamReader`
"""
...
def read(
self,
path: str,
format: Literal["text", "bytes", "stream"] = "text",
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
stream_idle_timeout: Optional[float] = None,
):
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
username = default_username
params = {"path": path}
if username:
params["username"] = username
headers = {}
if gzip:
headers["Accept-Encoding"] = "gzip"
timeout = self._connection_config.get_request_timeout(request_timeout)
if format == "stream":
# Stream the response body instead of buffering it in memory.
request = self._envd_api.build_request(
"GET",
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=timeout,
)
try:
r = self._envd_api.send(request, stream=True)
except httpx.RemoteProtocolError as e:
raise handle_envd_api_transport_exception_with_health(e, self._envd_api)
err = _handle_filesystem_envd_api_exception(r)
if err:
r.close()
raise err
# The request timeout bounds only the initial handshake; httpx's
# per-chunk `read` timeout becomes the idle-read timeout for the body
# (defaults to the request timeout). The timeout dict is shared by
# reference with the transport and read again when iteration starts.
idle_timeout = (
timeout if stream_idle_timeout is None else stream_idle_timeout
)
request.extensions.get("timeout", {})["read"] = idle_timeout or None
return FileStreamReader(r)
try:
r = self._envd_api.get(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=timeout,
)
except httpx.RemoteProtocolError as e:
raise handle_envd_api_transport_exception_with_health(e, self._envd_api)
err = _handle_filesystem_envd_api_exception(r)
if err:
raise err
if format == "text":
return r.text
elif format == "bytes":
return bytearray(r.content)
def write(
self,
path: str,
data: Union[str, bytes, IO],
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
use_octet_stream: Optional[bool] = None,
metadata: Optional[Dict[str, str]] = None,
) -> WriteInfo:
"""
Write content to a file on the path.
Writing to a file that doesn't exist creates the file.
Writing to a file that already exists overwrites the file.
Writing to a file at path that doesn't exist creates the necessary directories.
:param path: Path to the file
:param data: Data to write to the file, can be a `str`, `bytes`, or `IO`. File-like objects are streamed in chunks instead of being buffered in memory.
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`.
:param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when `data` is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`.
:param metadata: User-defined metadata to persist on the uploaded file as extended attributes. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later.
:return: Information about the written file
"""
result = self.write_files(
[WriteEntry(path=path, data=data)],
user=user,
request_timeout=request_timeout,
gzip=gzip,
use_octet_stream=use_octet_stream,
metadata=metadata,
)
if len(result) != 1:
raise SandboxException("Received unexpected response from write operation")
return result[0]
def write_files(
self,
files: List[WriteEntry],
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
gzip: bool = False,
use_octet_stream: Optional[bool] = None,
metadata: Optional[Dict[str, str]] = None,
) -> List[WriteInfo]:
"""
Writes multiple files.
Writes a list of files to the filesystem.
When writing to a file that doesn't exist, the file will get created.
When writing to a file that already exists, the file will get overwritten.
When writing to a file at path that doesn't exist, the necessary directories will be created.
:param files: list of files to write as `WriteEntry` objects, each containing `path` and `data`
:param user: Run the operation as this user
:param request_timeout: Timeout for the request
:param gzip: Use gzip compression for the upload. Implies the `application/octet-stream` upload. Requires envd 0.5.7 or later — when not supported, the upload falls back to uncompressed `multipart/form-data`.
:param use_octet_stream: Upload using `application/octet-stream` instead of `multipart/form-data`. Defaults to `None`, which uses octet-stream when any entry is a file-like object (so streamed uploads aren't buffered) and `multipart/form-data` otherwise. Requires envd 0.5.7 or later — when not supported, the upload falls back to `multipart/form-data`.
:param metadata: User-defined metadata to persist on each uploaded file as extended attributes; the same map is applied to every file. Keys are lowercased by the sandbox; invalid keys or values raise an `InvalidArgumentException`. Requires envd 0.6.2 or later.
:return: Information about the written files
"""
username = user
if username is None and self._envd_version < ENVD_DEFAULT_USER:
username = default_username
if len(files) == 0:
return []
validate_metadata(metadata)
if metadata and self._envd_version < ENVD_FILE_METADATA:
raise TemplateException("File metadata requires envd 0.6.2 or later.")
# A file-like entry is streamed; str/bytes are sent from memory.
has_streamable_data = any(
not isinstance(file["data"], (str, bytes)) for file in files
)
if use_octet_stream is None:
# Streaming an upload only happens on the octet-stream path; the
# multipart path buffers file-like data. Default to octet-stream
# when any entry is a file-like object so a streamed upload isn't
# silently buffered.
use_octet_stream = has_streamable_data
supports_octet_stream = self._envd_version >= ENVD_OCTET_STREAM_UPLOAD
# Gzip compression only works with the octet-stream upload (the
# Content-Encoding header applies to the whole request body), so
# requesting gzip implies it when envd supports it.
use_octet_stream = (use_octet_stream or gzip) and supports_octet_stream
# Each chunk send is bounded by the request timeout (httpx applies it
# per write); a stalled upload the per-write timeout can't observe is
# bounded server-side (envd's per-read idle timeout, envd >= 0.6.7).
upload_timeout = self._connection_config.get_request_timeout(request_timeout)
# Metadata is sent as request-scoped X-Metadata-* headers, so the same
# metadata is applied to every file in a multi-file upload.
extra_headers = metadata_to_headers(metadata)
results: List[WriteInfo] = []
if use_octet_stream:
for file in files:
file_path, file_data = file["path"], file["data"]
params = {"path": file_path}
if username:
params["username"] = username
headers = {"Content-Type": "application/octet-stream", **extra_headers}
if gzip:
headers["Content-Encoding"] = "gzip"
try:
r = self._envd_api.post(
ENVD_API_FILES_ROUTE,
content=to_upload_body(file_data, gzip),
headers=headers,
params=params,
timeout=upload_timeout,
)
except httpx.RemoteProtocolError as e:
raise handle_envd_api_transport_exception_with_health(
e, self._envd_api
)
err = _handle_filesystem_envd_api_exception(r)
if err:
raise err
write_result = r.json()
if not isinstance(write_result, list) or len(write_result) == 0:
raise SandboxException(
"Expected to receive information about written file"
)
results.extend([WriteInfo.from_dict(f) for f in write_result])
else:
params = {}
if username:
params["username"] = username
if len(files) == 1:
params["path"] = files[0]["path"]
httpx_files = [_to_httpx_file(file["path"], file["data"]) for file in files]
if len(httpx_files) == 0:
return []
try:
r = self._envd_api.post(
ENVD_API_FILES_ROUTE,
files=httpx_files,
params=params,
headers=extra_headers,
timeout=upload_timeout,
)
except httpx.RemoteProtocolError as e:
raise handle_envd_api_transport_exception_with_health(e, self._envd_api)
err = _handle_filesystem_envd_api_exception(r)
if err:
raise err
write_result = r.json()
if not isinstance(write_result, list) or len(write_result) == 0:
raise SandboxException(
"Expected to receive information about written file"
)
results.extend([WriteInfo.from_dict(f) for f in write_result])
return results
def list(
self,
path: str,
depth: Optional[int] = 1,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> List[EntryInfo]:
"""
List entries in a directory.
:param path: Path to the directory
:param depth: Depth of the directory to list
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:return: List of entries in the directory
"""
if depth is not None and depth < 1:
raise InvalidArgumentException("depth should be at least 1")
try:
res = self._rpc.list_dir(
filesystem_pb2.ListDirRequest(path=path, depth=depth),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
entries: List[EntryInfo] = []
for entry in res.entries:
# Skip entries with an unknown file type.
if map_file_type(entry.type):
entries.append(map_entry_info(entry))
return entries
except Exception as e:
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def exists(
self,
path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> bool:
"""
Check if a file or a directory exists.
:param path: Path to a file or a directory
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:return: `True` if the file or directory exists, `False` otherwise
"""
try:
self._rpc.stat(
filesystem_pb2.StatRequest(path=path),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
return True
except Exception as e:
if isinstance(e, e2b_connect.ConnectException):
if e.status == e2b_connect.Code.not_found:
return False
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def get_info(
self,
path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> EntryInfo:
"""
Get information about a file or directory.
:param path: Path to a file or a directory
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:return: Information about the file or directory like name, type, and path
"""
try:
r = self._rpc.stat(
filesystem_pb2.StatRequest(path=path),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
return map_entry_info(r.entry)
except Exception as e:
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def remove(
self,
path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> None:
"""
Remove a file or a directory.
:param path: Path to a file or a directory
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
"""
try:
self._rpc.remove(
filesystem_pb2.RemoveRequest(path=path),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
except Exception as e:
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def rename(
self,
old_path: str,
new_path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> EntryInfo:
"""
Rename a file or directory.
:param old_path: Path to the file or directory to rename
:param new_path: New path to the file or directory
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:return: Information about the renamed file or directory
"""
try:
r = self._rpc.move(
filesystem_pb2.MoveRequest(
source=old_path,
destination=new_path,
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
return map_entry_info(r.entry)
except Exception as e:
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def make_dir(
self,
path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
) -> bool:
"""
Create a new directory and all directories along the way if needed on the specified path.
:param path: Path to a new directory. For example '/dirA/dirB' when creating 'dirB'.
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:return: `True` if the directory was created, `False` if the directory already exists
"""
try:
self._rpc.make_dir(
filesystem_pb2.MakeDirRequest(path=path),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, user),
)
return True
except Exception as e:
if isinstance(e, e2b_connect.ConnectException):
if e.status == e2b_connect.Code.already_exists:
return False
raise _handle_filesystem_rpc_exception(e, self._envd_api)
def watch_dir(
self,
path: str,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
recursive: bool = False,
include_entry: bool = False,
allow_network_mounts: bool = False,
) -> WatchHandle:
"""
Watch directory for filesystem events.
:param path: Path to a directory to watch
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:param recursive: Watch directory recursively
:param include_entry: Include the `EntryInfo` of the affected entry in each event, when available. Requires envd 0.6.3 or later
:param allow_network_mounts: Allow watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE), which are rejected by default. Events on network mounts may be unreliable or not delivered at all. Requires envd 0.6.4 or later
:return: `WatchHandle` object for stopping watching directory
"""
if recursive and self._envd_version < ENVD_VERSION_RECURSIVE_WATCH:
raise TemplateException(
"You need to update the template to use recursive watching."
)
if include_entry and self._envd_version < ENVD_VERSION_FS_EVENT_ENTRY_INFO:
raise TemplateException(
"You need to update the template to include entry info in watch events."
)
if (
allow_network_mounts
and self._envd_version < ENVD_VERSION_WATCH_NETWORK_MOUNTS
):
raise TemplateException(
"You need to update the template to watch directories on network mounts."
)
try:
r = self._rpc.create_watcher(
filesystem_pb2.CreateWatcherRequest(
path=path,
recursive=recursive,
include_entry=include_entry,
allow_network_mounts=allow_network_mounts,
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers={
**authentication_header(self._envd_version, user),
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
)
except Exception as e:
raise _handle_filesystem_rpc_exception(e, self._envd_api)
return WatchHandle(
lambda: self._rpc,
r.watcher_id,
self._connection_config,
self._envd_version,
user,
lambda: check_sandbox_health(self._envd_api),
)
@@ -0,0 +1,102 @@
from typing import Callable, List, Optional
from packaging.version import Version
from e2b import SandboxException
from e2b.connection_config import ConnectionConfig, Username
from e2b.envd.filesystem import filesystem_connect
from e2b.envd.filesystem.filesystem_pb2 import (
GetWatcherEventsRequest,
RemoveWatcherRequest,
)
from e2b.envd.rpc import authentication_header, handle_rpc_exception_with_health
from e2b.sandbox.filesystem.filesystem import map_entry_info
from e2b.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type
class WatchHandle:
"""
Handle for watching filesystem events.
It is used to get the latest events that have occurred in the watched directory.
Use `.stop()` to stop watching the directory.
"""
def __init__(
self,
get_rpc: Callable[[], filesystem_connect.FilesystemClient],
watcher_id: str,
connection_config: ConnectionConfig,
envd_version: Version,
user: Optional[Username] = None,
check_health: Optional[Callable[[], Optional[bool]]] = None,
):
self._get_rpc = get_rpc
self._watcher_id = watcher_id
self._connection_config = connection_config
self._envd_version = envd_version
self._user = user
self._check_health = check_health
self._closed = False
def stop(self, request_timeout: Optional[float] = None):
"""
Stop watching the directory.
After you stop the watcher you won't be able to get the events anymore.
:param request_timeout: Timeout for the request in **seconds**
"""
try:
self._get_rpc().remove_watcher(
RemoveWatcherRequest(watcher_id=self._watcher_id),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, self._user),
)
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
self._closed = True
def get_new_events(
self, request_timeout: Optional[float] = None
) -> List[FilesystemEvent]:
"""
Get the latest events that have occurred in the watched directory since the last call, or from the beginning of the watching, up until now.
:param request_timeout: Timeout for the request in **seconds**
:return: List of filesystem events
"""
if self._closed:
raise SandboxException("The watcher is already stopped")
try:
r = self._get_rpc().get_watcher_events(
GetWatcherEventsRequest(watcher_id=self._watcher_id),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers=authentication_header(self._envd_version, self._user),
)
except Exception as e:
raise handle_rpc_exception_with_health(e, self._check_health)
events = []
for event in r.events:
event_type = map_event_type(event.type)
if event_type:
events.append(
FilesystemEvent(
name=event.name,
type=event_type,
entry=(
map_entry_info(event.entry)
if event.HasField("entry")
else None
),
)
)
return events
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,975 @@
import datetime
import json
import logging
import shlex
import uuid
from typing import Dict, List, Optional, Union, overload
import httpx
from packaging.version import Version
from typing_extensions import Self, Unpack
from e2b.api.client.types import Unset
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
from e2b.exceptions import (
TemplateException,
format_request_timeout_error,
)
from e2b.sandbox.main import SandboxOpts
from e2b.sandbox.sandbox_api import (
McpServer,
SandboxLifecycle,
SandboxMetrics,
SandboxNetworkOpts,
SandboxNetworkUpdate,
SnapshotInfo,
)
from e2b.sandbox.utils import class_method_variant
from e2b.sandbox_sync.commands.command import Commands
from e2b.sandbox_sync.commands.pty import Pty
from e2b.sandbox_sync.filesystem.filesystem import Filesystem
from e2b.sandbox_sync.git import Git
from e2b.sandbox_sync.sandbox_api import SandboxApi, SandboxInfo
from e2b.sandbox_sync.paginator import SnapshotPaginator
from e2b.api.client.models import SandboxVolumeMount as SandboxVolumeMountAPI
from e2b.volume.volume_sync import Volume
logger = logging.getLogger(__name__)
SandboxVolumeMount = Dict[str, Union[Volume, str]]
class Sandbox(SandboxApi):
"""
E2B cloud sandbox is a secure and isolated cloud environment.
The sandbox allows you to:
- Access Linux OS
- Create, list, and delete files and directories
- Run commands
- Run isolated code
- Access the internet
Check docs [here](https://e2b.dev/docs).
Use the `Sandbox.create()` to create a new sandbox.
Example:
```python
from e2b import Sandbox
sandbox = Sandbox.create()
```
"""
@property
def files(self) -> Filesystem:
"""
Module for interacting with the sandbox filesystem.
"""
return self._filesystem
@property
def commands(self) -> Commands:
"""
Module for running commands in the sandbox.
"""
return self._commands
@property
def pty(self) -> Pty:
"""
Module for interacting with the sandbox pseudo-terminal.
"""
return self._pty
@property
def git(self) -> Git:
"""
Module for running git operations in the sandbox.
"""
return self._git
def __init__(self, **opts: Unpack[SandboxOpts]):
"""
:deprecated: This constructor is deprecated
Use `Sandbox.create()` to create a new sandbox instead.
"""
super().__init__(**opts)
self._filesystem = Filesystem(
self.envd_api_url,
self._envd_version,
self.connection_config,
)
self._commands = Commands(
self.envd_api_url,
self.connection_config,
self._envd_version,
)
self._pty = Pty(
self.envd_api_url,
self.connection_config,
self._envd_version,
)
self._git = Git(self._commands)
@property
def _envd_api(self) -> httpx.Client:
return self._filesystem._envd_api
def is_running(self, request_timeout: Optional[float] = None) -> bool:
"""
Check if the sandbox is running.
:param request_timeout: Timeout for the request in **seconds**
:return: `True` if the sandbox is running, `False` otherwise
Example
```python
sandbox = Sandbox.create()
sandbox.is_running() # Returns True
sandbox.kill()
sandbox.is_running() # Returns False
```
"""
try:
r = self._envd_api.get(
ENVD_API_HEALTH_ROUTE,
timeout=self.connection_config.get_request_timeout(request_timeout),
)
if r.status_code == 502:
return False
err = handle_envd_api_exception(r)
if err:
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
return True
@classmethod
def create(
cls,
template: Optional[str] = None,
timeout: Optional[int] = None,
metadata: Optional[Dict[str, str]] = None,
envs: Optional[Dict[str, str]] = None,
secure: bool = True,
allow_internet_access: bool = True,
mcp: Optional[McpServer] = None,
network: Optional[SandboxNetworkOpts] = None,
lifecycle: Optional[SandboxLifecycle] = None,
volume_mounts: Optional[SandboxVolumeMount] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
"""
Create a new sandbox.
By default, the sandbox is created from the default `base` sandbox template.
:param template: Sandbox template name or ID
:param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
:param metadata: Custom metadata for the sandbox
:param envs: Custom environment variables for the sandbox
:param secure: Envd is secured with access token and cannot be used without it, defaults to `True`.
:param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
:param mcp: MCP server to enable in the sandbox
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``.
:param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``, or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` (default ``True``) set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}``
:param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names
:param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted.
:return: A Sandbox instance for the new sandbox
Use this method instead of using the constructor to create a new sandbox.
"""
if not template and mcp is not None:
template = cls.default_mcp_template
elif not template:
template = cls.default_template
transformed_mounts = None
if volume_mounts:
transformed_mounts = [
SandboxVolumeMountAPI(
name=vol.name if isinstance(vol, Volume) else vol,
path=path,
)
for path, vol in volume_mounts.items()
]
sandbox = cls._create(
template=template,
timeout=timeout,
metadata=metadata,
envs=envs,
secure=secure,
allow_internet_access=allow_internet_access,
mcp=mcp,
network=network,
lifecycle=lifecycle,
volume_mounts=transformed_mounts,
logger=logger,
**opts,
)
if mcp is not None:
token = str(uuid.uuid4())
sandbox._mcp_token = token
res = sandbox.commands.run(
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
user="root",
envs={"GATEWAY_ACCESS_TOKEN": token},
)
if res.exit_code != 0:
raise Exception(f"Failed to start MCP gateway: {res.stderr}")
return sandbox
@overload
def connect(
self,
timeout: Optional[int] = None,
**opts: Unpack[ApiParams],
) -> Self:
"""
Connect to a sandbox. If the sandbox is paused, it will be automatically resumed.
Sandbox must be either running or be paused.
With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc).
:param timeout: Timeout for the sandbox in **seconds**
For running sandboxes, the timeout will update only if the new timeout is longer than the existing one.
:return: A running sandbox instance
@example
```python
sandbox = Sandbox.create()
sandbox.pause()
# Another code block
same_sandbox = sandbox.connect()
:return: A running sandbox instance
"""
...
@overload
@staticmethod
def connect(
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> "Sandbox":
"""
Connect to a sandbox. If the sandbox is paused, it will be automatically resumed.
Sandbox must be either running or be paused.
With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc).
:param sandbox_id: Sandbox ID
:param timeout: Timeout for the sandbox in **seconds**.
For running sandboxes, the timeout will update only if the new timeout is longer than the existing one.
:param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted.
:return: A running sandbox instance
@example
```python
sandbox = Sandbox.create()
Sandbox.pause(sandbox.sandbox_id)
# Another code block
same_sandbox = Sandbox.connect(sandbox.sandbox_id)
```
"""
...
@class_method_variant("_cls_connect_sandbox")
def connect(
self,
timeout: Optional[int] = None,
**opts: Unpack[ApiParams],
) -> Self:
"""
Connect to a sandbox. If the sandbox is paused, it will be automatically resumed.
Sandbox must be either running or be paused.
With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc).
:param timeout: Timeout for the sandbox in **seconds**.
For running sandboxes, the timeout will update only if the new timeout is longer than the existing one.
:return: A running sandbox instance
@example
```python
sandbox = Sandbox.create()
sandbox.pause()
# Another code block
same_sandbox = sandbox.connect()
```
"""
if self.connection_config.debug:
# Skip connecting to the sandbox in debug mode
return self
SandboxApi._cls_connect(
sandbox_id=self.sandbox_id,
timeout=timeout,
**self.connection_config.get_api_params(**opts),
)
return self
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.kill()
@overload
def kill(
self,
**opts: Unpack[ApiParams],
) -> bool:
"""
Kill the sandbox.
:return: `True` if the sandbox was killed, `False` if the sandbox was not found
"""
...
@overload
@staticmethod
def kill(
sandbox_id: str,
**opts: Unpack[ApiParams],
) -> bool:
"""
Kill the sandbox specified by sandbox ID.
:param sandbox_id: Sandbox ID
:return: `True` if the sandbox was killed, `False` if the sandbox was not found
"""
...
@class_method_variant("_cls_kill")
def kill(
self,
**opts: Unpack[ApiParams],
) -> bool:
"""
Kill the sandbox specified by sandbox ID.
:return: `True` if the sandbox was killed, `False` if the sandbox was not found
"""
if self.connection_config.debug:
# Skip killing the sandbox in debug mode
return True
return SandboxApi._cls_kill(
sandbox_id=self.sandbox_id,
**self.connection_config.get_api_params(**opts),
)
@overload
def set_timeout(
self,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
"""
Set the timeout of the sandbox.
This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`.
The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
:param timeout: Timeout for the sandbox in **seconds**
"""
...
@overload
@staticmethod
def set_timeout(
sandbox_id: str,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
"""
Set the timeout of the sandbox specified by sandbox ID.
This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`.
The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
:param sandbox_id: Sandbox ID
:param timeout: Timeout for the sandbox in **seconds**
"""
...
@class_method_variant("_cls_set_timeout")
def set_timeout(
self,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
"""
Set the timeout of the sandbox.
This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.set_timeout`.
The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
:param timeout: Timeout for the sandbox in **seconds**
"""
SandboxApi._cls_set_timeout(
sandbox_id=self.sandbox_id,
timeout=timeout,
**self.connection_config.get_api_params(**opts),
)
@overload
def update_network(
self,
network: SandboxNetworkUpdate,
**opts: Unpack[ApiParams],
) -> None:
"""
Update the network configuration of the sandbox.
Replaces the current egress configuration atomically — fields that are
omitted are cleared on the server.
:param network: New network configuration.
"""
...
@overload
@staticmethod
def update_network(
sandbox_id: str,
network: SandboxNetworkUpdate,
**opts: Unpack[ApiParams],
) -> None:
"""
Update the network configuration of the sandbox specified by sandbox ID.
Replaces the current egress configuration atomically — fields that are
omitted are cleared on the server.
:param sandbox_id: Sandbox ID.
:param network: New network configuration.
"""
...
@class_method_variant("_cls_update_network")
def update_network(
self,
network: SandboxNetworkUpdate,
**opts: Unpack[ApiParams],
) -> None:
"""
Update the network configuration of the sandbox.
Replaces the current egress configuration atomically — fields that are
omitted are cleared on the server.
:param network: New network configuration.
"""
SandboxApi._cls_update_network(
sandbox_id=self.sandbox_id,
network=network,
**self.connection_config.get_api_params(**opts),
)
@overload
def get_info(
self,
**opts: Unpack[ApiParams],
) -> SandboxInfo:
"""
Get sandbox information like sandbox ID, template, metadata, started at/end at date.
:return: Sandbox info
"""
...
@overload
@staticmethod
def get_info(
sandbox_id: str,
**opts: Unpack[ApiParams],
) -> SandboxInfo:
"""
Get sandbox information like sandbox ID, template, metadata, started at/end at date.
:param sandbox_id: Sandbox ID
:return: Sandbox info
"""
...
@class_method_variant("_cls_get_info")
def get_info(
self,
**opts: Unpack[ApiParams],
) -> SandboxInfo:
"""
Get sandbox information like sandbox ID, template, metadata, started at/end at date.
:return: Sandbox info
"""
return SandboxApi._cls_get_info(
sandbox_id=self.sandbox_id,
**self.connection_config.get_api_params(**opts),
)
@overload
def get_metrics(
self,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
**opts: Unpack[ApiParams],
) -> List[SandboxMetrics]:
"""
Get the metrics of the current sandbox.
:param start: Start time for the metrics, defaults to the start of the sandbox
:param end: End time for the metrics, defaults to the current time
:return: List of sandbox metrics containing CPU, memory and disk usage information
"""
...
@overload
@staticmethod
def get_metrics(
sandbox_id: str,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
**opts: Unpack[ApiParams],
) -> List[SandboxMetrics]:
"""
Get the metrics of the sandbox specified by sandbox ID.
:param sandbox_id: Sandbox ID
:param start: Start time for the metrics, defaults to the start of the sandbox
:param end: End time for the metrics, defaults to the current time
:return: List of sandbox metrics containing CPU, memory and disk usage information
"""
...
@class_method_variant("_cls_get_metrics")
def get_metrics(
self,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
**opts: Unpack[ApiParams],
) -> List[SandboxMetrics]:
"""
Get the metrics of the current sandbox.
:param start: Start time for the metrics, defaults to the start of the sandbox
:param end: End time for the metrics, defaults to the current time
:return: List of sandbox metrics containing CPU, memory and disk usage information
"""
if self.connection_config.debug:
# Skip getting the metrics in debug mode
return []
if self._envd_version < Version("0.1.5"):
raise TemplateException(
"You need to update the template to use the new SDK."
)
if self._envd_version < Version("0.2.4"):
logger.warning(
"Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics."
)
return SandboxApi._cls_get_metrics(
sandbox_id=self.sandbox_id,
start=start,
end=end,
**self.connection_config.get_api_params(**opts),
)
@overload
def pause(
self,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool:
"""
Pause the sandbox.
:param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`.
:return: `True` if the sandbox got paused, `False` if the sandbox was already paused
"""
...
@overload
@staticmethod
def pause(
sandbox_id: str,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool:
"""
Pause the sandbox specified by sandbox ID.
:param sandbox_id: Sandbox ID
:param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. Defaults to `True`.
:return: `True` if the sandbox got paused, `False` if the sandbox was already paused
"""
...
@class_method_variant("_cls_pause")
def pause(
self,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool:
"""
Pause the sandbox.
:param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `True` (full memory snapshot).
:return: `True` if the sandbox got paused, `False` if the sandbox was already paused
"""
return SandboxApi._cls_pause(
sandbox_id=self.sandbox_id,
keep_memory=keep_memory,
**self.connection_config.get_api_params(**opts),
)
@overload
def beta_pause(
self,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool: ...
@overload
@staticmethod
def beta_pause(
sandbox_id: str,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool: ...
@class_method_variant("_cls_pause")
def beta_pause(
self,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool:
"""
:deprecated: Use `pause()` instead.
:return: `True` if the sandbox got paused, `False` if the sandbox was already paused
"""
return self.pause(keep_memory=keep_memory, **opts)
@overload
def create_snapshot(
self,
name: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotInfo:
"""
Create a snapshot of the sandbox's current state.
The sandbox will be paused while the snapshot is being created.
The snapshot can be used to create new sandboxes with the same filesystem and state.
Snapshots are persistent and survive sandbox deletion.
Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot.
:param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one.
:return: Snapshot information including the snapshot ID and names
"""
...
@overload
@staticmethod
def create_snapshot(
sandbox_id: str,
name: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotInfo:
"""
Create a snapshot from the sandbox specified by sandbox ID.
The sandbox will be paused while the snapshot is being created.
:param sandbox_id: Sandbox ID
:param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one.
:return: Snapshot information including the snapshot ID and names
"""
...
@class_method_variant("_cls_create_snapshot")
def create_snapshot(
self,
name: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotInfo:
"""
Create a snapshot of the sandbox's current state.
The sandbox will be paused while the snapshot is being created.
The snapshot can be used to create new sandboxes with the same filesystem and state.
Snapshots are persistent and survive sandbox deletion.
Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot.
:param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one.
:return: Snapshot information including the snapshot ID and names
"""
return SandboxApi._cls_create_snapshot(
sandbox_id=self.sandbox_id,
name=name,
**self.connection_config.get_api_params(**opts),
)
@overload
def list_snapshots(
self,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotPaginator:
"""
List snapshots for this sandbox.
:param limit: Maximum number of snapshots to return per page
:param next_token: Token for pagination
:return: Paginator for listing snapshots
"""
...
@overload
@staticmethod
def list_snapshots(
sandbox_id: Optional[str] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotPaginator:
"""
List all snapshots.
:param sandbox_id: Filter snapshots by source sandbox ID
:param limit: Maximum number of snapshots to return per page
:param next_token: Token for pagination
:return: Paginator for listing snapshots
"""
...
@class_method_variant("_cls_list_snapshots")
def list_snapshots(
self,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotPaginator:
"""
List snapshots for this sandbox.
:param limit: Maximum number of snapshots to return per page
:param next_token: Token for pagination
:return: Paginator for listing snapshots
"""
return SnapshotPaginator(
sandbox_id=self.sandbox_id,
limit=limit,
next_token=next_token,
**self.connection_config.get_api_params(**opts),
)
@staticmethod
def _cls_list_snapshots(
sandbox_id: Optional[str] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotPaginator:
return SnapshotPaginator(
sandbox_id=sandbox_id,
limit=limit,
next_token=next_token,
**opts,
)
@staticmethod
def delete_snapshot(
snapshot_id: str,
**opts: Unpack[ApiParams],
) -> bool:
"""
Delete a snapshot.
:param snapshot_id: Snapshot ID
:return: `True` if the snapshot was deleted, `False` if it was not found
"""
return SandboxApi._cls_delete_snapshot(
snapshot_id=snapshot_id,
**opts,
)
def get_mcp_token(self) -> Optional[str]:
"""
Get the MCP token for the sandbox.
:return: MCP token for the sandbox, or None if MCP is not enabled.
"""
if not self._mcp_token:
self._mcp_token = self.files.read("/etc/mcp-gateway/.token", user="root")
return self._mcp_token
@classmethod
def _cls_connect_sandbox(
cls,
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
debug = ConnectionConfig(**opts).debug
if debug:
sandbox_domain = None
envd_version = ENVD_DEBUG_FALLBACK
envd_access_token = None
traffic_access_token = None
else:
sandbox = SandboxApi._cls_connect(
sandbox_id=sandbox_id,
timeout=timeout,
logger=logger,
**opts,
)
sandbox_id = sandbox.sandbox_id
sandbox_domain = sandbox.sandbox_domain
envd_version = Version(sandbox.envd_version)
envd_access_token = sandbox.envd_access_token
traffic_access_token = sandbox.traffic_access_token
sandbox_headers = {
"E2b-Sandbox-Id": sandbox_id,
"E2b-Sandbox-Port": str(ConnectionConfig.envd_port),
}
if envd_access_token is not None and not isinstance(envd_access_token, Unset):
sandbox_headers["X-Access-Token"] = envd_access_token
connection_config = ConnectionConfig(
extra_sandbox_headers=sandbox_headers,
logger=logger,
**opts,
)
return cls(
sandbox_id=sandbox_id,
sandbox_domain=sandbox_domain,
envd_version=envd_version,
envd_access_token=envd_access_token,
traffic_access_token=traffic_access_token,
connection_config=connection_config,
)
@classmethod
def _create(
cls,
template: Optional[str],
timeout: Optional[int],
metadata: Optional[Dict[str, str]],
envs: Optional[Dict[str, str]],
secure: bool,
allow_internet_access: bool,
mcp: Optional[McpServer] = None,
network: Optional[SandboxNetworkOpts] = None,
lifecycle: Optional[SandboxLifecycle] = None,
volume_mounts: Optional[list] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
extra_sandbox_headers = {}
debug = ConnectionConfig(**opts).debug
if debug:
sandbox_id = "debug_sandbox_id"
sandbox_domain = None
envd_version = ENVD_DEBUG_FALLBACK
envd_access_token = None
traffic_access_token = None
else:
response = SandboxApi._create_sandbox(
template=template or cls.default_template,
timeout=timeout or cls.default_sandbox_timeout,
metadata=metadata,
env_vars=envs,
secure=secure,
allow_internet_access=allow_internet_access,
mcp=mcp,
network=network,
lifecycle=lifecycle,
volume_mounts=volume_mounts,
logger=logger,
**opts,
)
sandbox_id = response.sandbox_id
sandbox_domain = response.sandbox_domain
envd_version = Version(response.envd_version)
envd_access_token = response.envd_access_token
traffic_access_token = response.traffic_access_token
if envd_access_token is not None and not isinstance(
envd_access_token, Unset
):
extra_sandbox_headers["X-Access-Token"] = envd_access_token
extra_sandbox_headers["E2b-Sandbox-Id"] = sandbox_id
extra_sandbox_headers["E2b-Sandbox-Port"] = str(ConnectionConfig.envd_port)
connection_config = ConnectionConfig(
extra_sandbox_headers=extra_sandbox_headers,
logger=logger,
**opts,
)
return cls(
sandbox_id=sandbox_id,
sandbox_domain=sandbox_domain,
envd_version=envd_version,
envd_access_token=envd_access_token,
traffic_access_token=traffic_access_token,
connection_config=connection_config,
)
@@ -0,0 +1,140 @@
import urllib.parse
from typing import Optional, List
from typing_extensions import Unpack
from e2b.api import handle_api_exception
from e2b.api.client.api.sandboxes import get_v2_sandboxes
from e2b.api.client.api.snapshots import get_snapshots
from e2b.api.client.models.error import Error
from e2b.api.client.types import UNSET
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.exceptions import SandboxException
from e2b.sandbox.sandbox_api import (
SandboxPaginatorBase,
SandboxInfo,
SnapshotPaginatorBase,
SnapshotInfo,
)
from e2b.api.client_sync import get_api_client
class SandboxPaginator(SandboxPaginatorBase):
"""
Paginator for listing sandboxes.
Example:
```python
paginator = Sandbox.list()
while paginator.has_next:
sandboxes = paginator.next_items()
print(sandboxes)
```
"""
def next_items(self, **opts: Unpack[ApiParams]) -> List[SandboxInfo]:
"""
Returns the next page of sandboxes.
Call this method only if `has_next` is `True`, otherwise it will raise an exception.
:param opts: Per-call connection options (e.g. `api_key`, `domain`,
`headers`, `request_timeout`). When provided, this call uses these
options instead of the ones the paginator was constructed with.
:returns: List of sandboxes
"""
if not self.has_next:
raise Exception("No more items to fetch")
# Convert filters to the format expected by the API
metadata: Optional[str] = None
if self.query and self.query.metadata:
quoted_metadata = {
urllib.parse.quote(k): urllib.parse.quote(v)
for k, v in self.query.metadata.items()
}
metadata = urllib.parse.urlencode(quoted_metadata)
config = ConnectionConfig(**{**self._opts, **opts})
api_client = get_api_client(config)
res = get_v2_sandboxes.sync_detailed(
client=api_client,
metadata=metadata if metadata else UNSET,
state=self.query.state if self.query and self.query.state else UNSET,
limit=self.limit if self.limit else UNSET,
next_token=self._next_token if self._next_token else UNSET,
)
if res.status_code >= 300:
raise handle_api_exception(res)
self._update_pagination(res.headers)
if res.parsed is None:
return []
# Check if res.parsed is Error
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed]
class SnapshotPaginator(SnapshotPaginatorBase):
"""
Paginator for listing snapshots.
Example:
```python
paginator = Sandbox.list_snapshots()
while paginator.has_next:
snapshots = paginator.next_items()
print(snapshots)
```
"""
def next_items(self, **opts: Unpack[ApiParams]) -> List[SnapshotInfo]:
"""
Returns the next page of snapshots.
Call this method only if `has_next` is `True`, otherwise it will raise an exception.
:param opts: Per-call connection options (e.g. `api_key`, `domain`,
`headers`, `request_timeout`). When provided, this call uses these
options instead of the ones the paginator was constructed with.
:returns: List of snapshots
"""
if not self.has_next:
raise Exception("No more items to fetch")
config = ConnectionConfig(**{**self._opts, **opts})
api_client = get_api_client(config)
res = get_snapshots.sync_detailed(
client=api_client,
sandbox_id=self.sandbox_id if self.sandbox_id else UNSET,
limit=self.limit if self.limit else UNSET,
next_token=self._next_token if self._next_token else UNSET,
)
if res.status_code >= 300:
raise handle_api_exception(res)
self._update_pagination(res.headers)
if res.parsed is None:
return []
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
return [
SnapshotInfo(
snapshot_id=snapshot.snapshot_id,
names=list(snapshot.names) if snapshot.names else [],
)
for snapshot in res.parsed
]
@@ -0,0 +1,491 @@
import datetime
import logging
from typing import Any, Dict, List, Optional, cast
from packaging.version import Version
from typing_extensions import Unpack
from e2b.api import SandboxCreateResponse, handle_api_exception
from e2b.api.client.api.sandboxes import (
delete_sandboxes_sandbox_id,
get_sandboxes_sandbox_id,
get_sandboxes_sandbox_id_metrics,
post_sandboxes,
post_sandboxes_sandbox_id_connect,
post_sandboxes_sandbox_id_pause,
post_sandboxes_sandbox_id_snapshots,
post_sandboxes_sandbox_id_timeout,
put_sandboxes_sandbox_id_network,
)
from e2b.api.client.api.templates import delete_templates_template_id
from e2b.api.client.models import (
ConnectSandbox,
Error,
NewSandbox,
PostSandboxesSandboxIDSnapshotsBody,
PostSandboxesSandboxIDTimeoutBody,
SandboxAutoResumeConfig,
SandboxNetworkConfig,
SandboxPauseRequest,
SandboxVolumeMount as SandboxVolumeMountAPI,
)
from e2b.api.client.types import UNSET
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.exceptions import (
InvalidArgumentException,
SandboxException,
SandboxNotFoundException,
TemplateException,
)
from e2b.sandbox.main import SandboxBase
from e2b.sandbox.sandbox_api import (
build_network_update_body,
McpServer,
SandboxInfo,
SandboxLifecycle,
SandboxMetrics,
SandboxNetworkOpts,
SandboxNetworkUpdate,
SandboxQuery,
SnapshotInfo,
build_network_config,
)
from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client
class SandboxApi(SandboxBase):
@staticmethod
def list(
query: Optional[SandboxQuery] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SandboxPaginator:
"""
List sandboxes.
By default (no `query.state` set), returns sandboxes in both `running`
and `paused` states. To filter by state, pass `query=SandboxQuery(state=[...])`.
:param query: Filter the list of sandboxes by metadata or state, e.g. `SandboxQuery(metadata={"key": "value"})` or `SandboxQuery(state=[SandboxState.RUNNING])`
:param limit: Maximum number of sandboxes to return per page
:param next_token: Token for pagination
:return: A `SandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `paginator.next_items()` while `paginator.has_next` is True.
"""
return SandboxPaginator(
query=query,
limit=limit,
next_token=next_token,
**opts,
)
@classmethod
def _cls_get_info(
cls,
sandbox_id: str,
**opts: Unpack[ApiParams],
) -> SandboxInfo:
"""
Get the sandbox info.
:param sandbox_id: Sandbox ID
:return: Sandbox info
"""
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = get_sandboxes_sandbox_id.sync_detailed(
sandbox_id,
client=api_client,
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
if res.parsed is None:
raise Exception("Body of the request is None")
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
return SandboxInfo._from_sandbox_detail(res.parsed)
@classmethod
def _cls_kill(
cls,
sandbox_id: str,
**opts: Unpack[ApiParams],
) -> bool:
config = ConnectionConfig(**opts)
if config.debug:
# Skip killing the sandbox in debug mode
return True
api_client = get_api_client(config)
res = delete_sandboxes_sandbox_id.sync_detailed(
sandbox_id,
client=api_client,
)
if res.status_code == 404:
return False
if res.status_code >= 300:
raise handle_api_exception(res)
return True
@classmethod
def _cls_set_timeout(
cls,
sandbox_id: str,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
config = ConnectionConfig(**opts)
if config.debug:
# Skip setting timeout in debug mode
return
api_client = get_api_client(config)
res = post_sandboxes_sandbox_id_timeout.sync_detailed(
sandbox_id,
client=api_client,
body=PostSandboxesSandboxIDTimeoutBody(timeout=timeout),
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
@classmethod
def _cls_update_network(
cls,
sandbox_id: str,
network: SandboxNetworkUpdate,
**opts: Unpack[ApiParams],
) -> None:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = put_sandboxes_sandbox_id_network.sync_detailed(
sandbox_id,
client=api_client,
body=build_network_update_body(network),
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
@classmethod
def _create_sandbox(
cls,
template: str,
timeout: int,
allow_internet_access: bool,
metadata: Optional[Dict[str, str]],
env_vars: Optional[Dict[str, str]],
secure: bool,
mcp: Optional[McpServer] = None,
network: Optional[SandboxNetworkOpts] = None,
lifecycle: Optional[SandboxLifecycle] = None,
volume_mounts: Optional[List[SandboxVolumeMountAPI]] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> SandboxCreateResponse:
config = ConnectionConfig(logger=logger, **opts)
# on_timeout accepts a bare action or {"action", "keep_memory"}; normalize.
# Only the object form carries keep_memory; anything else (a bare action
# string, or an unexpected value from an untyped caller) passes through as
# the action, so a non-"pause" value resolves to kill instead of crashing.
on_timeout_raw = lifecycle.get("on_timeout", "kill") if lifecycle else "kill"
if isinstance(on_timeout_raw, dict):
on_timeout = on_timeout_raw.get("action", "kill")
keep_memory_provided = "keep_memory" in on_timeout_raw
keep_memory = on_timeout_raw.get("keep_memory")
else:
on_timeout = on_timeout_raw
keep_memory = None
keep_memory_provided = False
# keep_memory only governs a pause action. The discriminated union type
# forbids it on action="kill"; re-check at runtime for callers that
# bypass the type.
if keep_memory_provided and on_timeout != "pause":
raise InvalidArgumentException(
"keep_memory is only allowed when on_timeout action is 'pause'."
)
# A missing or explicit None keep_memory defaults to True (full memory),
# mirroring the JS SDK; sending null would wrongly read as filesystem-only.
if keep_memory is None:
keep_memory = True
auto_resume = lifecycle.get("auto_resume", False) if lifecycle else False
if auto_resume and on_timeout != "pause":
raise InvalidArgumentException(
"auto_resume can only be True when on_timeout action is 'pause'."
)
if not keep_memory and auto_resume:
raise InvalidArgumentException(
"auto_resume: True is not a valid value when keep_memory: False - "
"a filesystem-only snapshot cannot be auto-resumed by traffic and "
"must be resumed explicitly using Sandbox.connect()."
)
network_body = build_network_config(network)
body = NewSandbox(
template_id=template,
auto_pause=on_timeout == "pause",
auto_pause_memory=keep_memory if on_timeout == "pause" else UNSET,
auto_resume=SandboxAutoResumeConfig(enabled=auto_resume),
metadata=metadata or {},
timeout=timeout,
env_vars=env_vars or {},
mcp=cast(Any, mcp) or UNSET,
secure=secure,
allow_internet_access=allow_internet_access,
network=SandboxNetworkConfig(**network_body) if network_body else UNSET,
volume_mounts=volume_mounts if volume_mounts else UNSET,
)
api_client = get_api_client(config)
res = post_sandboxes.sync_detailed(
body=body,
client=api_client,
)
if res.status_code >= 300:
raise handle_api_exception(res)
if res.parsed is None:
raise Exception("Body of the request is None")
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
if Version(res.parsed.envd_version) < Version("0.1.0"):
SandboxApi._cls_kill(res.parsed.sandbox_id)
raise TemplateException(
"You need to update the template to use the new SDK."
)
domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None
envd_token = (
res.parsed.envd_access_token
if isinstance(res.parsed.envd_access_token, str)
else None
)
traffic_token = (
res.parsed.traffic_access_token
if isinstance(res.parsed.traffic_access_token, str)
else None
)
return SandboxCreateResponse(
sandbox_id=res.parsed.sandbox_id,
sandbox_domain=domain,
envd_version=res.parsed.envd_version,
envd_access_token=envd_token,
traffic_access_token=traffic_token,
)
@classmethod
def _cls_get_metrics(
cls,
sandbox_id: str,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
**opts: Unpack[ApiParams],
) -> List[SandboxMetrics]:
config = ConnectionConfig(**opts)
if config.debug:
# Skip getting the metrics in debug mode
return []
api_client = get_api_client(config)
res = get_sandboxes_sandbox_id_metrics.sync_detailed(
sandbox_id,
start=int(start.timestamp()) if start else UNSET,
end=int(end.timestamp()) if end else UNSET,
client=api_client,
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
if res.parsed is None:
return []
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
# Convert to typed SandboxMetrics objects
return [
SandboxMetrics(
cpu_count=metric.cpu_count,
cpu_used_pct=metric.cpu_used_pct,
disk_total=metric.disk_total,
disk_used=metric.disk_used,
mem_total=metric.mem_total,
mem_used=metric.mem_used,
mem_cache=metric.mem_cache,
timestamp=metric.timestamp,
)
for metric in res.parsed
]
@classmethod
def _cls_connect(
cls,
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> SandboxCreateResponse:
timeout = timeout or SandboxBase.default_sandbox_timeout
config = ConnectionConfig(logger=logger, **opts)
api_client = get_api_client(config)
res = post_sandboxes_sandbox_id_connect.sync_detailed(
sandbox_id,
client=api_client,
body=ConnectSandbox(timeout=timeout),
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Paused sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
if res.parsed is None:
raise Exception("Body of the request is None")
domain = res.parsed.domain if isinstance(res.parsed.domain, str) else None
envd_token = (
res.parsed.envd_access_token
if isinstance(res.parsed.envd_access_token, str)
else None
)
traffic_token = (
res.parsed.traffic_access_token
if isinstance(res.parsed.traffic_access_token, str)
else None
)
return SandboxCreateResponse(
sandbox_id=res.parsed.sandbox_id,
sandbox_domain=domain,
envd_version=res.parsed.envd_version,
envd_access_token=envd_token,
traffic_access_token=traffic_token,
)
@classmethod
def _cls_create_snapshot(
cls,
sandbox_id: str,
name: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> SnapshotInfo:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = post_sandboxes_sandbox_id_snapshots.sync_detailed(
sandbox_id,
client=api_client,
body=PostSandboxesSandboxIDSnapshotsBody(name=name if name else UNSET),
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code >= 300:
raise handle_api_exception(res)
if res.parsed is None:
raise Exception("Body of the request is None")
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
return SnapshotInfo(
snapshot_id=res.parsed.snapshot_id,
names=list(res.parsed.names) if res.parsed.names else [],
)
@classmethod
def _cls_delete_snapshot(
cls,
snapshot_id: str,
**opts: Unpack[ApiParams],
) -> bool:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = delete_templates_template_id.sync_detailed(
snapshot_id,
client=api_client,
)
if res.status_code == 404:
return False
if res.status_code >= 300:
raise handle_api_exception(res)
return True
@classmethod
def _cls_pause(
cls,
sandbox_id: str,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = post_sandboxes_sandbox_id_pause.sync_detailed(
sandbox_id,
client=api_client,
body=SandboxPauseRequest(memory=keep_memory),
)
if res.status_code == 404:
raise SandboxNotFoundException(f"Sandbox {sandbox_id} not found")
if res.status_code == 409:
# Sandbox is already paused
return False
if res.status_code >= 300:
raise handle_api_exception(res)
# Check if res.parse is Error
if isinstance(res.parsed, Error):
raise SandboxException(f"{res.parsed.message}: Request failed")
return True