chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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]],
|
||||
]
|
||||
Reference in New Issue
Block a user