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,396 @@
from typing import Dict, List, Literal, Optional, Union, overload
import e2b_connect
import httpcore
import httpx
from packaging.version import Version
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 acheck_sandbox_health
from e2b.envd.rpc import authentication_header, ahandle_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_async.commands.command_handle import AsyncCommandHandle, Stderr, Stdout
from e2b.sandbox_async.utils import OutputHandler
class Commands:
"""
Module for executing commands in the sandbox.
"""
def __init__(
self,
envd_api_url: str,
connection_config: ConnectionConfig,
pool: httpcore.AsyncConnectionPool,
envd_version: Version,
envd_api: httpx.AsyncClient,
) -> None:
self._connection_config = connection_config
self._envd_version = envd_version
self._check_health = lambda: acheck_sandbox_health(envd_api)
self._rpc = process_connect.ProcessClient(
envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)
async 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 = await self._rpc.alist(
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 await ahandle_rpc_exception_with_health(e, self._check_health)
async 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:
await self._rpc.asend_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 await ahandle_rpc_exception_with_health(e, self._check_health)
async def send_stdin(
self,
pid: int,
data: Union[str, bytes],
request_timeout: Optional[float] = None,
) -> 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:
await self._rpc.asend_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 await ahandle_rpc_exception_with_health(e, self._check_health)
async 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:
await self._rpc.aclose_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 await ahandle_rpc_exception_with_health(e, self._check_health)
@overload
async 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[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = 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
async def run(
self,
cmd: str,
background: Literal[True],
envs: Optional[Dict[str, str]] = None,
user: Optional[Username] = None,
cwd: Optional[str] = None,
on_stdout: Optional[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> AsyncCommandHandle:
"""
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 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: `AsyncCommandHandle` handle to interact with the running command
"""
...
async 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[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = 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 = await self._start(
cmd,
envs,
user,
cwd,
stdin,
timeout,
request_timeout,
on_stdout=on_stdout,
on_stderr=on_stderr,
)
return proc if background else await proc.wait()
async 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],
on_stdout: Optional[OutputHandler[Stdout]],
on_stderr: Optional[OutputHandler[Stderr]],
) -> AsyncCommandHandle:
events = self._rpc.astart(
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 = await events.__anext__()
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 AsyncCommandHandle(
pid=pid,
handle_kill=lambda: self.kill(pid),
events=events,
on_stdout=on_stdout,
on_stderr=on_stderr,
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:
await events.aclose()
except Exception:
pass
raise await ahandle_rpc_exception_with_health(e, self._check_health)
async def connect(
self,
pid: int,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
on_stdout: Optional[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = None,
) -> AsyncCommandHandle:
"""
Connects to a running command.
You can use `AsyncCommandHandle.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 request_timeout: Request timeout in **seconds**
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param on_stdout: Callback for command stdout output
:param on_stderr: Callback for command stderr output
:return: `AsyncCommandHandle` handle to interact with the running command
"""
events = self._rpc.aconnect(
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 = await events.__anext__()
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 AsyncCommandHandle(
pid=pid,
handle_kill=lambda: self.kill(pid),
events=events,
on_stdout=on_stdout,
on_stderr=on_stderr,
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:
await events.aclose()
except Exception:
pass
raise await ahandle_rpc_exception_with_health(e, self._check_health)
@@ -0,0 +1,298 @@
import asyncio
import codecs
import inspect
from typing import (
Optional,
Callable,
Any,
AsyncGenerator,
List,
Awaitable,
Union,
Tuple,
Coroutine,
)
from e2b.envd.rpc import ahandle_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,
)
from e2b.sandbox_async.utils import OutputHandler
class AsyncCommandHandle:
"""
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
@property
def stdout(self):
"""
Command stdout output.
"""
return "".join(self._stdout_chunks)
@property
def stderr(self):
"""
Command stderr output.
"""
return "".join(self._stderr_chunks)
@property
def error(self):
"""
Command execution error message.
"""
if self._result is None:
return None
return self._result.error
@property
def exit_code(self):
"""
Command execution exit code.
`0` if the command finished successfully.
It is `None` if the command is still running.
"""
if self._result is None:
return None
return self._result.exit_code
def __init__(
self,
pid: int,
handle_kill: Callable[[], Coroutine[Any, Any, bool]],
events: AsyncGenerator[
Union[process_pb2.StartResponse, process_pb2.ConnectResponse], Any
],
on_stdout: Optional[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = None,
on_pty: Optional[OutputHandler[PtyOutput]] = None,
handle_send_stdin: Optional[
Callable[[Union[str, bytes], Optional[float]], Coroutine[Any, Any, None]]
] = None,
handle_close_stdin: Optional[
Callable[[Optional[float]], Coroutine[Any, Any, None]]
] = None,
check_health: Optional[Callable[[], Awaitable[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._on_stdout = on_stdout
self._on_stderr = on_stderr
self._on_pty = on_pty
self._result: Optional[CommandResult] = None
self._iteration_exception: Optional[Exception] = None
self._wait = asyncio.create_task(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
async def _iterate_events(
self,
) -> AsyncGenerator[
Union[
Tuple[Stdout, None, None],
Tuple[None, Stderr, None],
Tuple[None, None, PtyOutput],
],
None,
]:
try:
async 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,
)
for f in flushed:
yield f
except Exception:
# 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 re-raise so the error is
# still surfaced by the consumer.
for flushed in self._flush_decoders():
yield flushed
raise
# 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:
for flushed in self._flush_decoders():
yield flushed
async def disconnect(self) -> None:
"""
Disconnects 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._wait.cancel()
await asyncio.wait([self._wait])
try:
await self._events.aclose()
except Exception:
pass
async def _handle_events(self):
try:
async for stdout, stderr, pty in self._iterate_events():
if stdout is not None and self._on_stdout:
cb = self._on_stdout(stdout)
if inspect.isawaitable(cb):
await cb
elif stderr is not None and self._on_stderr:
cb = self._on_stderr(stderr)
if inspect.isawaitable(cb):
await cb
elif pty is not None and self._on_pty:
cb = self._on_pty(pty)
if inspect.isawaitable(cb):
await cb
except StopAsyncIteration:
pass
except Exception as e:
self._iteration_exception = await ahandle_rpc_exception_with_health(
e, self._check_health
)
async def wait(self) -> CommandResult:
"""
Wait for the command to finish and return the result.
If the command exits with a non-zero exit code, it throws a `CommandExitException`.
:return: `CommandResult` result of command execution
"""
await self._wait
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
async def kill(self) -> bool:
"""
Kills the command.
It uses `SIGKILL` signal to kill the command
:return: `True` if the command was killed successfully, `False` if the command was not found
"""
result = await self._handle_kill()
return result
async 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."
)
await self._handle_send_stdin(data, request_timeout)
async 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."
)
await self._handle_close_stdin(request_timeout)
@@ -0,0 +1,257 @@
from typing import Dict, Optional
import e2b_connect
import httpcore
import httpx
from packaging.version import Version
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 acheck_sandbox_health
from e2b.envd.rpc import authentication_header, ahandle_rpc_exception_with_health
from e2b.sandbox.commands.command_handle import PtySize
from e2b.sandbox_async.commands.command_handle import (
AsyncCommandHandle,
OutputHandler,
PtyOutput,
)
class Pty:
"""
Module for interacting with PTYs (pseudo-terminals) in the sandbox.
"""
def __init__(
self,
envd_api_url: str,
connection_config: ConnectionConfig,
pool: httpcore.AsyncConnectionPool,
envd_version: Version,
envd_api: httpx.AsyncClient,
) -> None:
self._connection_config = connection_config
self._envd_version = envd_version
self._check_health = lambda: acheck_sandbox_health(envd_api)
self._rpc = process_connect.ProcessClient(
envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)
async 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:
await self._rpc.asend_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 await ahandle_rpc_exception_with_health(e, self._check_health)
async 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:
await self._rpc.asend_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 await ahandle_rpc_exception_with_health(e, self._check_health)
async def create(
self,
size: PtySize,
on_data: OutputHandler[PtyOutput],
user: Optional[Username] = None,
cwd: Optional[str] = None,
envs: Optional[Dict[str, str]] = None,
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> AsyncCommandHandle:
"""
Start a new PTY (pseudo-terminal).
:param size: Size of the PTY
:param on_data: Callback to handle PTY data
: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.astart(
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 = await events.__anext__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to start process: expected start event, got {start_event}"
)
return AsyncCommandHandle(
pid=start_event.event.start.pid,
handle_kill=lambda: self.kill(start_event.event.start.pid),
events=events,
on_pty=on_data,
check_health=self._check_health,
)
except Exception as e:
try:
await events.aclose()
except Exception:
pass
raise await ahandle_rpc_exception_with_health(e, self._check_health)
async def connect(
self,
pid: int,
on_data: OutputHandler[PtyOutput],
timeout: Optional[float] = 60,
request_timeout: Optional[float] = None,
) -> AsyncCommandHandle:
"""
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 on_data: Callback to handle PTY data
: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.aconnect(
process_pb2.ConnectRequest(
process=process_pb2.ProcessSelector(pid=pid),
),
timeout=timeout,
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
headers={
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
)
try:
start_event = await events.__anext__()
if not start_event.HasField("event"):
raise SandboxException(
f"Failed to connect to process: expected start event, got {start_event}"
)
return AsyncCommandHandle(
pid=start_event.event.start.pid,
handle_kill=lambda: self.kill(start_event.event.start.pid),
events=events,
on_pty=on_data,
check_health=self._check_health,
)
except Exception as e:
try:
await events.aclose()
except Exception:
pass
raise await ahandle_rpc_exception_with_health(e, self._check_health)
async 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**
"""
await self._rpc.aupdate(
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,720 @@
import asyncio
from typing import IO, Dict, List, Literal, Optional, Union, overload
import httpcore
import httpx
from packaging.version import Version
import e2b_connect as connect
from e2b.connection_config import (
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
ConnectionConfig,
Username,
default_username,
)
from e2b.envd.api import (
ENVD_API_FILES_ROUTE,
acheck_sandbox_health,
ahandle_envd_api_exception,
ahandle_envd_api_transport_exception_with_health,
)
from e2b.envd.filesystem import filesystem_connect, filesystem_pb2
from e2b.envd.rpc import authentication_header, ahandle_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 (
AsyncFileStreamReader,
EntryInfo,
WriteEntry,
WriteInfo,
_to_httpx_file,
map_entry_info,
map_file_type,
metadata_to_headers,
to_upload_body_async,
validate_metadata,
)
from e2b.sandbox.filesystem.watch_handle import FilesystemEvent
from e2b.sandbox_async.filesystem.watch_handle import AsyncWatchHandle
from e2b.sandbox_async.utils import OutputHandler
from e2b_connect.client import Code
_FILESYSTEM_RPC_ERROR_MAP = {
Code.not_found: FileNotFoundException,
}
_FILESYSTEM_HTTP_ERROR_MAP = {
404: FileNotFoundException,
}
async def _ahandle_filesystem_rpc_exception(
e: Exception, envd_api: httpx.AsyncClient
) -> Exception:
return await ahandle_rpc_exception_with_health(
e, lambda: acheck_sandbox_health(envd_api), _FILESYSTEM_RPC_ERROR_MAP
)
async def _ahandle_filesystem_envd_api_exception(r):
return await ahandle_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,
pool: httpcore.AsyncConnectionPool,
envd_api: httpx.AsyncClient,
) -> None:
self._envd_api_url = envd_api_url
self._envd_version = envd_version
self._connection_config = connection_config
self._pool = pool
self._envd_api = envd_api
self._rpc = filesystem_connect.FilesystemClient(
envd_api_url,
# TODO: Fix and enable compression again — the headers compression is not solved for streaming.
# compressor=e2b_connect.GzipCompressor,
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)
@overload
async 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
async 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
async 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,
) -> AsyncFileStreamReader:
"""
Read file content as an `AsyncFileStreamReader` (an `AsyncIterator[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 an async context manager or call `aclose()` for
deterministic cleanup. There is no garbage-collection safety net—an
abandoned stream holds its connection until the idle timeout fires or
the client is closed.
: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 an `AsyncFileStreamReader`
"""
...
async 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 = await self._envd_api.send(request, stream=True)
except httpx.RemoteProtocolError as e:
raise await ahandle_envd_api_transport_exception_with_health(
e, self._envd_api
)
err = await _ahandle_filesystem_envd_api_exception(r)
if err:
await r.aclose()
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 AsyncFileStreamReader(r)
try:
r = await self._envd_api.get(
ENVD_API_FILES_ROUTE,
params=params,
headers=headers,
timeout=timeout,
)
except httpx.RemoteProtocolError as e:
raise await ahandle_envd_api_transport_exception_with_health(
e, self._envd_api
)
err = await _ahandle_filesystem_envd_api_exception(r)
if err:
raise err
if format == "text":
return r.text
elif format == "bytes":
return bytearray(r.content)
async 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 = await 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]
async 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:
async def _upload_file(file):
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 = await self._envd_api.post(
ENVD_API_FILES_ROUTE,
content=to_upload_body_async(file_data, gzip),
headers=headers,
params=params,
timeout=upload_timeout,
)
except httpx.RemoteProtocolError as e:
raise await ahandle_envd_api_transport_exception_with_health(
e, self._envd_api
)
err = await _ahandle_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"
)
return [WriteInfo.from_dict(f) for f in write_result]
upload_results = await asyncio.gather(
*[_upload_file(file) for file in files]
)
for file_results in upload_results:
results.extend(file_results)
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 = await 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 await ahandle_envd_api_transport_exception_with_health(
e, self._envd_api
)
err = await _ahandle_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
async 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 = await self._rpc.alist_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 await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async 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:
await self._rpc.astat(
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, connect.ConnectException):
if e.status == connect.Code.not_found:
return False
raise await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async 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 = await self._rpc.astat(
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 await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async 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:
await self._rpc.aremove(
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 await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async 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 = await self._rpc.amove(
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 await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async 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:
await self._rpc.amake_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, connect.ConnectException):
if e.status == connect.Code.already_exists:
return False
raise await _ahandle_filesystem_rpc_exception(e, self._envd_api)
async def watch_dir(
self,
path: str,
on_event: OutputHandler[FilesystemEvent],
on_exit: Optional[OutputHandler[Optional[Exception]]] = None,
user: Optional[Username] = None,
request_timeout: Optional[float] = None,
timeout: Optional[float] = 60,
recursive: bool = False,
include_entry: bool = False,
allow_network_mounts: bool = False,
) -> AsyncWatchHandle:
"""
Watch directory for filesystem events.
:param path: Path to a directory to watch
:param on_event: Callback to call on each event in the directory
:param on_exit: Callback to call when the watching ends. It receives the error that ended the watch, or `None` on a clean end or when `stop()` is called
:param user: Run the operation as this user
:param request_timeout: Timeout for the request in **seconds**
:param timeout: Timeout for the watch operation in **seconds**. Using `0` will not limit the watch time
: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: `AsyncWatchHandle` 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."
)
events = self._rpc.awatch_dir(
filesystem_pb2.WatchDirRequest(
path=path,
recursive=recursive,
include_entry=include_entry,
allow_network_mounts=allow_network_mounts,
),
request_timeout=self._connection_config.get_request_timeout(
request_timeout
),
timeout=timeout,
headers={
**authentication_header(self._envd_version, user),
KEEPALIVE_PING_HEADER: str(KEEPALIVE_PING_INTERVAL_SEC),
},
)
try:
start_event = await events.__anext__()
if not start_event.HasField("start"):
raise SandboxException(
f"Failed to start watch: expected start event, got {start_event}",
)
return AsyncWatchHandle(
events=events,
on_event=on_event,
on_exit=on_exit,
check_health=lambda: acheck_sandbox_health(self._envd_api),
)
except Exception as e:
try:
await events.aclose()
except Exception:
pass
raise await _ahandle_filesystem_rpc_exception(e, self._envd_api)
@@ -0,0 +1,97 @@
import asyncio
import inspect
from typing import Any, AsyncGenerator, Awaitable, Callable, Optional
from e2b.envd.rpc import ahandle_rpc_exception_with_health
from e2b.envd.filesystem.filesystem_pb2 import WatchDirResponse
from e2b.sandbox.filesystem.filesystem import map_entry_info
from e2b.sandbox.filesystem.watch_handle import FilesystemEvent, map_event_type
from e2b.sandbox_async.utils import OutputHandler
class AsyncWatchHandle:
"""
Handle for watching a directory in the sandbox filesystem.
Use `.stop()` to stop watching the directory.
"""
def __init__(
self,
events: AsyncGenerator[WatchDirResponse, Any],
on_event: OutputHandler[FilesystemEvent],
on_exit: Optional[OutputHandler[Optional[Exception]]] = None,
check_health: Optional[Callable[[], Awaitable[Optional[bool]]]] = None,
):
self._events = events
self._on_event = on_event
self._on_exit = on_exit
self._check_health = check_health
self._wait = asyncio.create_task(self._handle_events())
async def stop(self):
"""
Stop watching the directory.
"""
self._wait.cancel()
await asyncio.wait([self._wait])
try:
await self._events.aclose()
except Exception:
pass
async def _iterate_events(self):
try:
async for event in self._events:
if event.HasField("filesystem"):
event_type = map_event_type(event.filesystem.type)
if event_type:
yield FilesystemEvent(
name=event.filesystem.name,
type=event_type,
entry=(
map_entry_info(event.filesystem.entry)
if event.filesystem.HasField("entry")
else None
),
)
except Exception as e:
raise await ahandle_rpc_exception_with_health(e, self._check_health)
async def _call_on_exit(self, error: Optional[Exception]):
if self._on_exit is None:
return
try:
cb = self._on_exit(error)
if inspect.isawaitable(cb):
await cb
except Exception:
# `on_exit` is the terminal callback; an error it raises has nowhere
# to propagate in this background task, so it's swallowed to avoid an
# "Task exception was never retrieved" warning. A `CancelledError`
# (a `BaseException`) is intentionally not caught here.
pass
async def _handle_events(self):
error: Optional[Exception] = None
try:
async for event in self._iterate_events():
cb = self._on_event(event)
if inspect.isawaitable(cb):
await cb
except asyncio.CancelledError:
# `stop()` cancels this task to end the watch. Treat it as a clean,
# user-initiated end: fire `on_exit` (with no error), then propagate
# the cancellation so the task still finishes as cancelled.
await self._call_on_exit(None)
raise
except Exception as e:
error = e
# `on_exit` fires exactly once when the watch ends — with the error when
# the stream failed, or with `None` on a clean end. This matches the JS
# SDK, which calls `onExit()` after the loop completes and `onExit(err)`
# on error.
await self._call_on_exit(error)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,987 @@
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 import make_async_logging_event_hooks
from e2b.api.client.types import Unset
from e2b.api.client_async import get_envd_transport as get_transport
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_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_async.commands.command import Commands
from e2b.sandbox_async.commands.pty import Pty
from e2b.sandbox_async.filesystem.filesystem import Filesystem
from e2b.sandbox_async.git import Git
from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxInfo
from e2b.sandbox_async.paginator import AsyncSnapshotPaginator
from e2b.volume.volume_async import AsyncVolume
from e2b.api.client.models import SandboxVolumeMount as SandboxVolumeMountAPI
logger = logging.getLogger(__name__)
SandboxAsyncVolumeMount = Dict[str, Union[AsyncVolume, str]]
class AsyncSandbox(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 `AsyncSandbox.create()` to create a new sandbox.
Example:
```python
from e2b import AsyncSandbox
sandbox = await AsyncSandbox.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 `AsyncSandbox.create()` to create a new sandbox instead.
"""
super().__init__(**opts)
self._transport = get_transport(self.connection_config)
self._envd_api = httpx.AsyncClient(
base_url=self.envd_api_url,
transport=self._transport,
headers=self.connection_config.sandbox_headers,
event_hooks=make_async_logging_event_hooks(self.connection_config.logger),
)
self._filesystem = Filesystem(
self.envd_api_url,
self._envd_version,
self.connection_config,
self._transport.pool,
self._envd_api,
)
self._commands = Commands(
self.envd_api_url,
self.connection_config,
self._transport.pool,
self._envd_version,
self._envd_api,
)
self._pty = Pty(
self.envd_api_url,
self.connection_config,
self._transport.pool,
self._envd_version,
self._envd_api,
)
self._git = Git(self._commands)
async 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 = await AsyncSandbox.create()
await sandbox.is_running() # Returns True
await sandbox.kill()
await sandbox.is_running() # Returns False
```
"""
try:
r = await 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 = await ahandle_envd_api_exception(r)
if err:
raise err
except httpx.TimeoutException:
raise format_request_timeout_error()
return True
@classmethod
async 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[SandboxAsyncVolumeMount] = 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 AsyncVolume 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: Optional[List[SandboxVolumeMountAPI]] = None
if volume_mounts:
transformed_mounts = [
SandboxVolumeMountAPI(
name=vol.name if isinstance(vol, AsyncVolume) else vol,
path=path,
)
for path, vol in volume_mounts.items()
]
sandbox = await 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 = await 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
async 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 = await AsyncSandbox.create()
await sandbox.pause()
# Another code block
same_sandbox = await sandbox.connect()
```
"""
...
@overload
@staticmethod
async def connect(
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> "AsyncSandbox":
"""
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 = await AsyncSandbox.create()
await AsyncSandbox.pause(sandbox.sandbox_id)
# Another code block
same_sandbox = await AsyncSandbox.connect(sandbox.sandbox_id)
```
"""
...
@class_method_variant("_cls_connect_sandbox")
async 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 = await AsyncSandbox.create()
await sandbox.pause()
# Another code block
same_sandbox = await sandbox.connect()
```
"""
if self.connection_config.debug:
# Skip connecting to the sandbox in debug mode
return self
await SandboxApi._cls_connect(
sandbox_id=self.sandbox_id,
timeout=timeout,
**self.connection_config.get_api_params(**opts),
)
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.kill()
@overload
async 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
async 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")
async 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 await SandboxApi._cls_kill(
sandbox_id=self.sandbox_id,
**self.connection_config.get_api_params(**opts),
)
@overload
async 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
async def set_timeout(
sandbox_id: str,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
"""
Set the timeout of the specified 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 sandbox_id: Sandbox ID
:param timeout: Timeout for the sandbox in **seconds**
"""
...
@class_method_variant("_cls_set_timeout")
async def set_timeout(
self,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
"""
Set the timeout of the specified 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**
"""
await SandboxApi._cls_set_timeout(
sandbox_id=self.sandbox_id,
timeout=timeout,
**self.connection_config.get_api_params(**opts),
)
@overload
async 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
async 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")
async 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.
"""
await SandboxApi._cls_update_network(
sandbox_id=self.sandbox_id,
network=network,
**self.connection_config.get_api_params(**opts),
)
@overload
async 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
async 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")
async 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 await SandboxApi._cls_get_info(
sandbox_id=self.sandbox_id,
**self.connection_config.get_api_params(**opts),
)
@overload
async 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
async 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")
async 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 await SandboxApi._cls_get_metrics(
sandbox_id=self.sandbox_id,
start=start,
end=end,
**self.connection_config.get_api_params(**opts),
)
@overload
async 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
async 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")
async 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 await SandboxApi._cls_pause(
sandbox_id=self.sandbox_id,
keep_memory=keep_memory,
**self.connection_config.get_api_params(**opts),
)
@overload
async def beta_pause(
self,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool: ...
@overload
@staticmethod
async def beta_pause(
sandbox_id: str,
keep_memory: bool = True,
**opts: Unpack[ApiParams],
) -> bool: ...
@class_method_variant("_cls_pause")
async 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 await self.pause(keep_memory=keep_memory, **opts)
@overload
async 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 `AsyncSandbox.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
async 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")
async 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 `AsyncSandbox.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 await 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],
) -> AsyncSnapshotPaginator:
"""
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],
) -> AsyncSnapshotPaginator:
"""
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],
) -> AsyncSnapshotPaginator:
"""
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 AsyncSnapshotPaginator(
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],
) -> AsyncSnapshotPaginator:
return AsyncSnapshotPaginator(
sandbox_id=sandbox_id,
limit=limit,
next_token=next_token,
**opts,
)
@staticmethod
async 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 await SandboxApi._cls_delete_snapshot(
snapshot_id=snapshot_id,
**opts,
)
async 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 = await self.files.read(
"/etc/mcp-gateway/.token", user="root"
)
return self._mcp_token
@classmethod
async 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 = await 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
async 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 = await 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.client.api.sandboxes import get_v2_sandboxes
from e2b.api.client.api.snapshots import get_snapshots
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 import handle_api_exception
from e2b.api.client.models.error import Error
from e2b.api.client_async import get_api_client
class AsyncSandboxPaginator(SandboxPaginatorBase):
"""
Paginator for listing sandboxes.
Example:
```python
paginator = AsyncSandbox.list()
while paginator.has_next:
sandboxes = await paginator.next_items()
print(sandboxes)
```
"""
async 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 = await get_v2_sandboxes.asyncio_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 AsyncSnapshotPaginator(SnapshotPaginatorBase):
"""
Paginator for listing snapshots.
Example:
```python
paginator = AsyncSandbox.list_snapshots()
while paginator.has_next:
snapshots = await paginator.next_items()
print(snapshots)
```
"""
async 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 = await get_snapshots.asyncio_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,504 @@
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.api.client_async import get_api_client
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_async.paginator import AsyncSandboxPaginator
class SandboxApi(SandboxBase):
@staticmethod
def list(
query: Optional[SandboxQuery] = None,
limit: Optional[int] = None,
next_token: Optional[str] = None,
**opts: Unpack[ApiParams],
) -> AsyncSandboxPaginator:
"""
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: An `AsyncSandboxPaginator` that yields pages of sandboxes (running and paused by default). Iterate pages via `await paginator.next_items()` while `paginator.has_next` is True.
"""
return AsyncSandboxPaginator(
query=query,
limit=limit,
next_token=next_token,
**opts,
)
@classmethod
async 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 = await get_sandboxes_sandbox_id.asyncio_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
async 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 = await delete_sandboxes_sandbox_id.asyncio_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
async def _cls_set_timeout(
cls,
sandbox_id: str,
timeout: int,
**opts: Unpack[ApiParams],
) -> None:
config = ConnectionConfig(**opts)
if config.debug:
# Skip setting the timeout in debug mode
return
api_client = get_api_client(config)
res = await post_sandboxes_sandbox_id_timeout.asyncio_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
async def _cls_update_network(
cls,
sandbox_id: str,
network: SandboxNetworkUpdate,
**opts: Unpack[ApiParams],
) -> None:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = await put_sandboxes_sandbox_id_network.asyncio_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
async 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 = await post_sandboxes.asyncio_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"):
await 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
async def _cls_get_metrics(
cls,
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
"""
config = ConnectionConfig(**opts)
if config.debug:
# Skip getting the metrics in debug mode
return []
api_client = get_api_client(config)
res = await get_sandboxes_sandbox_id_metrics.asyncio_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 []
# Check if res.parse is Error
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
async 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 = await post_sandboxes_sandbox_id_snapshots.asyncio_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
async def _cls_delete_snapshot(
cls,
snapshot_id: str,
**opts: Unpack[ApiParams],
) -> bool:
config = ConnectionConfig(**opts)
api_client = get_api_client(config)
res = await delete_templates_template_id.asyncio_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
async 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 = await post_sandboxes_sandbox_id_pause.asyncio_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
@classmethod
async 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
# Sandbox is not running, resume it
config = ConnectionConfig(logger=logger, **opts)
api_client = get_api_client(config)
res = await post_sandboxes_sandbox_id_connect.asyncio_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)
# Check if res.parse is Error
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,
)
@@ -0,0 +1,7 @@
from typing import TypeVar, Union, Callable, Awaitable
T = TypeVar("T")
OutputHandler = Union[
Callable[[T], None],
Callable[[T], Awaitable[None]],
]